Skip to content

Getting started

Documentation

Everything here is plain CSS. There is nothing to install, nothing to compile and no runtime. If a page can load a stylesheet, it can use this framework.

Copy the files and link them

Copy src/ into whatever directory your site serves static assets from. The four files under core/ are mandatory and are the only thing that is. Everything else is optional and independent.

<!-- mandatory, in this order for readability, though order does not matter -->
<link rel="stylesheet" href="/css/core/reset.css">
<link rel="stylesheet" href="/css/core/tokens.css">
<link rel="stylesheet" href="/css/core/type.css">
<link rel="stylesheet" href="/css/core/base.css">

A page that imports core and nothing else is already themed, readable and keyboard operable: the reset normalises the browser, the tokens carry both themes, the type file sets a fluid scale with real measure caps, and the base file styles bare elements. No class is needed for any of that.

Import order cannot break you

Every file in the framework opens with the same layer statement and then writes into exactly one of those layers. A layer statement is one of the two at-rules allowed before @import, so the order is fixed the moment any framework file is parsed, whichever one that happens to be.

@layer bare.reset, bare.tokens, bare.base, bare.layout, bare.component, bare.utility;
bare.reset
Browser normalisation. Removes inherited inconsistency, imposes no look.
bare.tokens
The whole custom property surface, including both themes.
bare.base
Bare element defaults. No classes.
bare.layout
The layout primitives: shell, grid, stack, cluster, sidebar, split.
bare.component
Everything with a block, an element and a modifier.
bare.utility
Single-purpose classes. Last, so no utility needs !important.

Your own CSS sits outside every layer, and unlayered rules beat layered ones, so a single class of your own already wins over any framework rule with no specificity fight and no !important.

A page pays only for what it shows

No component file imports a sibling. Importing the table cannot drag in the modal, and shared values live in tokens rather than in cross-imports. That is what makes the cost of a page a function of what is on it.

The page you are reading links twenty-four files. The home page links twenty-three, and neither pays for the components it does not use.

Two barrel files exist, src/forms/index.css and src/data/index.css, for a page that genuinely uses most of a group. They are convenience, never the cheap path.

Light by default, dark on request, and a class always wins

Three states, not two. With no class on the document element the page follows prefers-color-scheme, which means light unless the system asks for dark. Adding .theme-light or .theme-dark pins the choice, and it beats the system preference in both directions.

The part implementations usually get wrong is that source order alone does not achieve this: a later media query still beats an equal-specificity class rule placed before it. The token file solves it twice over. The media query targets :root:not(.theme-light), so a pinned light theme is never matched rather than merely overridden afterwards, and the pinned blocks sit at specificity (0,2,0) against the plain :root default at (0,1,0).

Theme for this site

The choice is stored in localStorage and applied before the first paint on every page, so there is no flash. Currently unknown.

Scoping works too. The classes are written as both :root.theme-light and a bare .theme-light, so a single panel can carry the opposite theme from the page around it.

Custom properties are the API

Every component reads its own hooks with a global token as the fallback. The pattern is always var(--bs-card-bg, var(--s-card)): set the hook and the component changes, leave it alone and it follows the theme.

/* your stylesheet, no build step involved */
:root {
  --bs-card-radius: 0;              /* square cards everywhere        */
  --bs-wrap-max: 72rem;             /* narrower page shell            */
  --bs-btn-radius: 9999px;          /* pill buttons                   */
}

.panel--quiet {
  --bs-card-bg: var(--s-prev);      /* one card, one surface          */
  --bs-card-shadow: var(--bs-shadow-0);
}

One caution the framework itself follows: a custom property declared on an element inherits into everything inside it. That is why the framework's own modifiers set the used value rather than declaring a hook, and why a hook you set on a container will also retune the same primitive nested inside it. Set hooks on the element you mean.

What the CSS guarantees, and what it cannot

Contrast, focus visibility, target size, motion preference and reduced-motion behaviour are properties of the stylesheets and are enforced by tests in this repo. Every text and background pairing that ships is measured against every surface in both themes.

Everything that is behaviour is yours. A tab strip is a roving tabindex, a modal is showModal(), a menu returns focus to its trigger, a scrollable table needs a name and a tabindex. Each component file states its own obligations in the comment at the top, and the documentation pages repeat them next to the example.

Focus
:focus-visible and never :focus, so a pointer user is not shown a ring they did not ask for and a keyboard user always is. The ring is never removed anywhere.
Target size
The reset floors every button at the WCAG 2.2 SC 2.5.8 minimum, so a control cannot ship under target size by omission.
Motion
The reset clamps every animation and transition under prefers-reduced-motion, so a component author who forgets the media query still cannot ship vestibular-triggering motion.
Colour
No state is signalled by colour alone. Every variant that changes a fill also changes a bar, an icon slot, a weight or a marker.

Every file, measured

Produced by bun run size. Raw, gzip and brotli are the file compressed on its own. The last column is what adding that one file to core costs in a single gzip stream, which is the number that matters when the claim is that a page pays only for what it uses.

Size of every file in bytes.
File Raw gzip brotli Adds to core
src/core/base.css 5,748 2,357 1,849 n/a
src/core/reset.css 5,659 2,375 1,867 n/a
src/core/tokens.css 17,752 5,843 4,815 n/a
src/core/type.css 16,114 5,108 4,163 n/a
src/data/avatar-group.css 3,507 1,431 1,114 1,085
src/data/avatar.css 3,295 1,350 1,034 996
src/data/badge.css 4,646 1,903 1,522 1,457
src/data/card.css 7,204 2,579 2,041 2,037
src/data/description-list.css 3,358 1,408 1,138 1,061
src/data/divider.css 3,622 1,364 1,083 997
src/data/empty.css 4,288 1,693 1,343 1,309
src/data/index.css 854 412 333 284
src/data/kbd.css 2,532 1,114 887 784
src/data/list.css 3,607 1,377 1,082 1,013
src/data/stat.css 4,179 1,615 1,312 1,219
src/data/table.css 12,040 3,731 3,007 3,195
src/data/tag.css 5,142 2,097 1,691 1,675
src/feedback/alert.css 5,635 2,187 1,770 1,742
src/feedback/banner.css 5,974 2,040 1,611 1,635
src/feedback/modal.css 8,553 3,083 2,486 2,545
src/feedback/popover.css 6,925 2,582 2,044 2,104
src/feedback/progress.css 5,314 1,994 1,564 1,609
src/feedback/skeleton.css 3,897 1,579 1,231 1,219
src/feedback/spinner.css 2,761 1,203 929 898
src/feedback/toast.css 7,957 2,816 2,289 2,338
src/feedback/tooltip.css 5,594 2,220 1,764 1,772
src/forms/button-group.css 3,891 1,391 1,084 1,124
src/forms/button.css 10,549 3,672 2,939 3,067
src/forms/checkbox.css 3,908 1,632 1,269 1,238
src/forms/error.css 3,781 1,640 1,294 1,304
src/forms/field.css 2,913 1,326 1,009 1,042
src/forms/file.css 5,071 1,764 1,379 1,352
src/forms/help.css 1,833 906 700 599
src/forms/index.css 1,012 447 336 302
src/forms/input.css 5,210 2,017 1,581 1,587
src/forms/label.css 2,810 1,217 942 872
src/forms/radio.css 3,302 1,342 1,036 987
src/forms/range.css 2,186 1,103 818 866
src/forms/select.css 6,755 2,359 1,888 1,946
src/forms/switch.css 6,883 2,346 1,846 1,856
src/forms/textarea.css 4,334 1,651 1,284 1,242
src/layout/center.css 2,697 1,199 927 950
src/layout/cluster.css 3,049 1,419 1,104 1,113
src/layout/container.css 3,322 1,328 1,059 1,044
src/layout/grid.css 5,488 2,405 1,953 2,044
src/layout/sidebar.css 3,692 1,612 1,287 1,332
src/layout/spacer.css 2,305 1,146 885 923
src/layout/split.css 3,230 1,418 1,136 1,143
src/layout/stack.css 2,369 1,096 879 850
src/motion/reveal.css 8,327 3,150 2,502 2,726
src/motion/transitions.css 8,093 2,780 2,208 2,419
src/nav/breadcrumb.css 4,566 1,852 1,458 1,395
src/nav/drawer.css 9,874 3,339 2,646 2,854
src/nav/menu.css 8,918 2,966 2,343 2,452
src/nav/navbar.css 9,665 3,391 2,693 2,801
src/nav/navlink.css 6,037 2,201 1,765 1,721
src/nav/pagination.css 6,747 2,340 1,889 1,862
src/nav/sidebar-nav.css 7,062 2,505 2,005 1,895
src/nav/tabs.css 9,504 3,276 2,654 2,720
src/prose/code.css 7,757 2,873 2,307 2,297
src/prose/prose.css 10,042 3,578 2,869 2,910
src/prose/quote.css 5,904 2,397 1,896 1,853

The two index.css barrels contain only imports, which is why they appear to add almost nothing on their own.