Theming
Give prfct your brand colors, a neutral tint, a radius or a mode, globally or for a single subtree, without touching a component.
Every prfct component reads its colors, radii and shadows from CSS custom properties. Theming means changing those properties; components never need to be edited. Try it live in the theme builder.
Same components, same classes — the subtree sets the mode.
Same components, same classes — the subtree sets the mode.
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
function Panel({ mode, label }: { mode: "light" | "dark"; label: string }) {
return (
<div
className={`${mode} flex flex-col gap-4 rounded-xl border bg-background p-5 text-foreground`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{label}</span>
<Badge variant="brand">Scoped</Badge>
</div>
<p className="text-sm text-muted-foreground">
Same components, same classes — the subtree sets the mode.
</p>
<div className="flex items-center justify-between">
<Switch defaultChecked aria-label={`Notifications, ${label}`} />
<Button size="sm">Continue</Button>
</div>
</div>
)
}
export default function ScopedTheme() {
return (
<div className="grid w-full max-w-2xl gap-3 sm:grid-cols-2">
<Panel mode="light" label="Light" />
<Panel mode="dark" label="Dark" />
</div>
)
}Light and dark
prfct ships both modes. The default setup uses next-themes to follow the operating system, persist a choice, and avoid a flash of the wrong theme:
import { ThemeProvider } from "@/components/theme-provider"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}ThemeProvider passes its props to next-themes and adds one of its own: hotkey, a single key that toggles light and dark, as in hotkey="d". It is off by default, because a single-key shortcut has to be something people opt in to (WCAG 2.1.4).
Scoped themes
Modes are defined on classes, not only on the root, so any subtree can switch:
<aside className="dark">…</aside> {/* a dark panel on a light page */}
<figure className="light">…</figure> {/* a light preview on a dark page */}This is how prfct's own documentation renders light and dark swatches side by side. It works because components express every mode difference through variables — none of them use dark: utilities.
Brand colors
prfct ships black and white. Both brand colors are monochrome, so checked states, progress, focus rings and links are black, or white in dark mode, and color appears only where it means something: status, data, and whatever your brand adds. A theme gives the system up to two brand colors.
export const themeConfig: ThemeConfig = {
gray: neutralPresets.neutral,
brand: huePresets.blue, // or { hue: 150, chroma: 0.15, lightness: 0.55 }
brandSecondary: "mono",
}In your project, the theme builder does this in the browser: it accepts your brand's hex values and gives you the CSS to paste. It also reads a brand from its address: /themes?brand=violet opens with a preset, /themes?hue=150 with a hue in degrees. In the prfct repository, pnpm tokens rebuilds styles/tokens.css from the same code.
Primary brand
A brand is generated from three numbers: hue, chroma and the lightness of its solid step, step 9. Three optional ones tune it: hueShift drifts the hue across the scale, tintBoost adds chroma to steps 3 to 8, and darkLightness sets a different solid for dark mode. All 12 steps are regenerated for both modes, and the text steps are re-solved for contrast. The primary brand colors whatever shows selection or focus: checked controls, sliders, progress, the focus ring, links and text selection (brand, ring, link, selection). It is also the first chart series, chart-1.
The solid step is solved for the text placed on it, not against the page. A light brand, with a solid lightness above about 0.65 such as the amber preset, fills checked controls at less than 3:1 on white; their check mark and thumb still carry the state. The focus ring is different, because it has nothing else to rely on: ring points at brand-focus, which is the solid when it stands 3:1 from every surface and the brand's text step when it doesn't, in each mode.
Secondary brand
The secondary brand, brand-secondary with steps brand-secondary-1 to brand-secondary-12, is never used by a component on its own. It's yours to place: a plan badge, a highlight in a chart, an illustration. Keep it for emphasis; two colors competing for attention cancel each other out.
<Badge variant="brand-secondary">Pro</Badge>
<p className="text-brand-secondary-11">Included in your plan</p>Brand-colored primary buttons
Primary buttons are black by default, so the one colored element on a page is the one that means something. If your brand calls for colored buttons, map primary to it. The theme builder writes this for you when you turn on Brand-colored primary buttons.
:root,
.light,
.dark {
--primary: var(--brand-9);
--primary-foreground: var(--brand-contrast);
}Neutral tint
The gray scale can carry a subtle hue. Seven presets ship with prfct:
Radius
:root {
--radius: 0.5rem;
}Every radius step is calculated from --radius, so 0 gives a sharp, technical interface and 0.75rem a soft, friendly one. See Shape.
Overriding semantic tokens
For one-off requirements, override semantic tokens after prfct's stylesheet. Map them to a scale step rather than a raw color, so dark mode keeps working. Classic blue links on an otherwise monochrome interface, for example:
:root,
.light,
.dark {
--link: var(--blue-11);
}