UI
Theme and surfaces
The registry gets its colours from one theme file, and its surfaces layer themselves: nesting decides the shade, and quiet text stays readable at every level.
theme.css
The registry is the only place classes live, and its colours come from one file. prongs add writes components/prongs/theme.css and imports it into your global CSS, right after Tailwind. If your project already defines these colours, your own values win because they come later.
/* app/globals.css: prongs add writes the second line */
@import "tailwindcss";
@import "../components/prongs/theme.css";
/* Your values, after the import, win */
:root {
--primary: oklch(0.55 0.2 260);
}Next to the usual primary, muted and destructive there are success, warning and info, and the surface colours below. The names are the contract; the values are yours.
backgroundThe page. Surface level 0.
foregroundMain text. Surfaces mix towards it as they rise.
cardCard background, for components outside Prongs. Prongs looks use surfaces instead.
card-foregroundText on
--card.popoverPopover background, for components outside Prongs.
popover-foregroundText on
--popover.primaryThe main action and the accent colour.
primary-foregroundText on
--primary.secondaryQuieter filled controls.
secondary-foregroundText on
--secondary.mutedTinted backgrounds that do not layer.
muted-foregroundQuiet text on the page. On surfaces use
--surface-muted.accentHover and selection tint.
accent-foregroundText on
--accent.destructiveDanger: deleting, errors.
destructive-foregroundText on
--destructive.successSuccess text.
warningWarning text.
infoNeutral notice text.
borderHairlines on the page.
inputField borders.
ringFocus rings.
surfaceThe background of the surface you are on.
surface-hoverOne level up from it: hover fills.
surface-activeTwo levels up: pressed and selected fills.
surface-insetA recess cut into it: wells, code, skeletons.
surface-fgText on it.
surface-mutedQuiet text on it, kept readable at every level.
surface-borderA hairline that shows on it.
Surfaces that layer themselves
A panel inside a panel should sit one step above it. Usually someone picks the shade by eye, the inner panel ends up the same colour as the outer one, and a new "panel-inner" style appears. In Prongs the intent says what the box is (layout.panel) and the nesting decides the shade. Below, the same intent is used at every level.
A panel on the page
Quiet text stays readable here. Hover the options.
A panel inside it
Quiet text stays readable here. Hover the options.
<Layout intent="layout.panel"> // same intent at every level
And inside that
Quiet text stays readable here. Hover the options.
Four levels deep
Quiet text stays readable here. Hover the options.
Hover fills, quiet text and hairlines follow the surface they sit on, so the hover on the deepest panel is as visible as the one on the page. An overlay, like a dialog, floats over the page and starts its own stack wherever it is opened.
// The registry's surface looks, from the starter
'surface.panel': { binding: { classes: 'surface rounded-lg border border-surface-border' } },
'surface.sunken': { binding: { classes: 'surface-inset rounded-lg' } },
'surface.overlay': { binding: { classes: 'surface-overlay m-auto rounded-lg border border-surface-border shadow-xl' } },
// Anything that sits on a surface uses the surface colours
'control.ghost': { binding: { classes: 'text-foreground hover:bg-surface-hover' } },
'tone.muted': { binding: { classes: 'text-surface-muted' } },How it works
Plain CSS with no JavaScript, so it works in Server Components: surface sits one level above the surface it is in, surface-inset is a recess (a well, a code block, a field), and surface-overlay restarts the count for something that floats. Put surface-page on an element that sets its own --background or --foreground, like a themed profile or a preview, and the levels under it are mixed from its colours. Elements in between that are not surfaces do not count.
/* theme.css: depth comes from nesting, not from a number someone picked */
.surface { --surface-level: 1; }
.surface .surface { --surface-level: 2; }
.surface .surface .surface { --surface-level: 3; }
/* every level is mixed from two colours, so dark mode and your palette come free */
--surface-base: color-mix(in oklch, var(--foreground) calc(var(--surface-step) * var(--surface-level)), var(--background));
--surface-hover: /* the same, one level up */;
--surface-muted: color-mix(in oklch, var(--foreground) var(--surface-muted-mix), var(--surface-base));Because every level is mixed from --background and --foreground, a theme sets two colours and gets every level, in light and dark. Dark surfaces get lighter as they rise, which is how elevation reads there.
Dark mode follows the system. To let people choose, put class="dark" or class="light" on <html>; with neither, the system decides.
:root {
--background: #fafaf9;
--foreground: #1a1918;
--surface-step: 4%; /* how far each level moves towards the foreground */
--surface-muted-mix: 63%; /* more ink for quiet text on a softer palette */
}
@media (prefers-color-scheme: dark) {
:root {
--surface-step: 5%; /* dark mode needs bigger steps to read */
--surface-overlay-level: 2;
}
}Readable at every level
A fixed muted colour that passes on the page fails on the panels above it. text-surface-muted is mixed from the surface it sits on instead. These are the default theme's contrasts, computed the way the browser mixes them; the Prongs tests fail if any level drops below 4.5:1.
- light pagetext 19.8:1surface-muted 6.3:1muted-foreground 4.7:1
- light 1text 17.9:1surface-muted 6.1:1muted-foreground 4.3:1 ✗
- light 2text 16.2:1surface-muted 5.8:1muted-foreground 3.9:1 ✗
- light 3text 14.6:1surface-muted 5.6:1muted-foreground 3.5:1 ✗
- light 4text 13.1:1surface-muted 5.3:1muted-foreground 3.1:1 ✗
- light overlaytext 19.8:1surface-muted 6.3:1muted-foreground 4.7:1
- light overlay 1text 17.9:1surface-muted 6.1:1muted-foreground 4.3:1 ✗
- light overlay 2text 16.2:1surface-muted 5.8:1muted-foreground 3.9:1 ✗
- light overlay 3text 14.6:1surface-muted 5.6:1muted-foreground 3.5:1 ✗
- dark pagetext 19.0:1surface-muted 6.1:1muted-foreground 7.6:1
- dark 1text 17.8:1surface-muted 6.1:1muted-foreground 7.2:1
- dark 2text 16.2:1surface-muted 5.9:1muted-foreground 6.5:1
- dark 3text 14.4:1surface-muted 5.6:1muted-foreground 5.8:1
- dark 4text 12.5:1surface-muted 5.2:1muted-foreground 5.0:1
- dark overlaytext 16.2:1surface-muted 5.9:1muted-foreground 6.5:1
- dark overlay 1text 14.4:1surface-muted 5.6:1muted-foreground 5.8:1
- dark overlay 2text 12.5:1surface-muted 5.2:1muted-foreground 5.0:1
- dark overlay 3text 10.6:1surface-muted 4.7:1muted-foreground 4.3:1 ✗
With your own colours, check the deepest level you use. A softer palette needs a higher --surface-muted-mix, as this site's does.
Checked
prongs check reads the registry's classes too. unknown-token fails a var() that no CSS file declares, which would otherwise render as nothing. raw-value warns about a colour the theme cannot change: a literal like bg-[#e5383b] or a palette colour like text-sky-700. See the check rules.