Try it
Every control below sets one custom property on the stage. No classes are swapped and no script runs — the components re-render because none of them reference a raw value. This is the entire theming argument, made interactively.
Facet card
Border, surface tint, clipped corner and icon well all resolve from roles.
- Marker colour is the accent
- Body copy is the muted role
Nothing was reclassed
The alert, the badge and the switch below are the same markup at every setting.
Radio inputs, :has() and custom properties. Where :has() is unsupported the stage shows its default state and the controls do nothing.
Override the roles
Load your theme file after Core and redeclare on :root. Because components reference roles rather than the palette, this repaints buttons, links, focus rings, borders, alerts, tables and the mesh in one go.
:root {
--pf-bg: #0f172a;
--pf-bg-deep: #020617;
--pf-accent: #38bdf8;
--pf-accent-2: #fb7185;
--pf-text: #e2e8f0;
--pf-text-muted: #94a3b8;
--pf-border: rgba(148, 163, 184, .22);
--pf-border-strong: rgba(56, 189, 248, .45);
--pf-focus: #38bdf8;
--pf-base-950: #04121f;
}
Watch the ink
--pf-base-950 is the text colour on filled accent buttons. If you move to a dark accent, raise it to a light value or the button label disappears.
Light islands
Add .pf-invert to any element — the body, one section, a single card — and the roles flip to a light palette inside it. Nested components follow automatically because they were never referencing the palette in the first place.
Default surface
Roles resolve to the dark palette. Muted text sits at --pf-base-300.
.pf-invert
Same markup, same classes. Only the roles changed, and the accent stays put.
Shape and density
Two properties change the personality of the whole framework without touching a component.
--pf-cutsets the depth of the clipped corner on.pf-facetand.pf-card--facet. Set it to0and every angular surface becomes a plain rectangle.--pf-radius-lgdrives cards, plan cards, heroes and CTA bands. Drop it to4pxfor a sharper, more technical feel, or raise it to32pxfor a softer one.
Density is the space scale. Multiplying the four largest steps tightens every section, grid and stack at once, because nothing hard-codes padding:
:root {
--pf-space-6: 1.5rem;
--pf-space-7: 2rem;
--pf-space-8: 3rem;
--pf-space-9: 4rem;
--pf-cut: 0px;
}
Scoped themes
Roles are inherited, so scoping a theme to part of the page is the same technique with a different selector. This is how you give one section its own accent without a second stylesheet:
.promo {
--pf-accent: var(--pf-hue-2);
--pf-border-strong: rgba(229, 177, 137, .45);
}
Everything inside .promo — buttons, links, markers, focus rings — picks up the orange. Everything outside is untouched.
No rebuild, ever
Because this is all runtime cascade, a theme can be swapped by changing one link tag, or toggled by adding a class to <html>. There is no compile step to run and no output to purge.
Anatomy of a theme file
A theme is a stylesheet that redeclares roles. It loads after Core and nothing else about the framework changes. The four shipped with the site are each under forty lines.
/* Monospace display face, no mesh, tight radii. */
:root {
--pf-bg: #0d1117;
--pf-bg-deep: #010409;
--pf-text: #e6edf3;
--pf-text-muted: #8b949e;
--pf-accent: #7dd3fc;
--pf-base-950: #04121f;
--pf-font-display: "IBM Plex Mono", ui-monospace, monospace;
--pf-radius-lg: 6px;
--pf-cut: 0px;
--pf-mesh: none;
}
The eleven roles that carry a theme
| Role | Every component that reads it | Skip it and |
|---|---|---|
--pf-bg | Page, nav, hero, sections | The page keeps the default navy |
--pf-bg-deep | Footers, code blocks, alt sections, menus | Bands stop separating from the page |
--pf-surface | Cards, list rows, inputs, badges | Cards read as the page, not as objects |
--pf-surface-raised | Hover states, addons, progress tracks | Hover feedback disappears |
--pf-text | Body copy and headings | Contrast fails immediately |
--pf-text-muted | Ledes, help text, metadata, captions | Secondary copy shouts as loud as primary |
--pf-accent | Buttons, links, markers, switches, focus | The theme has no voice |
--pf-accent-2 | Secondary buttons, second-voice washes | Falls back to the default apricot |
--pf-border | Every hairline on the page | Structure blurs together |
--pf-border-strong | Emphasis borders, outline buttons | Accented components lose their edge |
--pf-base-950 | Text on an accent fill | Button labels may fail contrast |
The one duty a theme cannot skip
Recolour the accent and you have changed the contrast of every button label, link and focus ring on the site. A pastel that reads at 10.7:1 on deep navy reads at 1.6:1 on near-white — the same hex, a different page.
Check these five pairs, every time
Text on background. Muted text on background. Accent on background. Ink on accent. Ink on the accent's hover shade. All five need 4.5:1 for body-size text, and the framework's own palette holds 9:1 or better on all of them so there is headroom for a retint.
Scoping a theme to part of a page
Roles are inherited custom properties, so declaring them on an element themes that subtree
and nothing else. This is how .pf-invert works and how the playground retints a
live page without touching the page around it.
/* One section on a warm palette, the rest of the page untouched. */
.promo {
--pf-bg: #171310;
--pf-accent: #f5b544;
--pf-base-950: #1a1208;
background: var(--pf-bg);
}
Two cautions. A scoped theme must set its own background, because
redeclaring --pf-bg does not repaint anything by itself. And .pf-invert
uses a doubled class to reach specificity (0,2,0), because a component declared later in the
cascade would otherwise win with a single class.
Beyond colour
Shape
--pf-radius, --pf-radius-lg and --pf-cut. Setting --pf-cut: 0 removes the framework's signature clipped corner everywhere without editing a component.
Rhythm
--pf-section-pad, --pf-gap and --pf-gap-lg. Three properties move the whole page from dense to airy; the density control in the playground sets exactly these.
Type
--pf-font-display, --pf-font-body and --pf-font-mono. Swap the display face for a serif and the framework reads as a different product; the scale is unchanged.
Next: the component reference.