Skip to content
PolyFish CSS

Tokens, theming and motion

Every custom property, retheming from eleven roles, scoped palettes, and the effects layer that keeps motion honest.

Documentation menu

01 / 03

Tokens

Every value in the framework is a custom property declared on :root in Core. Components reference roles, never the palette, which is what makes a reskin an eleven-line change.

Palette

Raw values. Change these to shift the whole brand, or leave them and remap the roles below.

TokenValueUsed for
--pf-base-900#00030fDeep surfaces: footers, code blocks, alt sections
--pf-base-800#000823Page background
--pf-base-700#000f3fRaised surface — the lightest background in the framework
--pf-base-600#17306eInfo alert rule
--pf-hue-1#8fe06aAccent: buttons, links, markers
--pf-hue-1-deep#6dc44aAccent hover and press
--pf-hue-1-washrgba(143, 224, 106,.12)Accent tint behind icons and cards
--pf-hue-2#e5b189Secondary accent
--pf-hue-2-deep#c48f66Secondary hover
--pf-base-50#e9eefaPrimary text
--pf-base-300#a9bcd8Muted text
--pf-base-950#00081fText on accent fills

Roles

These are what components actually reference — no component reads a palette token directly, and the checker fails the build if one does. Override the roles and every module follows.

TokenDefaultMeaning
--pf-bg--pf-base-800Page background
--pf-bg-deep--pf-base-900Recessed background, alternating sections
--pf-surfacewhite 4.5%Cards, alerts, plan cards
--pf-surface-raisedwhite 8%Hover state for surfaces
--pf-text--pf-base-50Body and strong text
--pf-text-muted--pf-base-300Secondary text, help text
--pf-ink--pf-base-950Text on an accent fill
--pf-accent--pf-hue-1Primary actions and links
--pf-accent-2--pf-hue-2Secondary emphasis
--pf-borderbase-300 22%Hairline borders
--pf-border-strongaccent 42%Emphasised borders
--pf-focus--pf-accentFocus ring colour

One colour in, a whole theme out

Hover, wash, line and ring colours are derived from the accent with color-mix(), so a theme can be a single declaration:

:root { --pf-accent: #d9734e; }

Buttons darken toward that colour on hover, badges and accent cards take a wash of it, emphasised borders and focus rings follow it. Override any derived token directly when the mix is not what you want.

TokenDerived as
--pf-accent-hoveraccent 82% mixed with black, in OKLab
--pf-accent-washaccent at 12%
--pf-accent-lineaccent at 35% — also the focus ring
--pf-accent-2-hover / -wash / -linethe same, from --pf-accent-2

One caveat, which is how CSS works rather than a choice: custom properties resolve where they are declared. Set --pf-accent on :root and everything follows. To retint one region, add .pf-retint to the element carrying the new accent — it re-derives the dependent colours there.

.promo { --pf-accent: #7aa7ff; }

<section class="promo pf-retint">…</section>

Readability

Six decisions, each of which can be overridden, and each of which was made in the same direction: toward the reader.

DecisionWhy
Base is 17–19px, fluidThe 16px default is inherited from 1996 monitors, not from a reading study. Body copy grows with the viewport rather than staying fixed.
Line height 1.75, prose 1.8The largest readability lever after size. Tightens as type grows, because a 60px headline at 1.7 leaves its lines unrelated.
66-character measureThe comfortable range is roughly 45–75. Past that, the return sweep to the next line starts landing in the wrong place.
Nothing below 13.6pxThe old 12px step is a decision to have the text not read. The smallest size is now 0.8125rem.
Muted text at 6.79:1It was 5.49:1 — passing AA and still the first thing people squint at. Secondary text is still text.
Links underlined by defaultA link identified only by colour is invisible to anyone who cannot separate that colour from the text around it. text-decoration-skip-ink keeps the underline out of the descenders.

Headings also carry more space above than below, because a heading belongs to what follows it. Without that asymmetry a page reads as a list of fragments rather than as sections.

Type

Three stacks and a nine-step scale. The top four steps are fluid, clamped between a phone and a desktop width.

TokenValue
--pf-font-displayBricolage Grotesque, Trebuchet MS, system-ui
--pf-font-bodyInter, system-ui, -apple-system, Segoe UI, Roboto
--pf-font-monoIBM Plex Mono, ui-monospace, Menlo
--pf-text-xs … -base0.8125, 0.9375, 1rem — and the base itself is fluid, 17px to 19px
--pf-text-lg … -2xl1.1875, 1.5, 1.9375rem
--pf-text-3xl … -5xlfluid: 2.25→3, 2.75→4.4, 3.25→6rem
--pf-leading1.75 body, 1.8 prose, 1.22 snug, 1.06 display
--pf-measure66ch, applied to p and li

Try the scale

Three controls, six token overrides, no reclassing. The specimen below is ordinary markup — a heading, a lede, body copy and a card — reading whatever the stage declares.

type scale
--pf-measure
--pf-leading

Specimen

Large headers, generous spacing

The lede reads --pf-text-lg and the measure token, so it narrows and widens with the controls above.

Body copy sits at --pf-text-base and is capped at --pf-measure. That cap is the one thing the framework imposes on your content, and it is the difference between a readable column and a line the eye cannot track back to. Sixty-eight characters is the default; narrower suits a sidebar, wider suits a reference page where tables want the room.

Card title

Card copy reads --pf-text-sm, so it moves with the scale but stays a step below the body.

--pf-text-xs --pf-text-sm --pf-text-base --pf-text-lg --pf-text-xl --pf-text-2xl --pf-text-3xl

Radio inputs and :has(). The stage declares tokens; every component inside reads them.

Tip: change tokens, never component rules

If you find yourself editing a rule inside a module to change a colour, radius or gap, stop — there is a token for it, and editing the rule means every future update is a merge conflict. The exception is genuinely new behaviour, which belongs in your own unlayered stylesheet where it beats the framework anyway. See why your override may not be applying.

Space and shape

Nine space steps from 4px, multiplied by a single density knob. Radii are deliberately few — angles do the distinctive work through --pf-cut.

TokenValue
--pf-density1.2 — multiplies every space step, both gaps and the section rhythm
--pf-space-1 … -9.25, .5, .75, 1, 1.5, 2, 3.5, 5.5, 8.5rem × density
--pf-space-fluid-1 … -5Grow smoothly between 320px and 1440px screens: .75→1, 1→1.5, 1.5→2.5, 2→4, 3→7rem
--pf-gapclamp(1.5rem, 3vw, 2.5rem) × density — grid and stat gaps
--pf-gap-lgclamp(2.5rem, 5.5vw, 4.5rem) × density — splits, shells, section heads
--pf-section-padclamp(5rem, 11vw, 9.5rem) × density — vertical rhythm between sections
--pf-radius-sm / (default) / -lg6px, 12px, 22px
--pf-radius-pill999px
--pf-cut18px — depth of the clipped corner on facet surfaces
--pf-ring3px of --pf-accent-line, used on focused inputs
--pf-shadow-sm / (default) / -lgRaised rows; menus and popovers; dialogs and hero media

Density

Set --pf-density on :root to make a whole site tighter or roomier with one number — 0.85 suits an admin screen, 1.5 an editorial page. For one region, use .pf-compact (0.8) or .pf-airy (1.4): they recompute the scale on that element, which setting the variable on a random element would not. See it on the components page.

Layout and motion

TokenValue
--pf-container987px — one measure for every page. --pf-container-narrow 720px, --pf-container-wide the same 987px unless you raise it
--pf-gutterclamp(1.25rem, 5vw, 3rem)
--pf-minPer-grid override for .pf-grid--auto, default 260px
--pf-switchContainer width where .pf-switcher flips to a column, default 40rem
--pf-side / --pf-main-min.pf-sidebar: ideal side width 16rem; stack when main drops below 55%
--pf-cover-minMinimum height of .pf-cover, default 100dvh
--pf-speed / --pf-ease160ms, cubic-bezier(.2,.7,.3,1) — the default transition
--pf-ease-out / -in / -in-outArrivals, departures, and moves within the page
--pf-ease-springA linear() curve with a slight overshoot, for menus and toasts
--pf-duration-1 … -4120, 200, 320, 520ms
--pf-meshThe triangle pattern, as an inline SVG data URI

Four easings and four durations is deliberate. Libraries that ship thirty easings tend to get thirty different ones used, and motion stops feeling like one product. When a reader asks for reduced motion, Core shortens every transition and animation to effectively zero under prefers-reduced-motion: reduce, so anything you build on these tokens inherits that without extra work.

Renamed in 0.16 and 0.17

Palette tokens were named after hues, which is correct in one palette and wrong in every other: --pf-blue-800 held #f4f7fa in the cream theme and --pf-green held #ccff00 in the electric one. They now name position on a scale instead. The aliases file that kept the old names working was removed in 0.20, so update markup using the table below.

WasNowNote
--pf-blue-900--pf-base-900Deepest surface
--pf-blue-800--pf-base-800Page background
--pf-blue-700--pf-base-700Raised surface
--pf-blue-600--pf-base-600Emphasis border
--pf-paper--pf-base-50Lightest tone
--pf-mist--pf-base-300Muted tone
--pf-ink--pf-base-950Text on an accent fill
--pf-green, -deep, -wash--pf-hue-1, -deep, -washPrimary accent hue
--pf-orange, -deep, -wash--pf-hue-2, -deep, -washSecond accent hue
.pf-fg-green.pf-fg-accentHelpers now name the role, not the colour
.pf-fg-orange.pf-fg-accent-2
.pf-bg-green-wash.pf-bg-accent-wash—
.pf-bg-orange-wash.pf-bg-accent-2-wash—
.pf-bg-blue.pf-bg-base—
.pf-bordered-green.pf-bordered-strong—
.pf-btn--orange.pf-btn--accent-2—
.pf-pill--green, .pf-pill--orange.pf-pill--accent, --accent-2—
.pf-mark--orange.pf-mark--accent-2—
.pf-card__icon--orange.pf-card__icon--accent-2—
.pf-checks--orange.pf-checks--accent-2—
.pf-topbar--green.pf-topbar--accent—
.pf-footer--green.pf-footer--accent—
pf-ml-0 pf-ml-px pf-ml-1 pf-ml-2 pf-ml-3 pf-ml-4 pf-ml-5 pf-ml-6 pf-ml-7 pf-ml-8 pf-ml-9 pf-ml-auto.pf-ms-*Logical spacing. The old names implied a physical side while applying a logical one
pf-mr-0 pf-mr-px pf-mr-1 pf-mr-2 pf-mr-3 pf-mr-4 pf-mr-5 pf-mr-6 pf-mr-7 pf-mr-8 pf-mr-9 pf-mr-auto.pf-me-*
pf-pl-0 pf-pl-px pf-pl-1 pf-pl-2 pf-pl-3 pf-pl-4 pf-pl-5 pf-pl-6 pf-pl-7 pf-pl-8 pf-pl-9.pf-ps-*
pf-pr-0 pf-pr-px pf-pr-1 pf-pr-2 pf-pr-3 pf-pr-4 pf-pr-5 pf-pr-6 pf-pr-7 pf-pr-8 pf-pr-9.pf-pe-*
.pf-text-left, .pf-text-right.pf-text-start, .pf-text-endSame reason

What the shim cannot do

It lets old names read new values. It does not let old declarations reach new components: if you retinted the framework by declaring --pf-green yourself, that declaration no longer feeds anything, and you need to rename it to --pf-hue-1. One line, and the shim is 1 KB you can then delete.

Light and dark

The framework ships dark and can follow the operating system. Only the palette is redeclared; every role, and therefore every component, follows.

MarkupBehaviour
<html lang="en">Follows prefers-color-scheme
data-pf-theme="dark"Always dark, whatever the OS says
data-pf-theme="light"Always light

The accent darkens in light mode rather than staying put. A pastel green that reads at 6.7:1 on deep blue reads at 1.6:1 on near-white — a palette that does not change with its background is decoration, not a palette. This site pins itself to dark because the dark page is its identity.

Adapting to the device

Six preference and capability queries are handled in Core, so a component never has to think about them.

QueryWhat changes
prefers-reduced-motionEvery transition and animation drops to 0.01ms; smooth scrolling is disabled
prefers-contrast: moreHairlines become solid 2px borders; muted text moves to full strength
forced-colors: activeBackgrounds are replaced by system keywords and borders reinstated, so nothing flattens into the page in Windows High Contrast
pointer: coarseInteractive targets get a 44px minimum height; checkboxes grow
Short landscapeSection and hero padding is capped so a 1024×600 screen is not all whitespace
prefers-color-schemeThe whole palette flips to light, unless the page pins a theme
hover: hoverHover affordances are only defined where a pointer can hover
@containerComponents respond to their container's width, not the viewport's
printNavigation, footers and CTA bands are hidden

All motion respects the user

A prefers-reduced-motion block reduces every transition and animation to 0.01ms and disables smooth scrolling. You do not need to handle this yourself.

02 / 03

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 nine shipped with the site are each under forty lines, and every one carries its measured contrast figures in the file header.

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

Check two pairs whenever you change the accent

The accent against the page background, and --pf-ink against the accent. Everything else derives from those two. The shipped palettes hold 7:1 on both, measured in a browser on every build; if your brand colour cannot, the honest fix is a darker or lighter shade of it rather than a smaller contrast claim. There are worked examples in Recipes.

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.

03 / 03

Effects and motion

Four easings, four durations, scroll-driven reveals and a particle field — plus the reduced-motion policy that overrides all of it, and why it completes animations rather than cancelling them.

Motion in this framework is deliberately small: four easings, four durations, a handful of named animations, and one policy that overrides all of it. Everything here is optional — the Effects module is not part of a minimal page.

The reduced-motion policy comes first

Core collapses every transition and animation to effectively zero under prefers-reduced-motion: reduce, and it does so for your CSS as well as the framework's, because the rule matches everything:

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

Note that it completes animations rather than cancelling them. Slowing an animation that starts at opacity: 0 leaves the element invisible forever; finishing it instantly leaves the page in its end state, which is what someone asking for less motion actually wants. If you animate through JavaScript, check the query yourself — CSS cannot help you there.

Easings and durations

TokenUse for
--pf-ease-outThings arriving: a panel opening, a toast
--pf-ease-inThings leaving
--pf-ease-in-outMovement within the page
--pf-ease-springA slight overshoot, for menus and toasts
--pf-duration-1…4120, 200, 320, 520ms

Four of each is a deliberate limit. A system with thirty easings gets thirty different ones used, and motion stops reading as one product. Read the tokens directly in your own transition — transition: opacity var(--pf-duration-2) var(--pf-ease-out) — which is what the utilities that wrapped them did, in one line, in the place that needs it. For a blanket transition, .pf-transition and .pf-transition-colors remain.

Named animations

.pf-animate-fade, -rise, -pulse and -spin. To stagger a small set, set animation-delay on the items yourself, and resist staggering a large one: past about five items a stagger stops reading as choreography and starts reading as lag.

<li class="pf-animate-rise">One</li>
<li class="pf-animate-rise">Two</li>

/* in your stylesheet */
.steps li:nth-child(2) { animation-delay: var(--pf-duration-1); }
.steps li:nth-child(3) { animation-delay: var(--pf-duration-2); }

Scroll-driven reveals

.pf-reveal and .pf-reveal-soft fade content in as it enters the viewport, using a scroll-driven animation timeline rather than an intersection observer — no script, and no work on the main thread when nothing is scrolling. Engines without support show the content immediately, which is the right fallback.

One caveat worth knowing if you write tests: an element waiting to be revealed sits at reduced opacity, so an automated contrast check run before it scrolls into view will report a failure that no reader ever sees. Run such checks with reduced motion emulated.

Surface effects

  • .pf-frost for a frosted panel over content, with a solid fallback where backdrop-filter is missing.
  • .pf-gradient-accent, -surface, -deep, -edge and .pf-gradient-text — all built from role tokens, so they follow the theme.
  • .pf-rule-fade for a hairline that fades at both ends.
  • .pf-ring, .pf-focus-ring and .pf-hover-lift for interactive affordances.

The particle field

.pf-particles puts a drifting field of accent-coloured specks behind a hero or title band. It is two pseudo-element layers of radial gradients at different sizes and speeds — no canvas, no script, no extra DOM node.

<section class="pf-hero pf-particles"> … </section>

.hero-quiet {
  --pf-particle-size: 160px;     /* spacing of the field */
  --pf-particle-opacity: 0.3;    /* how present it is */
  --pf-particle-speed: 140s;     /* slower drift */
}

Two variants swap the round specks for SVG tiles: .pf-particles--mesh draws the triangle motif the brand already uses, and .pf-particles--constellation draws points with lines between them. The SVG is used as a mask rather than a background image — a data URI cannot read a custom property, so a tile with the colour baked in would ignore every theme. Masking a plain --pf-accent fill takes the shape from the SVG and the colour from the token.

It paints behind content in an isolated stacking context, takes no pointer events, and animates only transform and opacity — so it cannot move layout, cannot cost a layout shift, and cannot intercept a click. Under reduced motion it stays visible but still.

A note on restraint

Every effect here is opt-in and none of them are load-bearing: remove the Effects module and the page still works, still reads, still submits. That is the test to apply to your own additions. If removing an animation breaks comprehension, it was carrying meaning that should have been in the markup.