Web User interface Programming

stylesheet.org is a single stylesheet for building nice-looking sites out of plain, standards compliant HTML. You write the page with the elements HTML already has, and it looks right, with a class only where HTML has no element for the job.

I made it because I was building a lot of sites at once, and I wanted every one of them to start from the same place: one design system, one way of laying things out, and markup that's equally readable to a browser, a screen reader, and me. It grew out of the CSS for an admin system I'd built before, which was already most of the way there, and it has become the stylesheet I reach for on anything new.

The philosophy

The aim is to be standards compliant, and to avoid the sea of divs and classes that most modern front ends turn into. Here's a panel with a title and a Save button, written with Tailwind's utility classes:

<div class="flex items-center justify-between
     gap-4 px-6 py-4 bg-white rounded-xl
     shadow-md border border-slate-200">
  <h2 class="text-xl font-bold tracking-tight
       text-slate-900">Title</h2>
  <button class="inline-flex items-center px-4
       py-2 rounded-lg bg-indigo-600 text-white
       font-medium hover:bg-indigo-700">Save</button>
</div>
A panel with Tailwind

And here's the same panel with stylesheet.org:

<div class="row panel">
  <h2>Title</h2>
  <span class="stretch"></span>
  <button>Save</button>
</div>
The same panel with stylesheet.org

The heading is a heading and the button is a button, and they look like the site's headings and buttons because that's what they are. The only classes are for things HTML has no element for: a row, a panel, and the space that pushes the button to the right. The design lives in the stylesheet, once, instead of being spelled out again on every element of every page.

The basic principles

When a component needs something beyond a bare element, it climbs a ladder, and stops at the first rung that works:

  1. A plain element. A button, a table, a details, a progress. Most things stop here.
  2. An attribute the HTML needs anyway. A switch is a checkbox with role="switch", which a screen reader needs to hear, so the stylesheet styles that. Something loading has aria-busy="true", so that's what shows the spinner.
  3. A class, where HTML has no element and no attribute for the job: .row, .grid, .card, .tabset.
  4. JavaScript, only where HTML and CSS genuinely can't do it, and only for the part that needs it. A toast's animation is pure CSS, and the script just adds the element and removes it after a timeout.

Custom tags are never used, because they mean nothing to a browser or a screen reader, and IDs are avoided wherever HTML allows. Every component's page says plainly whether it needs a class or JavaScript.

The components

The components are grouped into eight sections, mapped out across 109 component pages and being filled in as I go:

SectionComponentsFor example
Structure9Page, section, layout, sidebar layout
Navigation10Navigation bar, breadcrumb, tabset, table of contents
Content18Prose, code blocks, footnotes, margin notes
Container12Card, accordion, callout, carousel
Overlay10Dialog, drawer, popover, tooltip
Data15Table, statistics, timeline, charts
Form25Form layout, switch, range, file upload
Feedback10Progress, meter, busy state, toast
The component sections

The overview lists every component with what it's built on, the classes it needs, and whether it needs JavaScript: none, optional, or required. Most need none. A few of them took more thought than the rest.

Tabs

Tabs were the hardest. The first version was a strip of links and a panel, which is really navigation, and nested divs felt weak. So I looked at the ways a set of tabs can be built:

  • The ARIA tabs pattern, with role="tablist", role="tab" and role="tabpanel", is the standard. But it needs an ID on every panel for aria-controls, and JavaScript to switch between them. And a role="tab" without the script behind it is worse for a screen reader than no role at all.
  • A nav of links with aria-current is right when each tab is a different page, but not for switching content within one.
  • Details elements sharing a name open one at a time, which is exactly an accordion, but they don't make a strip. Restyling them as tabs needed display: contents, which broke the ordering in Chrome.
  • Radio buttons, with CSS's :has() showing the panel after the checked one, need no JavaScript and no IDs, and they're a real form control: a native radio group gets arrow keys, and "1 of 3", for free.

The radio buttons won. Each label holds a radio, and its panel is whatever element comes straight after it, with no class:

<div class="tabset">
  <label><input type="radio" name="view" checked> Overview</label>
  <div>…the Overview content…</div>
  <label><input type="radio" name="view"> Rules</label>
  <div>…the Rules content…</div>
</div>
A tabset
.tabset > label:has(input:checked) { background: var(--accent); }
.tabset > label:has(input) + * { order: 2; }
.tabset > label:has(input:not(:checked)) + * { display: none; }
The core of the tabset's CSS (trimmed)

A few details came out of using it for real. The tabs and panels sit side by side in the markup rather than in two lists, so a set can have any number of tabs, straight from a database, with no CSS for each position. A script can read the selection from the radio's change event. A tabset inside another goes inside a form, which scopes the radio names, so every set can use the same name. And there's exactly one class, .tabset, on a plain div, because nothing in HTML means "a set of tabs". The trade-off is that a screen reader announces them as radio buttons rather than tabs, which is honest about what they are.

Accordions

An accordion is the opposite case: HTML already has it. Each row is a details with a summary, giving them the same name makes only one open at a time, and the .accordion class just joins the rows into one bordered block. The browser owns the open and closed state, so there's no JavaScript at all.

Cards

.card is purely styling, so it can go on whatever element the content really is: an article, a section, a p. A card that's a link all over has two forms. The card can be the a itself, which is simple but means it can't contain any other links, or a .stretch-link can stretch the heading's link over the whole card, which allows other links inside, at the cost of the text not being selectable.

Switches, dialogs and busy states

These are the attribute rung of the ladder. A switch is <input type="checkbox" role="switch">, with no class. Anything loading gets aria-busy="true", which is what tells a screen reader to wait, and the stylesheet shows the spinner from the same attribute. A dialog is a dialog opened by a button with commandfor, with no JavaScript. That does bring an ID back, because commandfor names the dialog it opens, so the dialogs are where the rule against IDs bends.

The layout system

Layout uses the same rule as the user interface library in Angular best practices for large teams: components carry no outer margin, and spacing is always the parent's job. Everything is laid out in rows, columns and grids, and each of them spaces its immediate children with a single gap:

.row, .column { display: flex; gap: var(--gap); }
.row { flex-direction: row; }
.column { flex-direction: column; }
.row > *, .column > * { flex: 0 0 auto; min-width: 0; min-height: 0; }
.stretch { flex: 1 1 0; }
.grid { display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
Rows, columns and grids

Children keep their natural size unless they're told to .stretch into the space that's left, and an empty .stretch is a spacer, as in the panel above. Because the spacing comes from the parent, anything put in a row is spaced correctly, including components the stylesheet has never heard of, like a React component or a custom widget. Sizes that are genuinely one-off go in an inline style.

The standard page layout

A page has one shape. The body holds a header for the site's navigation, any number of nav elements for the section's navigation, one main, and a footer:

<body>
  <header> … site navigation … </header>
  <nav>    … section navigation … </nav>
  <main>   … the page itself   … </main>
  <footer> … site footer        … </footer>
</body>
The page

Inside main, every heading opens a section of its own, with the heading as its first child, and the sections nest to mirror the outline of the page, an h2 for each section, an h3 for each subsection inside it, and so on:

<main>
  <h1>Page title</h1>
  <section>
    <h2>A section</h2>
    <section>
      <h3>A subsection</h3>
    </section>
  </section>
</main>
Sections nested to match the headings

At first I wasn't sure section tags were worth having at all. What settled it is that a heading belongs to the content under it, and wrapping the two together in a section gives the CSS complete control: a section can take a background without any extra markup, and the vertical rhythm comes from the tree, with more space between top-level sections than between subsections. Headings carry no spacing of their own, and nothing on the page needs a class to be spaced correctly.

A full CSS reference

The site also contains a full CSS reference: everything in CSS that ships in browsers today, meaning supported without a prefix in at least one of Chrome, Firefox and Safari, with no drafts. That comes to 1,308 entries, built from the W3C's machine-readable CSS specifications and MDN's browser compatibility data:

  • Properties: 580 of them, across 20 themes, from the box model and flexbox to anchor positioning and scroll-driven animation.
  • Selectors: the basic selectors, combinators, pseudo-classes and pseudo-elements.
  • At-rules: with their descriptors, media features and container features.
  • Functions: colour, maths, gradients, transforms, easing and the rest.
  • Types: with their units, keywords and named colours.
  • A–Z: every entry by name, and a search on the reference's front page.

Every entry has the same table of facts: its syntax as the specification gives it, its initial value, what it applies to, whether it's inherited and animatable, the first browser version that supports it, and a link to the specification.

See also