JavaScript, performance and guides
The one optional script and what it adds, measured sizes and budgets, then the recipes, tips and fixes.
01 / 03
JavaScript
One optional file, 9.9 KB minified and 3.7 KB gzipped. Every component works without it. Nothing here creates behaviour that did not exist — it finishes behaviour that CSS could only take so far.
Nothing here is required
Dialogs, menus, tabs and accordions all work with the script absent — see Recipes for the markup-only versions. The script adds what markup cannot express, and if it fails to load the page keeps working. If something interactive does nothing at all, the cause is usually a missing stylesheet rather than a missing script: troubleshooting.
Attribute reference
Nine enhancements, every attribute each one reads, and what it does to the DOM. Nothing here is required; an element without its attribute is untouched.
| Attribute | Goes on | Value | What it adds |
|---|---|---|---|
data-pf-menu | <details class="pf-menu"> | none | Closes on outside click and Escape; keeps aria-expanded on the summary in sync |
data-pf-tabs | .pf-tabs | none | role="tablist", role="tab", aria-selected, aria-controls, roving tabindex, arrow-key and Home/End navigation |
data-pf-dialog | any button | id of the dialog | Fallback only. Modern browsers open dialogs from command="show-modal" with no script; this calls showModal() where they cannot, and does nothing where the browser already did |
data-pf-dialog-close | button inside a dialog | none | Closes the nearest dialog |
data-pf-copy | .pf-code | none | Adds a .pf-code__copy button and a polite live region for the result |
data-pf-counter | input or textarea with maxlength | id of the counter element | Live character count, updated on input |
data-pf-dismiss | button | selector to remove | Removes the closest matching ancestor |
data-pf-sort | <table> | none | Wraps each sortable header in a button, sorts on click, sets aria-sort; detects numeric columns |
data-pf-nosort | <th> | none | Excludes that column from sorting |
data-pf-spy | .pf-sidenav | none | Marks the link for the section currently in view with aria-current |
data-pf-theme-toggle | button | none | Cycles auto → light → dark, writes data-pf-theme on <html>, remembers the choice |
data-pf-theme-label | element inside that button | none | Receives the current mode as text |
data-pf-tip | any element with .pf-tooltip | tooltip text | Read by CSS, not the script — no JavaScript involved |
data-pf-label | <td> in .pf-table--stack | column label | Also CSS-only: printed before the cell when the table stacks |
The one function
PolyFish.init(container) re-scans a subtree after you inject markup. It is
idempotent — elements record which enhancements they already carry — so calling it twice is
harmless, and calling it on the whole document after adding one row is merely wasteful
rather than wrong.
const row = table.insertRow();
row.innerHTML = template;
// enhance just what you added
PolyFish.init(row);
PolyFish.version holds the release string. There is no
other public API: no options object, no event bus, no plugin registry. If you need behaviour
the attributes do not cover, write it — the components are ordinary HTML.
The contract
Adding a script to a framework that spent five versions not having one deserves an explicit set of rules. These are enforced by how the code is written, not by intention:
- No element is hidden by JavaScript on load. If a script hides content and then fails to load, the content is gone. Nothing here hides anything CSS was not already hiding.
- No enhancement is the only route to a feature. The menu opens without it. The tabs switch without it. The dialog opens without it, via
:target. - Every enhancement is opt-in per element, through a data attribute. Adding the file to a page changes nothing until you mark something up.
- Nothing injects CSS. The two elements the script creates — a copy button and a sort button — are styled in the Content module. A script that writes styles is a script that fights your Content-Security-Policy.
What changed in the policy
This site's CSP moved from script-src 'none' to script-src 'self'. That is the honest cost. In exchange the script tag carries a sha384 subresource-integrity hash, so a stale or truncated deploy fails loudly instead of half-running.
Adding it
<script src="/polyfish.min.js?v=ef19ac2b" defer></script>
defer, not async: the DOM has to exist, and there is nothing here worth blocking the parser for. Then mark up the elements you want upgraded — everything else is untouched.
npm and bundlers
npm i polyfishcss resolves the same file as an ES module for
Vite, esbuild, webpack, Rollup and anything else that reads package exports. Importing it
where there is no DOM — SSR, prerendering, tests — is a no-op rather than a crash, and the
named exports are the same API you would reach on window.PolyFish. Type
declarations ship in the package; nothing to configure.
import "polyfishcss/core";
import "polyfishcss/content";
import "polyfishcss/utilities"; // utilities last, as always
import { init, toast, version } from "polyfishcss/js"; // ESM, typed
Each subpath is one module file — core, headers,
content, forms, components, footers,
space, effects, utilities, fonts —
so the bundler includes what you import and nothing more, the manual version of
Tailwind's scan. "polyfishcss" alone is the whole bundle, themes live under
polyfishcss/themes/…, and fonts under polyfishcss/fonts/….
Inside a framework
The script runs itself once on load, which covers markup that exists
when it parses. Anything rendered later — hydration, a route change, a fetch — needs one
call on the new subtree. init() is idempotent, so calling it on re-render costs
a walk, not a double-bind:
import { useEffect, useRef } from "react";
import { init } from "polyfishcss/js";
export function Menu() {
const root = useRef<HTMLDetailsElement>(null);
useEffect(() => { init(root.current ?? undefined); }, []);
return <details ref={root} className="pf-menu" data-pf-menu>…</details>;
}
The same shape works everywhere — onMounted(() => init(root.value))
in Vue, onMount in Svelte, a client-side effect in Next or Nuxt. Server-rendered
apps need nothing extra: the CSS imports sit in the root layout, and the module is inert
until a DOM exists.
Classes compose as plain strings —
className={"pf-btn pf-btn--primary " + size} — or through clsx if you already
carry one. And the design tokens are ordinary custom properties, readable from code when a
component needs to agree with the palette:
const accent = getComputedStyle(document.documentElement)
.getPropertyValue("--pf-accent");
Enhancements
| Attribute | On | What it adds | Without it |
|---|---|---|---|
data-pf-menu | details.pf-menu | Closes on outside click and Escape, keeps aria-expanded in sync | Opens and closes on its summary |
data-pf-tabs | .pf-tabs | Full ARIA tablist: roles, aria-selected, roving tabindex, Home and End | Radios switch panels, arrows already work |
data-pf-dialog | Any trigger | Native showModal(), focus trapping, focus returned on close | :target dialog, no focus trap |
data-pf-copy | .pf-code | A copy button and a polite status message | Select and copy by hand |
data-pf-counter | textarea | Live character count against maxlength | The browser still enforces the limit |
data-pf-dismiss | Any button | Removes its closest alert | The alert stays |
data-pf-nosort | th | Opts one column out of sorting; data-pf-value on a cell sorts on that instead of its text | — |
data-pf-sort | table | Click-to-sort headers with aria-sort, numeric-aware | The table renders in source order |
data-pf-spy | .pf-sidenav | Marks the current section with aria-current | The sidebar is a plain list of links |
data-pf-dialog-close | Any button in a dialog | Closes the native dialog it sits inside | Use form method="dialog" or the CSS-only close link |
data-pf-drawer | .pf-drawer | Escape closes it, focus moves into the panel and back to the trigger, and the page behind it stops scrolling | Opens and closes on :target |
data-pf-theme-toggle | Any button | Cycles auto, light, dark and remembers the choice. Put data-pf-theme-label on a child to have its text updated too | The palette follows the operating system |
That table is sortable — click a header. So is the module table on the installation page. The theme button in the navigation, the copy buttons on every code block and the highlighted item in the sidebar are the same script.
The one exception
PolyFish.toast() is the only thing here with no CSS-only version, because a
toast with no script is an empty box. It is documented as requiring JavaScript rather than
quietly degrading to nothing.
PolyFish.toast("Check created");
PolyFish.toast("Could not save", { variant: "danger", duration: 0 });
The region is role="status" with aria-live="polite",
which queues the message behind whatever a screen reader is currently saying. An assertive
region interrupts mid-sentence — for a confirmation that is rude rather than urgent. Hovering
or focusing a toast cancels its removal, because a message that vanishes while being read is
worse than one that stays. duration: 0 keeps it until dismissed.
Build your attribute list
Tick the enhancements you want. Everything you leave off costs nothing — the script walks for the attributes and finds none.
<!-- the script itself, 9.9 KB -->
<script src="/polyfish.min.js?v=ef19ac2b" defer></script>
<!-- nothing ticked: the script loads and does nothing at all --><div class="pf-tabs" data-pf-tabs> roles, aria-selected, roving tabindex<button data-pf-dialog="confirm-id"> showModal, focus returned on close<div class="pf-code" data-pf-copy> adds a copy button and a status region<textarea maxlength="280" data-pf-counter="count-id"> live count<button data-pf-dismiss=".pf-alert"> removes its closest alert<table data-pf-sort> click-to-sort with aria-sort<aside class="pf-sidenav" data-pf-spy> marks the current section<button data-pf-theme-toggle> auto / light / dark, remembered
Checkboxes and :has(). Every line above is opt-in per element — the file changes nothing until you add one.
The API
Three properties, and only the first one matters.
// upgrade a container after injecting markup
PolyFish.init(document.querySelector('#results'));
// idempotent: elements record what they already have,
// so nothing is bound twice
PolyFish.init();
PolyFish.version; // "0.5.0"
PolyFish.reducedMotion(); // true if the user asked for less motion
Tests
The script ships with a jsdom suite of 69 assertions. Twelve of them are not about features at all — they check that the page survives the script's absence: that the tabs still have a checked radio, that no element carries a hidden attribute, and that nothing depends on a class the script adds.
Two more cover failure modes rather than success: blocked localStorage, which throws outright in private mode in some browsers, and a missing clipboard API, where the copy button is not created at all rather than rendered dead.
What is still not here
No carousel, no toast queue, no combobox, no scroll animation library. The line has not moved: this file finishes things the CSS started. A component that only exists when the script runs would break the first rule on this page.
02 / 03
Performance
Every number here is measured from the shipped files rather than estimated, and regenerated whenever they change. Sort the table by the gzipped column — that is the one that arrives over the wire.
Deploying it
The framework is static files, so the decisions are all in how you serve them. These are the four that matter, in the order they bite.
| Decision | Do this | Why |
|---|---|---|
| Cache lifetime | Cache-Control: public, max-age=31536000, immutable |
CSS and fonts never change at a given URL. A returning reader downloads nothing. |
| Cache busting | A content hash in the URL: PolyFishCSS.min.css?v=a1b2c3d4 |
Immutable caching is only safe while the URL changes with the bytes. A hand-typed version is a promise someone has to remember, and the first time it was forgotten this site served two different palettes depending on cache state. |
| Compression | Brotli if you have it, gzip otherwise | The bundle is 99 KB raw and 18.2 KB gzipped. The CSS is highly repetitive, which is exactly what deflate is good at — this is the single largest win available. |
| Fonts | Self-host, preload the two you use above the fold, and set a CORS header | A third-party font origin costs a DNS lookup, a connection and a hole in your policy. Preloading only the faces actually used first keeps the rest off the critical path. |
What the published config does
The nginx/ directory in the repository is the configuration this site runs on,
not an example. It is checked by nginx -t in CI so it cannot rot.
- HTTP to HTTPS,
wwwto apex, and every canonical rewrite in a single hop — a redirect chain costs a round trip each. - Clean URLs through
try_files, so/docs#installservesinstall.htmlwithout a redirect. - Immutable caching on hashed assets,
must-revalidateon HTML. - A strict Content-Security-Policy with exactly one allow-listed origin — a self-hosted analytics host — plus HSTS, nosniff, DENY framing, a referrer policy and a permissions policy covering fourteen features.
- Dotfiles and editor backups denied,
.well-known/excepted.
What is not worth doing
- Inlining critical CSS. It requires
unsafe-inlinein your style policy, and Core is 5 KB gzipped — you would be trading a real security control for a saving smaller than one round trip on a warm connection. - Splitting further than the modules. Below about 4 KB, HTTP overhead costs more than the bytes saved.
- A CDN for a small site. If your readers are regional, a single well-configured origin with immutable caching beats an extra DNS lookup and a cold edge.
Printing and PDFs
A printed page is a different medium, not a screenshot of a screen. The Utilities module
carries a print block that switches the palette to black on white, drops navigation,
footers, sidebars, tab controls and copy buttons, opens every tab panel and accordion so
nothing hides behind a control nobody can click, and prints the address after each external
link — read more is useless once the page is paper.
It also sets page-break rules, because a heading stranded at the foot of a page and a
table split across two are what make a printed reference unusable: headings avoid a break
after, tables and cards avoid a break inside, and thead repeats on every page.
| Class | On screen | On paper |
|---|---|---|
.pf-no-print | Visible | Hidden |
.pf-print-only | Hidden | Visible |
Use the second for a note or a short URL that belongs on paper and nowhere else. Print any page of this site to see the rest of it.
Font loading and layout shift
The gap between a fallback face rendering and the web font arriving is where cumulative layout shift comes from: Arial is shorter and wider than Inter, so every line box changes height and every heading rewraps at the moment of the swap.
The Fonts module declares three metric-matched fallback faces — Arial and Courier New with the real fonts' ascent, descent and average width forced onto them — and each stack names its fallback before the generic families. The browser lays the page out at the right line height and the right width immediately, then the real font lands into a box that already fits.
| Face | Units/em | Ascent | Descent | Average width |
|---|---|---|---|---|
| Inter | 2048 | 96.9% | 24.1% | 610/1000 |
| Bricolage Grotesque 700 | 1000 | 93.0% | 27.0% | 539/1000 |
| IBM Plex Mono | 1000 | 102.5% | 27.5% | 600/1000 |
| Arial (the fallback) | 2048 | 90.5% | 21.2% | 500/1000 |
Every figure is read out of the shipped woff2 files with fontTools
rather than estimated. size-adjust is the ratio of the two average widths.
What a page actually costs
Pick the kind of page you are building. The figures are measured at build time, not estimated — per-file minified size, the gzipped total, and how many requests it takes.
Sign-in or error screen · Core + Content
Two requests. No navigation, no footer, no utilities — the sign-in example loads exactly this.
Marketing page · Core + Headers + Content + Footers
Four requests, and the whole visual language of a landing page. Fonts add 167 KB once, cached for a year.
Documentation page · the four above plus Space
Space is 2.5 KB — the spacing utilities cost almost nothing now.
Application screen · Core, Headers, Content, Forms, Components
Five requests. Add the optional script at under 4 KB gzipped if you want sortable tables and a real tablist.
All nine core modules · the bundle
One request, and smaller gzipped than the same nine files fetched separately — which is why the advice is to ship the bundle past six files.
Bars are proportional width utilities, not inline styles. Figures come from the build, so they cannot drift from the files.
What it costs
| Module | Minified | Gzipped | Load it when |
|---|---|---|---|
| Core | 20.0 KB | 5.1 KB | Always |
| Headers | 10.4 KB | 2.6 KB | The page has a nav or a hero |
| Content | 18.7 KB | 4.2 KB | Almost always |
| Components | 19.6 KB | 4.0 KB | App UI: tabs, menus, drawers, toasts |
| Forms | 5.8 KB | 1.6 KB | Beyond the four basic controls |
| Footers | 4.6 KB | 1.2 KB | The page has a footer |
| Space | 2.5 KB | 0.6 KB | You want spacing utilities |
| Effects | 9.4 KB | 1.8 KB | Frost, gradients or animation |
| Utilities | 8.5 KB | 2.6 KB | Optional helpers |
| Variants | 1.8 KB | 0.6 KB | Curated hover and focus states |
| Classless | 4.8 KB | 1.3 KB | Styles bare semantic HTML |
| Fonts | 3.3 KB | 0.6 KB | You want the shipped typefaces |
| polyfish.js | 9.9 KB | 3.7 KB | Optional, never required |
Everything concatenated is 99 KB minified and 18.2 KB gzipped. The gap between those two numbers is the point: a utility surface is highly repetitive text and compresses accordingly — the two largest files, Core and Components, shrink to a quarter of their size over the wire.
What to load
Four real page types, with what they actually need.
Marketing page
Core, Headers, Content, Footers, Space, Utilities.
64.7 KB min · 16.3 KB gzip
Documentation
Core, Headers, Content, Space, Utilities. No footer module.
59.7 KB min · 10.9 KB gzip
Application shell
Core, Headers, Content, Forms, Components, Space, Utilities.
85.5 KB min · 16.7 KB gzip
Sign-in screen
Core and Content. Nothing else — no nav, no footer, no grid.
38.7 KB min · 9.3 KB gzip
One request or several?
Under HTTP/2 the per-request cost is small, so five module files and one bundle are close to equivalent on first load. Separate files win on repeat visits — change Content and the visitor re-downloads 4.2 KB rather than 18.2 KB, because the other files keep their year-long immutable cache. Use the bundle while prototyping, split when you ship.
Fonts are the real budget
The three self-hosted families total 166.6 KB, which is more than every stylesheet combined. That is normal and it is where the attention belongs.
- Two faces are preloaded, not seven: the body regular and the display bold, the only ones needed above the fold.
crossoriginis mandatory on a font preload — without it the file downloads twice. font-display: swapmeans text paints immediately in the fallback and repaints when the face arrives. Never invisible, never blocking.- Latin subsets only. Add a subset by dropping the file in
/fontsand copying a block; the framework never asks for a weight that is not declared. - Skip the module entirely and the system stacks take over with no layout change. That is 166.6 KB and a whole class of loading behaviour gone.
Layout shift
Cumulative layout shift is mostly caused by content arriving late into space that was never reserved. Four places the framework reserves it for you.
| Cause | What holds the space |
|---|---|
| Images | .pf-tile and .pf-ratio use aspect-ratio, so the box exists before the file arrives. |
| Embeds | .pf-ratio--16x9 and friends hold an iframe's box from first paint. |
| Async content | .pf-skeleton at the shape of what is coming, marked aria-busy. |
| Fonts | swap still reflows when the face lands. The fallback stacks are chosen for similar metrics to keep that shift small. |
Serving it
The published server configuration does four things that matter more than any byte count.
- Immutable caching on CSS, JS, fonts and themes:
max-age=31536000, immutable. A repeat visit fetches nothing. Version the filename when the content changes. must-revalidateon HTML, so a page is never stale while its assets are cached forever.- gzip on, with
text/htmlleft out ofgzip_types— nginx compresses HTML unconditionally and listing it logs a duplicate-MIME warning on every start. Brotli lines are included, commented, for when the module is available. - CORS on fonts and the script, because both are fetched in CORS mode. Without it the font preload is discarded and the subresource-integrity check on the script cannot run.
The number that is always zero
No page on this site makes a request to another origin. No font CDN, no analytics, no tag manager. That is a privacy decision first and a performance one second, but it also removes every DNS lookup and TLS handshake that is not to your own server.
03 / 03
Guides
Recipes for what you are building, tips worth knowing early, and fixes for what breaks, all on one page.
Three kinds of help on one page, so a single find-in-page covers all of it: recipes for things you are building, tips worth knowing before you need them, and fixes for what breaks, symptom first.
Recipes
Build a page from nothing
The shortest path from an empty file to something that looks deliberate. Core is required; everything else is there because this page has a header, content and a footer.
<!doctype html>
<html lang="en" data-pf-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/PolyFishCSS-Core.min.css?v=37ca963d">
<link rel="stylesheet" href="/PolyFishCSS-Headers.min.css?v=8528ac89">
<link rel="stylesheet" href="/PolyFishCSS-Content.min.css?v=f4d620bb">
<link rel="stylesheet" href="/PolyFishCSS-Footers.min.css?v=3bf8c0cc">
</head>
<body>
<a class="pf-skip" href="#guides-main">Skip to content</a>
<header class="pf-nav">…</header>
<main id="guides-main">
<section class="pf-section">
<div class="pf-container">…</div>
</section>
</main>
<footer class="pf-footer">…</footer>
</body>
</html>
Load Core first and Utilities last. Everything between them is whatever the page actually contains. Past six files, ship the bundle instead — one request, 18 KB gzipped.
Retint one section without a second theme
Setting --pf-accent on a section is not enough on its own: the hover, wash
and ring colours are derived on :root, so they will not follow. Add
.pf-retint, which re-derives them on that element.
.promo { --pf-accent: #7aa7ff; }
<section class="pf-section promo pf-retint"> … </section>
Match your brand in four declarations
Most of a theme is one colour. Start there, check the contrast, and only add more if the derived values are wrong for your palette.
:root {
--pf-accent: #c2410c; /* links, primary buttons, focus */
--pf-ink: #fff7ed; /* text on an accent fill */
--pf-font-display: "Your Face", var(--pf-font-sans);
--pf-radius: 4px; /* squarer corners everywhere */
}
Two pairs need checking whenever the accent changes: the accent against the page background, and the ink against the accent. Both should reach 7:1 to match what the shipped palettes hold.
A dialog that needs no JavaScript
Invoker commands open a native dialog from markup alone. closedby="any"
handles the backdrop click and Escape.
<button class="pf-btn" command="show-modal" commandfor="confirm">Delete</button>
<dialog class="pf-dialog" id="confirm" closedby="any">
<h2>Delete this record?</h2>
<p>This cannot be undone.</p>
<div class="pf-dialog__actions">
<button class="pf-btn pf-btn--ghost" command="close" commandfor="confirm">Cancel</button>
<button class="pf-btn">Delete</button>
</div>
</dialog>
In browsers without invoker commands the button does nothing, so keep a server-rendered
fallback for destructive actions, or load the optional script, which wires
data-pf-dialog as a fallback.
A form that reports errors without a script
Use the platform's validity states. :user-invalid only matches after the
person has interacted, so a pristine form is not red before anyone has typed.
<div class="pf-field">
<label class="pf-label" for="guides-email">Email</label>
<input class="pf-input" id="guides-email" name="email" type="email" required>
<p class="pf-help">We only use this to reply.</p>
</div>
Pair it with .pf-alert--inline for a one-line message beneath the field
rather than a full alert block. On touch devices the field font size is floored at 16px by
the Content module, which is what stops iOS zooming on focus.
Tables that survive a phone
Always wrap a table. The wrapper scrolls, and it carries tabindex="0" so a
keyboard can reach the scroll area — a scroll container nothing can focus fails WCAG
2.1.1.
<div class="pf-table-wrap" tabindex="0">
<table class="pf-table pf-table--zebra pf-table--numeric"> … </table>
</div>
For a table that is mostly figures, --numeric aligns them on the end edge
with tabular figures. For a table that should become cards on a phone, use
--stack and give each cell a data-pf-label.
Ship less CSS
- Link only the modules the page uses. A sign-in screen needs Core and Forms, not Headers and Footers.
- Past six files the bundle is fewer bytes and one request.
- Self-host the fonts and keep the metric-matched fallbacks: they are what hold cumulative layout shift at zero when the web font arrives late.
- Serve with
Cache-Control: public, max-age=31536000, immutableand a content hash in the filename. The framework never changes a file in place.
Make the print stylesheet earn its place
Core's print rules already force black on white, drop chrome, open every tab panel and accordion, and print the address after external links. What they cannot know is which of your own blocks are decoration. Mark those:
@media print {
.site-promo, .site-share { display: none; }
}
Tips
Things that are true, useful and almost never reached for. Each one here is either in the framework already or three lines you can paste. Where support is uneven I say so — a tip you cannot ship is a party trick.
Two words about headlines, one about paragraphs
text-wrap: balance evens the lines of a heading so you never get five words
on the first line and one on the second. It only works up to a handful of lines by design,
which is exactly what headings are.
text-wrap: pretty is the paragraph version: it prevents a lone word on the
last line without rebalancing the block. Firefox does not support it yet, so treat it as
polish. Both are already on in this framework, and if you write your own headings outside
it, they are one line each.
Line things up with cap height, not with guesswork
An icon beside a label never quite lines up when you size it in pixels, because the text
grows and the icon does not. The cap unit is the height of a capital letter in
the current font, so an icon sized in cap tracks the text at every size.
.icon {
inline-size: 0.75em; /* fallback for anything without cap */
block-size: 0.75em;
inline-size: 1cap;
block-size: 1cap;
}
That is what .pf-icon does. The ch unit is its sibling for
horizontal work: a gap of 0.5ch between an icon and its label stays
proportional when someone bumps the font size.
The one-line fix for a grid that blows out
A grid or flex item refuses to shrink below its longest unbreakable word. That is the cause of most "why does my page scroll sideways on a phone" reports, and the fix is one declaration on the child:
.layout > * { min-inline-size: 0; }
Worth knowing rather than rediscovering: a long URL, a German compound noun or a class name in a code chip is enough to trigger it, and it only shows at widths you are not looking at. The framework sets this on its own layouts for that reason.
Do not turn the form red before anyone types
:invalid matches an empty required field from the moment the page loads, so
styling it paints a pristine form in error colours. :user-invalid waits until
the person has actually interacted with the control.
.field:user-invalid { border-color: var(--pf-danger, crimson); }
The same logic applies to :user-valid for a green tick that does not appear
before it has been earned.
overflow: clip is not overflow: hidden
hidden creates a scroll container: the content is still scrollable
programmatically, and — the part that bites — it makes the element a scrollport, so
position: sticky inside it behaves differently and any sticky ancestor
relationship changes. clip just cuts.
.band { overflow: clip; } /* cuts, no scrollport */
.band { overflow: clip; overflow-clip-margin: 1rem; } /* cut with a bleed */
We reached for this when a decorative layer with a transform was adding a two-pixel
horizontal scroll to the page. hidden would have fixed the scroll and broken a
sticky sidebar.
Never remove a focus ring — replace it
:focus-visible is the browser's own judgement about whether focus should be
drawn: it fires for keyboard users and not for a mouse click on a button. That means you can
have a clean click and a visible ring without writing any logic.
.btn:focus { outline: none; } /* never on its own */
.btn:focus-visible { outline: 2px solid var(--pf-focus); outline-offset: 2px; }
And use outline rather than border or
box-shadow: outline follows border-radius now, does not affect
layout, and survives forced-colors mode, where a box-shadow ring disappears entirely.
Complete the animation, do not slow it
The common reduced-motion snippet sets animation-duration: 0.01ms, which
looks like a hack and is actually the correct behaviour: it lets the animation
finish. Slowing it instead leaves an element that starts at
opacity: 0 invisible for the next ten minutes.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
If you animate from JavaScript, check matchMedia yourself — CSS cannot help
you there. And note this also fixes the opposite bug: a test suite that screenshots a page
mid-animation.
Guard hover, because touch has no hover
On a touch device, :hover fires on first tap and sticks until you tap
somewhere else. Every hover effect you write should sit inside a query for a device that can
actually hover:
@media (hover: hover) {
.card:hover { transform: translateY(-2px); }
}
Related, and more commonly missed: (pointer: coarse) is how you find out the
person is using a finger, which is when you raise targets to 44px and floor form fields at
16px so iOS does not zoom.
A scroll container nothing can focus fails WCAG
If a region scrolls and nothing in it is focusable, keyboard users cannot reach the hidden content at all. That is a 2.1.1 failure, and it is everywhere: wide tables, code blocks, horizontal card reels.
<div class="table-wrap" tabindex="0">
<table> … </table>
</div>
One attribute. Automated checkers catch it, and almost nobody adds it before they are told.
The lang attribute is doing more than you think
It chooses the screen reader's voice and pronunciation. It decides whether hyphenation
works at all — hyphens: auto is silently inert without it. It picks quotation
marks for <q>, and it affects font selection for CJK, where the same code
point has different shapes across languages.
<html lang="en">
… <span lang="fr">déjà vu</span> …
Anchors that land under the sticky header
Click an in-page link with a sticky header and the heading lands behind it. The fix is not JavaScript, it is one property on the target:
:target, h2, h3 { scroll-margin-block-start: 6rem; }
html { scroll-padding-block-start: 6rem; } /* same idea, on the scrollport */
Stop maintaining tints by hand
A hover shade, a 12% wash, a border tint — all derivable from one colour, at runtime, with no build step and no preprocessor:
--hover: color-mix(in oklab, var(--accent) 82%, var(--text));
--wash: color-mix(in srgb, var(--accent) 12%, transparent);
Two things people miss. Mixing in oklab gives perceptually even results
where srgb goes muddy through the middle. And mixing toward the
text colour rather than toward black or white means the derived shade moves away
from whatever sits on top of it, so contrast can only improve.
Custom properties resolve where they are declared
The single most surprising thing about CSS variables. If --space is declared
on :root and computed into --gap: calc(var(--space) * 2) there
too, then setting --space on a section changes nothing, because
--gap was already resolved.
/* works: the derived value is redeclared where the input changes */
.compact { --space: 0.5rem; --gap: calc(var(--space) * 2); }
This is why the framework ships .pf-compact, .pf-airy and
.pf-retint rather than telling you to set a variable anywhere and hope.
Feature queries, so the fallback is boring rather than broken
Use the modern thing, and say what happens without it. The pattern matters more than any single feature:
.menu { inset-block-start: 100%; } /* fine everywhere */
@supports (anchor-name: --x) {
.menu { position-anchor: --trigger; inset-block-start: anchor(bottom); }
}
Anchor positioning is the example worth naming in 2026: genuinely useful, still uneven
across engines, and the one most likely to bite anyone who assumes it is everywhere. Scroll
driven animations and field-sizing are gentler — they degrade to nothing on
their own, so you can use them without a query.
Units that save you a media query
min(),max()andclamp()replace most breakpoint work:width: min(100%, 60ch)is a responsive column in one line.chfor measure, because it tracks the font rather than the pixel.dvhinstead ofvhon phones, where the address bar makesvhlie. Write100vhfirst as the fallback, then100dvh.lhsizes something to a line of text: a 4-line textarea isblock-size: 4lh.cqiisvwfor a container, so type can scale to a component rather than to the window.
Troubleshooting
Every entry here is a real failure, most of them ours. If the symptom matches, the cause is almost always the one named.
My CSS does not override the framework
If your rule is inside a cascade layer — a @layer block of your
own, or a framework layer you appended to — it loses to any later layer regardless of
specificity. Unlayered CSS always wins. Move the rule out of the layer rather than reaching
for !important:
/* wins: unlayered author CSS beats every framework layer */
.pf-btn { background: rebeccapurple; }
The same trap bites inside the framework. A rule in polyfish.core loses to
one in polyfish.content, so an override belongs in the module that owns the
component. This shipped twice: a 16px touch floor for fields, and the
.pf-invert flip.
My theme changes the accent but hovers the old colour
You are probably setting --pf-accent somewhere other than
:root. Derived colours — hover, wash, line, ring — are computed where they are
declared, so a scoped accent leaves them behind. Add .pf-retint to the element
carrying the new accent.
iOS zooms when a field takes focus and never zooms back
That is Safari's response to a field under 16px. Content floors form fields at 16px under
(pointer: coarse); if you override the field font size in your own CSS, restore
the floor in the same place:
@media (pointer: coarse) {
.pf-input, .pf-select, .pf-textarea { font-size: 16px; }
}
The page scrolls sideways on a phone
Almost always one of three things. A grid item's automatic minimum width is its longest
unbreakable word, so a long identifier widens the column: set
min-inline-size: 0 on the children. A display heading that is one long word is
wider than a 320px screen: headings hyphenate below 30em, but your own type scale may not.
Or something long is not inside a scroll container — wrap tables in
.pf-table-wrap and code in .pf-code.
To find it, open the page at 320px and ask the document which element sticks out:
[...document.querySelectorAll('body *')]
.filter(el => el.getBoundingClientRect().right > document.documentElement.clientWidth + 1)
.slice(0, 5)
My tabs show the wrong panel
The panel rules match div:nth-of-type(). Any extra <div>
placed directly inside .pf-tabs renumbers them. Put your wrapper outside the
tab group, or use an element that is not a div.
A component renders completely unstyled
The module that owns it is not loaded. Components live where you would expect — cards and tables in Content, menus and dialogs in Components, footers in Footers — and the class reference names the module for every class. If the page loads the bundle, it already has all nine core modules; Fonts, Variants and Classless are separate.
If it is half styled, check the load order: Core first, Utilities last.
The interactive bits do nothing
Menus, tabs and accordions run on checkboxes, radios and :has(), which means
the CSS that drives them must be on the page. A page that uses a class whose stylesheet it
never linked looks correct in the markup and does nothing at all — the most embarrassing bug
on this site was exactly that, for months.
Something is on the wrong side under dir="rtl"
A physical property crept in. Use logical ones — margin-inline-start,
border-inline-end, inset-inline-start — including the longhands:
border-left-color is the one that slipped past us, colouring the wrong edge of
three alert variants.
Animations run for people who asked for less motion
Core collapses every transition and animation to effectively zero under
prefers-reduced-motion: reduce, and that applies to your own CSS too as long as
Core is loaded. If you add motion through JavaScript, guard it yourself:
if (matchMedia('(prefers-reduced-motion: no-preference)').matches) { … }
A third-party script is silently blocked
Check the console for ERR_BLOCKED_BY_RESPONSE. A strict
Cross-Origin-Embedder-Policy: require-corp blocks any cross-origin resource
that does not send its own CORP or CORS header, and it fails quietly. Either use
credentialless or have the resource send the header. Our own analytics was
blocked this way for weeks before a browser test caught it.