Overview
What to expect
Brio is a CSS library built around native web-platform features. This
page is the mental model -- how to think when you're using brio, what
each piece is for, and what common patterns look like. It is not a
rule reference (see PRINCIPLES.md in the repo for that),
and it is not an API catalog (see the per-component pages for that).
It's the page to read once, early, so the rest of the docs click.
A note on the trade: brio isn't a drop-in library. It works when you commit to its mental model -- compositions instead of bespoke layout, tokens instead of selector overrides, ARIA instead of state classes. There's less to memorize than you might expect (a few principles, a handful of composition primitives, a token grammar), and one good way to express most patterns; there's more to understand up front. The investment is one-time. After that, things tend to do what you'd guess.
Influences worth naming up front:
Every Layout by
Heydon Pickering and
Andy Bell -- the source
of the idea that layout is best expressed as a handful of
named composition primitives like .stack and
.cluster; and
CUBE CSS, also by Andy
Bell, whose Composition / Utility / Block / Exception
structure is brio's organizing skeleton. brio diverges in
places (the exception layer uses class prefixes instead of
data-*, and the slot-class rule is stricter --
see PRINCIPLES.md P2 and P7), but the core
instincts are theirs.
Principles behind brio
Compose; don't invent
Layout comes from small, named composition primitives -- like
.stack and .grid -- one per recurring
layout problem. Components handle theming, naming, and state.
A component does not reinvent layout internally.
What this looks like in practice: an alert is a themed veneer paired
with the .media composition, not a self-contained widget
with its own figure / body slot classes.
The alert carries theming (is-warning remaps tokens). Layout comes from .media. Two composable pieces, one element.
<div class="alert media is-warning">
<svg class="media-figure" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>
<div class="media-body">
<strong>Heads up</strong>
<p>The alert carries theming (is-warning remaps tokens). Layout comes from .media. Two composable pieces, one element.</p>
</div>
</div>The tradeoff: the root element collects multiple class names. The tree in return has fewer named slot classes and reads closer to semantic HTML. Code review shifts from "which of six alert slots is this?" to "this is media; I already know media."
State binds to attributes, not classes
A navigation link for the current page uses aria-current="page",
not .is-active. A disclosed panel uses [open].
A pressed toggle uses [aria-pressed="true"]. A loading
button uses [aria-busy="true"]. CSS reads the attribute:
<nav class="cluster" aria-label="Example nav">
<a class="nav-link" href="#">Home</a>
<a class="nav-link" href="#" aria-current="page">Products</a>
<a class="nav-link" href="#">Pricing</a>
</nav>Benefits that follow from this single rule:
- Server-rendered markup is complete. No
<a class="is-active">that only works after a JS hydration step -- the first byte of HTML already reflects the state correctly. - Screen readers get the state for free. "current page" is an
announced role;
.is-activeis a CSS class with no meaning to assistive tech. :has()composes naturally..card:has([aria-current]) { border-color: primary }without inventing a.card.has-active-itemclass.
Classes in brio do name things: blocks (.card,
.alert), variants and placement (is-*,
as-*), exceptions (no-*). They do not own
interactive state.
Native first
When the platform has grown a capability, brio uses it:
- Overlays use native
<dialog>and the Popover API. Focus trapping, Escape, the backdrop -- all native behavior. Brio's custom elements sit on top to add animated exit and backdrop-click dismissal; they don't reimplement what the browser already does. - Invoker buttons use native
commandandcommandforattributes (a CSSWG-tracked feature now shipping across browsers, with a small polyfill for laggards). - Disclosure widgets are
<details>. Brio adds the chevron rotation and height animation; the open/close toggling is the browser's job. - Colors are
oklch(). Layout is logical properties. Selectors use:has(),:where(),@container,@layer. Every one of these has been a framework feature in the past; all are native now.
What this means for you: brio's JS surface stays small because it doesn't duplicate the browser. When something like a dialog feels surprisingly light, that's because it mostly is the browser, with brio adding a wrapping element for the things the browser doesn't offer (an animated close, a backdrop-click handler).
Accessibility is baseline
WCAG 2.1 AA is the floor for every brio component. Contrast is checked at the token level; focus visibility is built into base styles; keyboard interaction is part of every interactive component. These aren't opt-ins.
When you style with brio, you're starting from a surface that is already keyboard-navigable, already announces its state to screen readers, already meets AA contrast in both light and dark modes. Your job when building on top is not to break it:
- If you add interactive behavior, bind state to ARIA or native
attributes. If you find yourself reaching for a
.is-activeclass, first check whether an ARIA attribute fits -- it usually does. - If you override colors with your own tokens, verify the contrast holds. The tokens page ships a live contrast checker you can compare against.
- If you create custom components with focus states, use
:focus-visiblewith a visible outline -- don't suppress focus rings.
Restraint by default
Reach for the subtler end of every scale first. Shadow
sm before md. Tonal shift before
chroma. 1px before 2px. The question to keep in mind: what
is the smallest change that communicates this signal?
This shows up in brio's defaults. Atoms stay flat. Surfaces elevate only when tinted differentiation cannot carry the lift on its own. State changes are color shifts, not depth changes. Borders are 1px unless emphasis genuinely earns 2px. Animation durations are short and easing curves are gentle.
For CSS authors building on brio: when you override a token toward the louder end of a scale (a bigger shadow, a 2px border, a higher-chroma background), pause to check whether the design needs the lift or whether a quieter alternative (more whitespace, clearer hierarchy, a different composition) would carry the signal. The defaults pull the system back toward quiet; the intentional moments of loudness should be deliberate.
Dynamic content where it's needed
Most brio components are static-renderable -- the HTML you author
at page load is what the user sees. A small set of components
(toast and similar) need to receive content at runtime: a save
confirmation, a server-pushed status, an htmx swap. brio handles
those via a single pattern: pre-author the container with its
live-region landmark; let any path insert items into it; brio's
MutationObserver wires the lifecycle.
The same toast can be triggered three ways without changing the
library: a button calling brio.toast.show(), an htmx
or datastar response swapping a finished toast into the container,
or a framework component calling
container.appendChild(). brio doesn't care which
path delivered the markup -- the contract is "put a
.toast in the container; brio takes it from there."
This keeps server-driven and client-driven code paths reading
the same way.
How the pieces fit
Brio is structured in layers. You'll touch the higher layers day to day; the lower layers are mostly there to be correct by default.
Tokens
Two-tier design tokens. Palette stops (--gray-200,
--blue-500) live privately inside the token file;
semantic tokens (--color-primary,
--color-surface, --space-md) are what
components consume. Override semantic tokens at a scope to re-skin
without fighting specificity.
For spacing there's a third tier between raw tokens and component
vars: a density grammar (--ui-pad-y-snug,
--gap-relaxed) that pairs padding and gap into
consistent steps. Components use the grammar; you override the
grammar when you want everything in a subtree to feel tighter or
airier.
See the Tokens page for the full catalog and a live contrast checker.
Composition primitives
Small, named layout primitives. Each one does one thing well:
.stack for vertical rhythm, .cluster for
horizontal wrapping groups, .grid for auto-fill grids,
.flank for in-page two-column splits (the Every
Layout sidebar pattern),
.media for figure + body rows, and several more.
When in doubt, reach for a composition primitive before writing bespoke CSS. If a pattern keeps recurring in your own code and no primitive fits, that's a candidate for promotion to the library -- or for a pattern in your project-specific docs.
See the Composition page for each primitive with intent, an example, and pitfalls.
Components
Named blocks with theming and (sometimes) behavior, like alerts, dialogs, and form inputs. Each component declares an archetype that tells you how it fits with composition primitives:
- Atom. Single-element primitive with no
children needed. Variants via
is-*andas-*. Examples:.button,.badge,.spinner. - Composition primitive. Layout only, no
visual identity. Theming via scoped CSS vars. Examples:
.stack,.grid,.media. - Slotted container. Structural CSS that
composition primitives cannot absorb. Has named slot
classes that pass the P2 test. Examples:
.card,.dialog. - Semantic wrapper. No classes beyond the
root; all styling on semantic HTML descendants. Examples:
.table,.prose. - Themed veneer. Theming class paired with
a composition primitive. Adds identity (background,
border, color variant); composition handles layout.
Examples:
.alertwith.media,.toastwith.media.
Full definitions and the rules each archetype follows are in
PRINCIPLES.md P3.
Utilities
Single-purpose classes for cases that don't earn their own component or token override. Kept deliberately narrow; brio is not a utility framework and will not grow into one.
What's deliberately not here: utility-framework-style spacing
classes (p-8, m-4, gap-2,
and so on). brio's compositions already handle the work those
utilities target --
.stack for vertical rhythm, .cluster
and .grid for gap, the density grammar
(--ui-pad-*) for component padding. By the time
you'd reach for a spacing utility, one of those three usually
already fits. A parallel utility scale would duplicate work the
composition layer already does, and shifts layout decisions
from "name the shape" to "stack the modifiers."
If your project genuinely needs additional utilities, add them
to your own CSS. brio's brio.css declares the
cascade-layer order
(reset, tokens, animations, base, composition, blocks,
rescopes, utilities, overrides) so consumer styles can
drop into the right layer without specificity wars:
utilitiesfor new single-purpose helpers alongside brio's (a project-specific aspect ratio, a content-width clamp).overridesfor project-wide adjustments to brio's component output (remapping--card-padding-blockfor every card on a marketing site, retoning--color-primaryfor a brand).
The style attribute is fair game in moderation,
especially for one-off token overrides where authoring a class
would be more ceremony than the change deserves --
style="--card-padding-block: var(--space-lg)" on
a single hero card, for example. The line to watch is drift:
the same inline style appearing on three or more elements is a
class waiting to be born; the same property being set inline
across a section is usually a single token override on the
section root.
See the Utilities page for the catalog.
Anti-patterns
A short list of moves that fight brio's grain. None are illegal; they just make future changes harder than necessary.
- Reaching for a new component class for a layout need. Ask first: does a composition primitive do this? Most of the time, yes.
- Reaching for spacing utilities (
p-8,m-4,gap-2) for one-off layout adjustments. brio doesn't ship them on purpose. Use a composition primitive, override the component's scoped padding token, or set a single inline--tokenon the element — see Utilities above for the reasoning. - Using
.is-active,.is-open,.is-loading, or.is-disabledas the source of truth for interactive state. The corresponding ARIA or native attribute is usually the right hook. Keepis-*for variants, sizes, and transient animation states. - Consuming palette stops (
--gray-200,--blue-500) directly. They're private to the token file. Consume semantic tokens (--color-border,--color-primary) or a component's scoped vars. - Overriding styles with higher specificity selectors
instead of scoped tokens.
If a component exposes a token, override the token (in a
scoped class, in the
overrideslayer, or as an inlinestylefor a true one-off). Reach for!importantonly when the cascade has genuinely backed you into a corner (rare, in practice). - Reimplementing a native pattern.
If you're about to write JS to build a modal from scratch,
stop --
<dialog>is already there. Same for disclosure, popover, and invoker buttons.
Where to go next
Read the docs in whichever order fits what you're building. A natural starting order: Tokens (the vocabulary), then Composition (the layout grammar), then individual components as they come up. The component pages assume the vocabulary from the first two.
If you're curious about the technical rules that shape brio's design
(when slot classes earn their name, why a component picks one
archetype over another), that's in PRINCIPLES.md in
the repo. It's written for people extending brio, not using it --
but reading it answers "why is this named this way?" questions.