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.

CSS
/* 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.

  • background

    The page. Surface level 0.

  • foreground

    Main text. Surfaces mix towards it as they rise.

  • card

    Card background, for components outside Prongs. Prongs looks use surfaces instead.

  • card-foreground

    Text on --card.

  • popover

    Popover background, for components outside Prongs.

  • popover-foreground

    Text on --popover.

  • primary

    The main action and the accent colour.

  • primary-foreground

    Text on --primary.

  • secondary

    Quieter filled controls.

  • secondary-foreground

    Text on --secondary.

  • muted

    Tinted backgrounds that do not layer.

  • muted-foreground

    Quiet text on the page. On surfaces use --surface-muted.

  • accent

    Hover and selection tint.

  • accent-foreground

    Text on --accent.

  • destructive

    Danger: deleting, errors.

  • destructive-foreground

    Text on --destructive.

  • success

    Success text.

  • warning

    Warning text.

  • info

    Neutral notice text.

  • border

    Hairlines on the page.

  • input

    Field borders.

  • ring

    Focus rings.

  • surface

    The background of the surface you are on.

  • surface-hover

    One level up from it: hover fills.

  • surface-active

    Two levels up: pressed and selected fills.

  • surface-inset

    A recess cut into it: wells, code, skeletons.

  • surface-fg

    Text on it.

  • surface-muted

    Quiet text on it, kept readable at every level.

  • surface-border

    A 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.

TS
// 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.

CSS
/* 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.

CSS
: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.