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).
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-visibleand 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.
| 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.