Color
A closed, generated palette in OKLCH. Twelve steps per hue, one job per step, and contrast that is solved rather than hoped for.
prfct's color system is small on purpose. There is no blue-500 to reach for and no hex code to paste. Every color in the product comes from a 12-step scale, and every step exists to do one specific job. Pick the job, and the step picks itself.
import { Badge, BadgeDot } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
import { Switch } from "@/components/ui/switch"
export default function MeaningfulColor() {
return (
<div className="flex w-full max-w-sm flex-col gap-5 rounded-xl border bg-card p-5 shadow-raised">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Deploy preview</span>
<Badge variant="success">
<BadgeDot />
Ready
</Badge>
</div>
<Progress value={72} aria-label="Build progress" />
<div className="flex items-center gap-2.5">
<Checkbox id="notify" defaultChecked />
<Label htmlFor="notify">Notify me when it's live</Label>
</div>
<div className="flex items-center gap-2.5">
<Switch id="auto" defaultChecked />
<Label htmlFor="auto">Promote automatically</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm">
View logs
</Button>
<Button size="sm">Promote</Button>
</div>
</div>
)
}Principles
Monochrome by default. Surfaces, text and primary actions are neutral. A calm canvas lets the few colored elements carry meaning instead of competing for attention.
Color is a signal, not a decoration. prfct reserves hue for three jobs:
- Brand marks state and selection: checked boxes, active switches, progress, focus rings, links. It's monochrome until a theme gives it a color.
- Status communicates outcomes: success, warning, danger and information.
- Categorical hues distinguish series in charts and tags — never meaning.
Never color alone. Every state that is shown with color is also shown with a shape, an icon or text, so it survives color blindness, grayscale printing and forced-colors mode (WCAG 1.4.1).
Scales
Eleven scales ship with prfct: a neutral gray, two themeable brand scales (brand and brand-secondary), four status hues and four categorical hues. Both modes are rendered below from the same stylesheet.
Click any swatch to copy its token. Both modes are rendered live from the same CSS: every panel is a scoped .light or .dark subtree.
The scales are generated in OKLCH, a perceptually uniform color space: equal steps in lightness look equal, across every hue. That is why a warning amber and an info blue at the same step sit at the same visual weight — something HSL cannot do.
Anatomy of a scale
Each of the twelve steps has a single role. Components never pick steps by eye; they pick them by role.
- Backgrounds
- 1 · 2
- App canvas and subtle surfaces such as sidebars, wells and striped rows.
- Component fills
- 3 · 4 · 5
- Resting, hovered and pressed or selected fills of interactive elements.
- Borders
- 6 · 7 · 8
- Subtle separators, default component borders, and strong or hovered borders.
- Solids
- 9 · 10
- The purest expression of the hue, for filled buttons, badges and indicators, and its hover.
- Text
- 11 · 12
- Low-contrast text (≥ 4.5:1 on every fill, steps 1–5) and high-contrast text (≥ 11:1).
Semantic tokens
Most of the time you won't touch scale steps at all. Semantic tokens name the purpose of a color, and map to a step in each mode. Their names follow shadcn/ui's convention — background, primary, muted-foreground — so every shadcn-compatible component works with prfct unchanged.
Surfaces
The page canvas.
Recessed areas that frame content: sidebars, wells, code.
Raised surfaces: cards, panels, tiles.
Floating surfaces: menus, popovers, tooltips, dialogs.
Default text and icons.
Actions
The single most important action. Monochrome by default.
Supporting actions and quiet fills.
De-emphasized fills: tracks, skeletons, tab rails.
Hover and highlight fill for items in lists and menus.
Secondary text, descriptions, placeholders. Meets 4.5:1.
Brand
Brand color. Selection, focus, progress and checked states. Monochrome until a theme sets it.
The second brand color, for highlights you choose. Monochrome until a theme sets it.
Inline links.
Text selection highlight.
Keyboard focus indicator: the brand solid, or its text step when the solid is too light. Meets 3:1 against every surface, for any brand.
Status
Destructive actions and errors. Fills use danger-9; text uses danger-11.
Positive outcomes.
Caution. Always paired with dark text.
Neutral information.
Lines
Structural borders: cards, dividers, tables. Decorative.
Emphasized structure and hovered outlines.
Boundaries of form controls. Meets 3:1 against the page (WCAG 1.4.11).
Hairline ring around floating surfaces (menus, popovers, dialogs).
Scrim behind modal surfaces.
States
Hovered secondary fills; pressed toggles.
Pressed secondary fills; highlighted items on a muted surface.
Boundary of a hovered form control.
Outline of a hovered or pressed outline control.
Hovered brand fills.
Data
Series 1.
Series 2.
Series 3.
Series 4.
Series 5.
// Reach for semantic tokens first…
<p className="text-muted-foreground">Last edited 2 hours ago</p>
// …and scale steps when you're building a component that needs a specific role.
<span className="bg-brand-3 text-brand-11">Beta</span>Brand
prfct ships black and white: the brand scale follows the neutral one, so selection, progress and focus are black, or white in dark mode. That restraint is the point. With nothing else colored, a status color is impossible to miss.
Products bring their own color. A theme sets up to two brand colors, primary and secondary, from a preset, a hex value or three numbers, and all twelve steps of each are regenerated with their contrast guarantees. Status colors stay put, so success never turns into your brand color. See Theming and the theme builder.
Status colors
Status hues have semantic aliases, so components express what happened rather than which color to paint:
redambergreenblueimport {
CircleCheckIcon,
CircleXIcon,
InfoIcon,
TriangleAlertIcon,
} from "lucide-react"
// Status aliases resolve to hue scales: success → green, warning → amber,
// danger → red, info → blue.
const statuses = [
{ name: "success", scale: "green", label: "Deployed", icon: CircleCheckIcon },
{
name: "warning",
scale: "amber",
label: "Degraded",
icon: TriangleAlertIcon,
},
{ name: "danger", scale: "red", label: "Failed", icon: CircleXIcon },
{ name: "info", scale: "blue", label: "Queued", icon: InfoIcon },
] as const
export default function StatusColors() {
return (
<div className="grid w-full max-w-2xl gap-3 sm:grid-cols-2">
{statuses.map(({ name, scale, label, icon: Icon }) => (
<div
key={name}
className="flex items-center gap-3 rounded-xl border p-3"
style={{
backgroundColor: `var(--${scale}-2)`,
borderColor: `var(--${scale}-6)`,
}}
>
<span
className="flex size-9 items-center justify-center rounded-lg"
style={{
backgroundColor: `var(--${scale}-9)`,
color: `var(--${scale}-contrast)`,
}}
>
<Icon className="size-4" aria-hidden="true" />
</span>
<div className="flex flex-col">
<span
className="text-sm font-medium"
style={{ color: `var(--${scale}-12)` }}
>
{label}
</span>
<span
className="font-mono text-xs"
style={{ color: `var(--${scale}-11)` }}
>
{name}-2 · 6 · 9 · 11 · 12
</span>
</div>
</div>
))}
</div>
)
}Use the same recipe everywhere: step 2 for the background, 6 for the border, 9 for a solid fill (with -contrast as its foreground), 11 for text and icons, 12 for a title on the tinted background. Alerts use the whole recipe. Badges fill with step 3, the component fill, under step 11 text. Form errors use step 11 for icon and text, and toasts for the icon alone.
Contrast
Contrast in prfct is a property of the palette, not a review step. When a scale is generated, its text steps are solved numerically against its fills:
- Step 11 reaches at least 4.5:1 on every fill of its own scale — steps 1 to 5, including hover and pressed states — which is WCAG 2.2 AA for body text. In dark mode it also reaches 5:1 on step 3, because halation makes thin light-on-dark text harder to read.
- Step 12 reaches at least 11:1 on step 3.
- Every solid (step 9) ships with a
-contrastforeground that meets 4.5:1: white where possible, a deep tint of the same hue where white fails (amber and orange). - Form control boundaries use the
inputtoken, which meets 3:1 against the page for WCAG 1.4.11 — the part most design systems quietly skip.
The matrix below is computed from the generated palette at build time. If a change ever broke a guarantee, the table would say so — and so would the test suite.
prefers-contrast: more), prfct strengthens borders, control boundaries and secondary text automatically. In Windows High Contrast (forced-colors: active), components defer to system colors and focus rings stay visible because they are drawn with outlines, not shadows.Dark mode
Dark mode is not an inversion. Each scale is generated twice with separate lightness and chroma curves: dark backgrounds are near-black rather than pure black to avoid smearing on OLED screens, tints carry more chroma so they still read as color at low lightness, and text steps are solved against dark fills. Surfaces gain elevation by getting lighter, not by casting shadows nobody can see.
Any subtree can switch modes by adding a class — useful for previews, embedded panels and marketing sections:
<section className="dark">…</section> {/* dark, even on a light page */}
<section className="light">…</section> {/* light, even on a dark page */}Rules
- Don't use color alone. Pair status colors with an icon or text.
- Don't invent steps. If a color seems to be missing, a role is probably being misused — revisit the table above.
- Don't recolor components with
className. Use a variant, or add one to the component so the decision is shared.
For engineers
The palette is data. Scales are defined by a handful of numbers in lib/tokens/palette.ts and generated by lib/color/scale.ts; pnpm tokens writes styles/tokens.css and DTCG JSON for design tools. Tailwind's default palette is removed, so a stray bg-blue-500 fails to exist rather than silently diverging from the system.
export const huePresets = {
ultramarine: { hue: 267, chroma: 0.24, lightness: 0.515, darkLightness: 0.55 },
// …
}