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
Every custom property, retheming from eleven roles, scoped palettes, and the effects layer that keeps motion honest.
01 / 03
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.
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 |
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.
| Token | Default | Meaning |
|---|---|---|
--pf-bg | --pf-base-800 | Page background |
--pf-bg-deep | --pf-base-900 | Recessed background, alternating sections |
--pf-surface | white 4.5% | Cards, alerts, plan cards |
--pf-surface-raised | white 8% | Hover state for surfaces |
--pf-text | --pf-base-50 | Body and strong text |
--pf-text-muted | --pf-base-300 | Secondary text, help text |
--pf-ink | --pf-base-950 | Text on an accent fill |
--pf-accent | --pf-hue-1 | Primary actions and links |
--pf-accent-2 | --pf-hue-2 | Secondary emphasis |
--pf-border | base-300 22% | Hairline borders |
--pf-border-strong | accent 42% | Emphasised borders |
--pf-focus | --pf-accent | Focus ring colour |
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.
| Token | Derived as |
|---|---|
--pf-accent-hover | accent 82% mixed with black, in OKLab |
--pf-accent-wash | accent at 12% |
--pf-accent-line | accent at 35% — also the focus ring |
--pf-accent-2-hover / -wash / -line | the 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>
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.75, prose 1.8 | 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.
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.75 body, 1.8 prose, 1.22 snug, 1.06 display |
--pf-measure | 66ch, applied to p and li |
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
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 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.
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.
Nine space steps from 4px, multiplied by a single density knob. Radii are deliberately few — angles do the distinctive work through --pf-cut.
| Token | Value |
|---|---|
--pf-density | 1.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 … -5 | Grow smoothly between 320px and 1440px screens: .75→1, 1→1.5, 1.5→2.5, 2→4, 3→7rem |
--pf-gap | clamp(1.5rem, 3vw, 2.5rem) × density — grid and stat gaps |
--pf-gap-lg | clamp(2.5rem, 5.5vw, 4.5rem) × density — splits, shells, section heads |
--pf-section-pad | clamp(5rem, 11vw, 9.5rem) × density — vertical rhythm between sections |
--pf-radius-sm / (default) / -lg | 6px, 12px, 22px |
--pf-radius-pill | 999px |
--pf-cut | 18px — depth of the clipped corner on facet surfaces |
--pf-ring | 3px of --pf-accent-line, used on focused inputs |
--pf-shadow-sm / (default) / -lg | Raised rows; menus and popovers; dialogs and hero media |
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.
| Token | Value |
|---|---|
--pf-container | 987px — one measure for every page. --pf-container-narrow 720px, --pf-container-wide the same 987px unless you raise it |
--pf-gutter | clamp(1.25rem, 5vw, 3rem) |
--pf-min | Per-grid override for .pf-grid--auto, default 260px |
--pf-switch | Container 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-min | Minimum height of .pf-cover, default 100dvh |
--pf-speed / --pf-ease | 160ms, cubic-bezier(.2,.7,.3,1) — the default transition |
--pf-ease-out / -in / -in-out | Arrivals, departures, and moves within the page |
--pf-ease-spring | A linear() curve with a slight overshoot, for menus and toasts |
--pf-duration-1 … -4 | 120, 200, 320, 520ms |
--pf-mesh | The 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.
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.
| 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-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-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.
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.
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.
02 / 03
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.
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.
Border, surface tint, clipped corner and icon well all resolve from roles.
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.
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.
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.
Roles resolve to the dark palette. Muted text sits at --pf-base-300.
Same markup, same classes. Only the roles changed, and the accent stays put.
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:
:root {
--pf-space-6: 1.5rem;
--pf-space-7: 2rem;
--pf-space-8: 3rem;
--pf-space-9: 4rem;
--pf-cut: 0px;
}
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.
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.
/* 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;
}
| 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 |
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.
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.
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.
--pf-radius, --pf-radius-lg and --pf-cut. Setting --pf-cut: 0 removes the framework's signature clipped corner everywhere without editing a component.
--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.
--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
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.
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.
| Token | Use for |
|---|---|
--pf-ease-out | Things arriving: a panel opening, a toast |
--pf-ease-in | Things leaving |
--pf-ease-in-out | Movement within the page |
--pf-ease-spring | A slight overshoot, for menus and toasts |
--pf-duration-1…4 | 120, 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.
.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); }
.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.
.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..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.
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.