Styling
Use theme variables directly in plain CSS. The utility and recipe examples below apply to Tailwind consumers.
Contents
- Tailwind's default palette is banned
- Semantic token families
- Interaction colors come in pairs
- When a raw oklch literal is allowed
- Controls cast no shadow
- Never write a var-first shadow
- Writing your own
tv()recipe - Styling by
data-slot - Merging classes
Tailwind's default palette is banned
Raft-ui's token contract is authored in oklch and exposed through semantic tokens. Tailwind's built-in palette is not part of that contract and will not track the light and dark modes.
Incorrect:
<div className="bg-slate-50 text-gray-500 border-neutral-200" />Correct:
<div className="border border-line-hairline bg-fill-muted text-foreground-muted" />Never write hex, rgb(), or hsl() either.
Semantic token families
Reach for these first. The token contract lives in raft-ui/foundation.css; raft-ui/styles.css maps it to Tailwind utilities.
| Family | Purpose | Registered suffixes |
|---|---|---|
layer-* | elevation surfaces | canvas canvas-muted panel popover backdrop inset card hud hud-foreground |
fill-* | opaque neutral fills | muted strong |
line-* | borders | bare line, then strong muted hairline field field-hover |
foreground-* | text and icons | bare foreground, then strong muted hint icon placeholder disabled inverse active hover |
ink-* | neutral washes on transparent surfaces | bare ink, then 2 4 6 8 10 16 20 30 40 |
State families do not share one uniform suffix set. primary and accent expose numeric ramps plus strong, soft, hover, and active; info exposes strong, muted, soft, hover, and active; success exposes foreground, strong, muted, and soft; warning and danger expose foreground, strong, muted, soft, hover, and active. Check foundation.css for values and styles.css for utility names.
The ink ramp step is the alpha percentage — ink-8 is 8% ink. Use it for hairlines, hover fills, and pressed states on transparent backgrounds.
Within the state families, primary and accent split roles rather than rank:
primary-*(Source Yellow) — hints and interactive expression. New-message indicators, attention badges, the active tab. The tabs active state draws from the primary family. Focus rings do not: they are ink, a 2px paper gap plus a 2px ink ring (shadow-focus,--focus-ring).accent-*— core and important actions. A dialog's confirm, the composer's send.ComposerSubmitlocksvariant="accent"at the type level.
Do not use yellow for an action button just because it is the brand color, and do not use accent for a passive indicator.
Opaque tokens and alpha washes composite differently on non-white backdrops. Never swap one for the other during a refactor without checking the actual background.
Interaction colors come in pairs
Hover and active states on solid fills are derived tokens, not opacity hacks.
<button className="bg-brand hover:bg-brand-hover active:bg-brand-active" />Not every state family ships the full set — check foundation.css before assuming success-hover exists. Adding a family to the interaction contract means defining its complete hover/active pair in the token layer, not patching one callsite.
When a raw oklch literal is allowed
One-off precision values — a tuned tint, a glass surface, a terminal black — may be written inline:
<div className="bg-[oklch(0.21_0.006_285)]" />Tokenize on the rule of three: once the same value appears in a third component, it earns a token. Do not invent a token for a single consumer.
Controls cast no shadow
Controls never cast a shadow. shadow-float (--elevation-float) is the
overlay shadow: menus, popovers, dialogs, sheets, toasts, tooltips.
shadow-sticker (--elevation-sticker) is a hard offset under brand
illustration, such as the xl avatar.
Never write a var-first shadow
// Incorrect — tailwind-merge misreads this as a shadow-color and conflict
// resolution silently breaks.
<div className="shadow-[var(--elevation-float),0_1px_2px_oklch(0_0_0_/_0.18)]" />Use a registered named shadow utility such as shadow-float, or keep the shadow fully literal:
<div className="shadow-[0_1px_2px_oklch(0_0_0_/_0.18)]" />Writing your own tv() recipe
Never construct Tailwind utilities dynamically. Tailwind's static extraction cannot see them, and the class silently does nothing:
// Broken — the class is never generated.
const padding = `px-${size}`;Use static recipe entries (a size variant with literal classes) instead.
Styling by data-slot
Raft-ui's DOM-rendering parts stamp stable data-slot attributes (data-slot="dialog-content", data-slot="banner-action", …). Behavior-only roots and providers that render no element have no slot. Recipes use this DOM protocol for structural relationships (has-[[data-slot=…]]).
When your styling depends on a part's presence or identity, target data-slot, never the part's class names:
// React to a slot's presence from a parent you control:
<div className="grid has-[[data-slot=banner-action]]:grid-cols-[1fr_auto]">Class names are recipe internals and change between releases; data-slot values are the stable contract.
Merging classes
import { cn } from "raft-ui/cn";cn is exported from the package. Do not add clsx or tailwind-merge as direct dependencies to compose raft-ui classes.
Conditional classes go through cn, not template-literal ternaries:
// Incorrect
<div className={`px-3 ${active ? "bg-fill-muted" : ""}`} />
// Correct
<div className={cn("px-3", active && "bg-fill-muted")} />