Palette
Raw values. Change these to shift the whole brand, or leave them and remap the roles below.
| Token | Value | Used for |
|---|---|---|
--pf-base-900 | #00030f | Deep surfaces: footers, code blocks, alt sections |
--pf-base-800 | #000823 | Page background |
--pf-base-700 | #000f3f | Raised surface — the lightest background in the framework |
--pf-base-600 | #17306e | Info alert rule |
--pf-hue-1 | #8fe06a | Accent: buttons, links, markers |
--pf-hue-1-deep | #6dc44a | Accent hover and press |
--pf-hue-1-wash | rgba(143, 224, 106,.12) | Accent tint behind icons and cards |
--pf-hue-2 | #e5b189 | Secondary accent |
--pf-hue-2-deep | #c48f66 | Secondary hover |
--pf-base-50 | #e9eefa | Primary text |
--pf-base-300 | #a9bcd8 | Muted text |
--pf-base-950 | #00081f | Text on accent fills |
Roles
These are what components actually reference. Override these eleven and every module follows.
| Token | Default | Meaning |
|---|---|---|
--pf-bg | navy-800 | Page background |
--pf-bg-deep | navy-900 | Recessed background |
--pf-surface | white 4.5% | Cards, alerts, plan cards |
--pf-surface-raised | white 8% | Hover state for surfaces |
--pf-text | paper | Body text |
--pf-text-muted | mist | Secondary text, help text |
--pf-accent | green | Primary actions and links |
--pf-accent-2 | orange | Secondary emphasis |
--pf-border | mist 22% | Hairline borders |
--pf-border-strong | green 42% | Emphasised borders |
--pf-focus | green | Focus ring colour |
Readability
Six decisions, each of which can be overridden, and each of which was made in the same direction: toward the reader.
| Decision | Why |
|---|---|
| Base is 17–19px, fluid | The 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.7, prose 1.75 | The largest readability lever after size. Tightens as type grows, because a 60px headline at 1.7 leaves its lines unrelated. |
| 66-character measure | The comfortable range is roughly 45–75. Past that, the return sweep to the next line starts landing in the wrong place. |
| Nothing below 13.6px | The old 12px step is a decision to have the text not read. The smallest size is now 0.8125rem. |
| Muted text at 6.79:1 | It was 5.49:1 — passing AA and still the first thing people squint at. Secondary text is still text. |
| Links underlined by default | A 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.
| Token | Value |
|---|---|
--pf-font-display | Bricolage Grotesque, Trebuchet MS, system-ui |
--pf-font-body | Inter, system-ui, -apple-system, Segoe UI, Roboto |
--pf-font-mono | IBM Plex Mono, ui-monospace, Menlo |
--pf-text-xs … -base | 0.8125, 0.9375, 1rem — and the base itself is fluid, 17px to 19px |
--pf-text-lg … -2xl | 1.1875, 1.5, 1.9375rem |
--pf-text-3xl … -5xl | fluid: 2.25→3, 2.75→4.4, 3.25→6rem |
--pf-leading | 1.7 body, 1.75 prose, 1.22 snug, 1.06 display |
--pf-measure | 66ch, 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.
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.
Radio inputs and :has(). The stage declares tokens; every component inside reads them.
Space and shape
Nine space steps from 4px, doubling at the top. Radii are deliberately few — angles do the distinctive work through --pf-cut.
| Token | Value |
|---|---|
--pf-space-1 … -9 | .25, .5, .75, 1, 1.5, 2, 3, 4.5, 7rem |
--pf-gap | clamp(1.25rem, 2.5vw, 2rem) — grid and stat gaps |
--pf-gap-lg | clamp(2rem, 4vw, 3.5rem) — splits, shells, section heads |
--pf-section-pad | clamp(4rem, 9vw, 7.5rem) — vertical rhythm between sections |
--pf-radius-sm / / -lg | 6px, 12px, 22px |
--pf-radius-pill | 999px |
--pf-cut | 18px — depth of the clipped corner on facet surfaces |
--pf-ring | 3px accent glow, used on focused inputs |
--pf-shadow-sm | Hairline depth, for raised rows |
--pf-shadow | Menus, popovers, hover lift |
--pf-lift | Dialogs and hero media |
Layout and motion
| Token | Value |
|---|---|
--pf-container | 1160px; --narrow is 780px, --wide 1360px |
--pf-gutter | clamp(1.25rem, 5vw, 3rem) |
--pf-speed | 160ms |
--pf-ease | cubic-bezier(.2,.7,.3,1) |
--pf-mesh | The triangle pattern, as an inline SVG data URI |
--pf-min | Per-grid override for .pf-grid--auto, default 260px |
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. Load PolyFishCSS-Compat.min.css after Core and your existing
markup keeps working while you migrate.
| Was | Now | Note |
|---|---|---|
--pf-blue-900 | --pf-base-900 | Deepest surface |
--pf-blue-800 | --pf-base-800 | Page background |
--pf-blue-700 | --pf-base-700 | Raised surface |
--pf-blue-600 | --pf-base-600 | Emphasis border |
--pf-paper | --pf-base-50 | Lightest tone |
--pf-mist | --pf-base-300 | Muted tone |
--pf-ink | --pf-base-950 | Text on an accent fill |
--pf-green, -deep, -wash | --pf-hue-1, -deep, -wash | Primary accent hue |
--pf-orange, -deep, -wash | --pf-hue-2, -deep, -wash | Second accent hue |
.pf-fg-green | .pf-fg-accent | Helpers 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-accent | — |
.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-end | Same 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.
| Markup | Behaviour |
|---|---|
<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.
| Query | What changes |
|---|---|
prefers-reduced-motion | Every transition and animation drops to 0.01ms; smooth scrolling is disabled |
prefers-contrast: more | Hairlines become solid 2px borders; muted text moves to full strength |
forced-colors: active | Backgrounds are replaced by system keywords and borders reinstated, so nothing flattens into the page in Windows High Contrast |
pointer: coarse | Interactive targets get a 44px minimum height; checkboxes grow |
| Short landscape | Section and hero padding is capped so a 1024×600 screen is not all whitespace |
prefers-color-scheme | The whole palette flips to light, unless the page pins a theme |
hover: hover | Hover affordances are only defined where a pointer can hover |
@container | Components respond to their container's width, not the viewport's |
print | Navigation, 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.
Next: theming.