I made SuCSS, a classless CSS framework

I made SuCSS, a classless CSS framework.
You don’t write class names or custom attributes. You write plain HTML like <header>, <article>, and <button>, and styles land on it. It goes on 素 (su) HTML, so: SuCSS.

The demo is at https://ryotasugawara.github.io/su-css/. I published it to npm as well.

The SuCSS demo page. Headings and body copy that use no class names sit on frosted-glass panels

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ryo9ra/su-css/dist-lib/sucss.min.css">

What got me started wasn’t a technical itch. It was daily life.
I’m on parental leave, and I was looking for things I could do with one hand while holding a baby.
Anything that starts with setting up a local environment is a bad fit. Something that lives entirely in the cloud is better. That’s how I found Google AI Studio. I was already paying for the AI Pro plan, so I decided to actually use it.

I threw it the constraints: classless, dark mode, accessibility, theme via CSS variables only.

The code that came back was enough, in the sense that it ran and didn’t look bad.
Almost everything I did after that wasn’t adding features. It was taking the constraints I’d asked for, which that code didn’t actually meet, and making it meet them. It didn’t use classes, but you still had to memorize attributes to use it. It advertised accessibility, and the contrast still failed WCAG AA. That’s mostly what the rest of this is about.

Being strict about classless

At the start, the framework CSS had a fair number of custom data attributes. Seven of them: data-tabs, data-secondary, data-grid, data-flex, data-badge, data-header-container, data-footer-container.

If you have to memorize attribute names instead of class names, calling it classless is pointless, so I deleted all of them.
Deleting didn’t mean dropping features. I moved them onto standard attributes. Secondary buttons are type="button" and type="reset" instead of data-secondary. Badges land on mark / samp / output / [role="status"] instead of data-badge.

/* Secondary / Action Button variants via standard type attribute */
button[type="button"],
button[type="reset"],
input[type="button"],
input[type="reset"] {
  background-color: var(--bg-subtle);
  color: var(--text-main);
  border: 1px solid var(--border-color);
}

Layout helpers like data-grid and data-flex didn’t belong in the framework, so I dropped them. Places on the demo page that need a specific arrangement write that CSS on the HTML directly.
The only custom attribute left in the framework is data-theme. I wrote in CONTRIBUTING.md that it doesn’t get siblings.

Modals are <dialog>, accordions are <details>/<summary>, toggle switches are <input type="checkbox" role="switch">. All of them work without JavaScript.

Put every color in a token

Colors live in custom properties at the top of the file. Rules don’t write colors directly. Change the number in --hue once, and the whole page’s palette shifts, including the background gradient.

:root {
  --hue: 262;
  --sat: 85%;
  --color-primary: hsl(var(--hue) var(--sat) 44%);
  --radius: 1rem;
  --glass-blur: 20px;
}

Dark mode follows prefers-color-scheme, and you can also force it with data-theme="light" / data-theme="dark".
That means the token-declaration blocks split into several: light, dark, and forced [data-theme]. Typo a variable name here and one theme quietly breaks, and you don’t notice.

Testing CSS

So I wrote tests. Not “open a browser and compare by eye.” Tests that load the CSS file itself and check the values written in it, mechanically.

One is contrast. It picks text/background pairs out of the CSS per theme and computes whether each pair has enough light-dark difference to be readable. The bar is WCAG AA: 4.5:1 or higher between text and background. Because the theme declarations are split across three blocks, the “one theme quietly breaks” case fails here too.

When I wrote it, it actually found failures. The button background against the white text on top of it was 2.87:1 in light and 1.84:1 in dark. That’s a long way from 4.5:1. A stylesheet that advertised accessibility was failing the bar on its most visible button. I fixed light by darkening the button background. For dark I wanted to keep the background bright because of how it sits with link color, so I changed the text from white to a dark color.

The other test checks that features claimed in the README are actually in the CSS. No animation for people who’ve turned animation off. A visible keyboard focus. Buttons and inputs large enough to hit with a finger. Those items are confirmed by whether the corresponding rules exist in the CSS. If a rule removes the focus outline and doesn’t provide a replacement, it fails there.

I wanted the claims in the README to be more than claims. These two were the parts I was gladdest I wrote.

I replaced the look entirely

Along the way I swapped the design wholesale to frosted-glass panels floating on a color gradient. All I did was ask Claude Code to “make it look like this.” That was pretty easy.

The current dist is 4.7KB minify + gzip. Supported browsers are Chrome/Edge 111+, Safari 16.4+, Firefox 128+.

Things I hit around release

npm has a way to hold a package back without publishing it. CI can only get it that far. It actually ships after I approve with 2FA. I wanted a shape where even if CI is compromised, that alone doesn’t reach users.

The thing that took the longest was a release workflow that died the instant it started. No jobs ran, so looking at logs gave almost nothing to go on.

GitHub Actions can call one workflow from another. When that happens, the callee can’t ask for stronger permissions than the caller passed in. The release workflow had one job that needed write access to create a GitHub release, and the caller was only passing read.
That job had a condition saying “don’t run on this path,” but permission checks happen before jobs start, so the whole run is rejected before the condition is evaluated. I didn’t want to hand unused write access to every caller, so I split that job into its own workflow.

The other hits: I ran a release with a version that was already published, so after build and tests passed it died at the end with You cannot publish over the previously published versions: 0.0.1. And after I renamed the repo from sucss to su-css, the demo site looked for files under the old name and GitHub Pages went blank.

Applying it to the blog

After I made it, I swapped this blog’s CSS from Bulma to SuCSS. I dropped all the utility classes and rewrote to main / header / article / section, so the diff is almost all deletions.

One problem showed up after applying it: putting a header element inside article collides with the sticky site header, so I don’t use header inside posts. I wasn’t sure whether to fix that in the framework or avoid it on the consuming side. For now I avoid it.

The other thing: this blog runs astro-purgecss at build time, so I had to add a safelist so the classless CSS didn’t get wiped as a block.

purgecss({
  safelist: {
    standard: [":root", "html", "body"],
    greedy: [/data-theme/],
  },
}),

The styles on this page are that. When you make the thing you use yourself, the next thing you want to fix is the next commit, so I’ll keep touching it at 0.x for a while.


🤖 Generated with Claude Code