Theming
The token system behind the kit, and how to re-theme it without touching a component.
Every visual decision in Faber UI resolves through a token. No component holds a color, a duration, or an easing curve of its own, which is what makes the kit re-themeable: changing the brand is an edit to one file, not an audit of sixty.
The layers
Tokens are layered, and each layer is only allowed to reference the one above
it. The stylesheet entry point, packages/ui/src/styles/globals.css, is import
order and nothing else.
| Layer | File | Holds |
|---|---|---|
| Primitives | tokens/primitives.css | The knobs (hue, chroma, radius, spacing) and the raw ramps generated from them. The only file with color maths in it. |
| Presets | tokens/presets.css | Named overrides of those knobs, addressed by data attribute. |
| Typography | tokens/typography.css | Families, size scale, tracking, leading. |
| Motion | tokens/motion.css | Durations, easings, and the reduced-motion collapse. |
| Elevation | tokens/elevation.css | The layered, hue-tinted shadow scale. |
| Semantic | tokens/semantic.css | What components consume: --primary, --muted, --border. Contains no raw color. |
| Bridge | bridge/fumadocs.css | Points the documentation chrome at the same tokens. |
| Theme | theme.css | Publishes all of it as Tailwind utilities. |
The rule that matters: components use the semantic layer, never the
primitives. A bg-brand-600 in a component is a color that will not follow a
future theme change; bg-primary is.
Re-theming
With a preset
<html data-brand="emerald" data-neutral="stone" data-radius="lg" data-density="compact">Presets work scoped as well as globally, because the ramps are re-derived on any element carrying a knob:
<section data-brand="amber">
<!-- everything in here is amber -->
</section>blue (default), azure, cyan, teal, emerald, lime, amber, orange,
rose, magenta, violet, indigo, mono.
mono is a near-neutral brand for a deliberately quiet product: color appears
only where it carries meaning, namely focus, links, and status.
With your own hue
Set the two knobs directly:
:root {
--brand-hue: 312;
--brand-chroma: 0.14;
}Chroma has a per-hue ceiling. sRGB is not a cylinder. It is far deeper in
violet than in cyan, so violet holds about 0.155 before clipping while cyan
manages only 0.095. Exceeding the ceiling does not raise an error and does not
give a more saturated ramp: the browser gamut-maps the overflow, the top steps
stop being distinguishable from one another, and the ramp flattens exactly where
it should be strongest. The measured ceiling for each named hue is in
tokens/presets.css.
Motion
Durations and easings are tokens, so timing is adjustable kit-wide.
--easing-out: cubic-bezier(0.23, 1, 0.32, 1); /* entering and exiting */
--easing-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* moving on screen */
--easing-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* sheets and drawers */
--duration-quick: 160ms; /* press feedback, popup exits */
--duration-base: 200ms; /* popovers, menus, selects, tooltips */
--duration-moderate: 250ms; /* dialogs, sheets */
--duration-slow: 320ms; /* drawers */They are available as utilities (ease-out, ease-drawer, duration-quick,
duration-slow), and Tailwind's transition defaults route through them, so a
bare transition-colors already picks up the kit's baseline timing.
Popups enter and exit through three utilities built on Base UI's
data-starting-style and data-ending-style attributes: motion-popup for
anything anchored to a trigger, motion-modal for centered dialogs and
motion-backdrop for their overlays. They use transitions rather than
keyframes, so a popup closed halfway through opening reverses from where it is.
Exits run faster than entrances, and a popup marked data-instant skips the
animation: Base UI sets it on a tooltip opened while another was just showing,
and CommandDialog sets it because a palette opened from the keyboard should
never animate.
For press feedback, add press to any clickable element. It scales to
--scale-press over --duration-quick and owns the element's hover color
transition too, so don't pair it with transition-colors.
There is deliberately no general ease-in token. It delays the first frame,
which is the exact moment a user is watching, so an interface using it reads as
sluggish. The one legitimate case, an element accelerating away as it leaves,
has its own token: ease-exit.
The same values are available to JavaScript from
@/lib/motion (the motion registry item), including spring presets and the momentum
projection used for flick gestures:
import { easing, durationSec, spring } from "@/lib/motion";Prefer CSS where the animation is expressible in CSS. CSS animations run off the
main thread and hold their frames while the browser is busy routing or
hydrating, where requestAnimationFrame-driven libraries drop them.
Accessibility
Three user preferences are handled by the token layer, so components inherit them without opting in.
prefers-reduced-motioncollapses the duration tokens and flattens the easing curves, and the scale and travel distances drop to zero so elements cross-fade in place rather than travelling. Reduced motion means gentler, not absent, so opacity transitions that aid comprehension are kept, and spinners pulse instead of rotating rather than stopping after one turn.prefers-contrast: moreswaps the low-alpha borders and tinted subtle surfaces for solid colors and defined edges.prefers-reduced-transparencydropsbackdrop-filteron translucent chrome, which falls back to an opaque surface.
Color pairings are checked rather than assumed. In both themes, body text, muted text, the primary button, links and the focus ring all meet WCAG AA against the surfaces they actually sit on.
Dark mode inverts the primary pairing: a light brand step carrying dark text. The intuitive alternative, a mid brand step under light text, measures around 4.4:1 and fails AA, and it fails for every hue, not just this one. A dark background leaves no room to darken the button for contrast, so the button has to get lighter instead.
Adding a token
Semantic tokens go in tokens/semantic.css in both the :root and .dark
blocks, then get published as a utility in theme.css:
/* tokens/semantic.css */
:root { --highlight: var(--brand-100); }
.dark { --highlight: color-mix(in oklab, var(--brand-500) 18%, transparent); }/* theme.css */
@theme inline {
--color-highlight: var(--highlight);
}Never write --x: var(--x) in @theme. The theme block emits onto :root, and
so do the token files, so a same-name mapping is a self-reference on one
element. That is invalid at computed-value time and silently resolves to
nothing. This
is why the source tokens use distinct names from the Tailwind namespaces that
republish them: --track-heading becomes --tracking-heading, --easing-out
becomes --ease-out, --font-sans-src becomes --font-sans.
Last updated on