raft-ui
Version:
React UI components for Raft.
178 lines (119 loc) • 7.38 kB
Markdown
---
name: styling
description: "Styling contract: oklch semantic token families, banned palettes, interaction color pairs, tv() recipe conventions, the data-slot protocol, the var-first shadow trap."
---
# Styling
## Contents
- Tailwind's default palette is banned
- Semantic token families
- Interaction colors come in pairs
- When a raw oklch literal is allowed
- Never write a var-first shadow
- Writing a `tv()` recipe
- Styling by `data-slot`
- Merging classes
---
## Tailwind's default palette is banned
Raft-ui's theme 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 theme.
**Incorrect:**
```tsx
<div className="bg-slate-50 text-gray-500 border-neutral-200" />
```
**Correct:**
```tsx
<div className="border border-line-muted 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/styles.css`.
| 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 `styles.css` before naming a utility.
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, focus rings. This is how the recipes already use it — the tabs active state, the rail's attention badge, and focus rings all draw from the primary family.
- **`accent-*` — core and important actions.** A dialog's confirm, the composer's send. `ComposerSubmit` locks `variant="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.
```tsx
<button className="bg-primary hover:bg-primary-hover active:bg-primary-active" />
```
Not every state family ships the full set — check `styles.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:
```tsx
<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.
---
## Never write a var-first shadow
```tsx
// Incorrect — tailwind-merge misreads this as a shadow-color and conflict
// resolution silently breaks.
<div className="shadow-[var(--theme-shadow-sm),0_1px_2px_oklch(0_0_0_/_0.18)]" />
```
Use a registered named shadow utility such as `shadow-raft-sm`, or keep the shadow fully literal:
```tsx
<div className="shadow-[0_1px_2px_oklch(0_0_0_/_0.18)]" />
```
---
## Writing a `tv()` recipe
Long class strings become arrays, one responsibility per entry. Keep a property together with its own state and responsive variants; split unrelated responsibilities apart. No comments — the grouping carries the intent. Group by:
- **layout** — display, grid/flex, gap, min/max size, overflow
- **typography** — font, size, line-height, weight, wrapping
- **surface** — background, border, radius, shadow, ring
- **state** — hover, active, focus-visible, disabled, selected, invalid
- **child/slot selectors** — icon sizing, `has-*`, `data-*` relationships
```ts
const button = tv({
base: [
"inline-flex items-center justify-center",
"rounded-md text-sm font-medium",
"transition-colors disabled:opacity-50",
],
variants: {
variant: {
primary: ["bg-primary hover:bg-primary-hover active:bg-primary-active", "text-primary-950"],
},
},
});
```
Add a `theme: { brutal, elegant }` variant when the two families genuinely differ, and resolve it from `useThemeFamily()` inside the component — never from a public prop.
**Never construct Tailwind utilities dynamically.** Tailwind's static extraction cannot see them, and the class silently does nothing:
```ts
// 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:
```tsx
// 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. This is also how the library's own style tests select elements.
---
## Merging classes
```tsx
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:
```tsx
// Incorrect
<div className={`px-3 ${active ? "bg-fill-muted" : ""}`} />
// Correct
<div className={cn("px-3", active && "bg-fill-muted")} />
```