Skip to content
PolyFish CSS

Theming

Never edit a component rule to change a colour. Every visual decision is a custom property, and custom properties cascade — so a theme is a stylesheet that redeclares a handful of them after Core.

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.

theme
--pf-accent
--pf-cut
radius
density

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.

Accent Outline Pill

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.

your-theme.csscss
: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-cut sets the depth of the clipped corner on .pf-facet and .pf-card--facet. Set it to 0 and every angular surface becomes a plain rectangle.
  • --pf-radius-lg drives cards, plan cards, heroes and CTA bands. Drop it to 4px for a sharper, more technical feel, or raise it to 32px for 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:

compact.csscss
: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:

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

themes/slate.csscss
/* 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

RoleEvery component that reads itSkip it and
--pf-bgPage, nav, hero, sectionsThe page keeps the default navy
--pf-bg-deepFooters, code blocks, alt sections, menusBands stop separating from the page
--pf-surfaceCards, list rows, inputs, badgesCards read as the page, not as objects
--pf-surface-raisedHover states, addons, progress tracksHover feedback disappears
--pf-textBody copy and headingsContrast fails immediately
--pf-text-mutedLedes, help text, metadata, captionsSecondary copy shouts as loud as primary
--pf-accentButtons, links, markers, switches, focusThe theme has no voice
--pf-accent-2Secondary buttons, second-voice washesFalls back to the default apricot
--pf-borderEvery hairline on the pageStructure blurs together
--pf-border-strongEmphasis borders, outline buttonsAccented components lose their edge
--pf-base-950Text on an accent fillButton 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.

scoped.csscss
/* 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.

Other things you can poke

Every documentation page on this site has something interactive in it: swap page regions, retint a live page, change the type scale, see what each module unlocks, assemble your link block, frame a real page at 320px, weigh a page type, build an attribute list, turn the invisible guards on, flip a component through its states and filter the changelog by tag. All of it is radio inputs, checkboxes and :has() — no script runs any of it.