Skip to content
PolyFish CSS

JavaScript, performance and guides

The one optional script and what it adds, measured sizes and budgets, then the recipes, tips and fixes.

Documentation menu

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">noneCloses on outside click and Escape; keeps aria-expanded on the summary in sync
data-pf-tabs.pf-tabsnonerole="tablist", role="tab", aria-selected, aria-controls, roving tabindex, arrow-key and Home/End navigation
data-pf-dialogany buttonid of the dialogFallback 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-closebutton inside a dialognoneCloses the nearest dialog
data-pf-copy.pf-codenoneAdds a .pf-code__copy button and a polite live region for the result
data-pf-counterinput or textarea with maxlengthid of the counter elementLive character count, updated on input
data-pf-dismissbuttonselector to removeRemoves the closest matching ancestor
data-pf-sort<table>noneWraps each sortable header in a button, sorts on click, sets aria-sort; detects numeric columns
data-pf-nosort<th>noneExcludes that column from sorting
data-pf-spy.pf-sidenavnoneMarks the link for the section currently in view with aria-current
data-pf-theme-togglebuttonnoneCycles auto → light → dark, writes data-pf-theme on <html>, remembers the choice
data-pf-theme-labelelement inside that buttonnoneReceives the current mode as text
data-pf-tipany element with .pf-tooltiptooltip textRead by CSS, not the script — no JavaScript involved
data-pf-label<td> in .pf-table--stackcolumn labelAlso 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.

after injecting markupjs
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

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

main.tsts
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:

Reacttsx
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:

tokens from jsts
const accent = getComputedStyle(document.documentElement)
  .getPropertyValue("--pf-accent");

Enhancements

AttributeOnWhat it addsWithout it
data-pf-menudetails.pf-menuCloses on outside click and Escape, keeps aria-expanded in syncOpens and closes on its summary
data-pf-tabs.pf-tabsFull ARIA tablist: roles, aria-selected, roving tabindex, Home and EndRadios switch panels, arrows already work
data-pf-dialogAny triggerNative showModal(), focus trapping, focus returned on close:target dialog, no focus trap
data-pf-copy.pf-codeA copy button and a polite status messageSelect and copy by hand
data-pf-countertextareaLive character count against maxlengthThe browser still enforces the limit
data-pf-dismissAny buttonRemoves its closest alertThe alert stays
data-pf-nosortthOpts one column out of sorting; data-pf-value on a cell sorts on that instead of its text—
data-pf-sorttableClick-to-sort headers with aria-sort, numeric-awareThe table renders in source order
data-pf-spy.pf-sidenavMarks the current section with aria-currentThe sidebar is a plain list of links
data-pf-dialog-closeAny button in a dialogCloses the native dialog it sits insideUse form method="dialog" or the CSS-only close link
data-pf-drawer.pf-drawerEscape closes it, focus moves into the panel and back to the trigger, and the page behind it stops scrollingOpens and closes on :target
data-pf-theme-toggleAny buttonCycles auto, light, dark and remembers the choice. Put data-pf-theme-label on a child to have its text updated tooThe 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.

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

enhancements
markupyour selection
<!-- 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 --><details class="pf-menu" data-pf-menu>            closes on outside click and Escape<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.

consolejs
// 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.

DecisionDo thisWhy
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, www to apex, and every canonical rewrite in a single hop — a redirect chain costs a round trip each.
  • Clean URLs through try_files, so /docs#install serves install.html without a redirect.
  • Immutable caching on hashed assets, must-revalidate on 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-inline in 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.

ClassOn screenOn paper
.pf-no-printVisibleHidden
.pf-print-onlyHiddenVisible

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.

FaceUnits/emAscentDescentAverage width
Inter204896.9%24.1%610/1000
Bricolage Grotesque 700100093.0%27.0%539/1000
IBM Plex Mono1000102.5%27.5%600/1000
Arial (the fallback)204890.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.

page type

Marketing page · Core + Headers + Content + Footers

Core20 KB
Headers10 KB
Content19 KB
Footers5 KB
over the wire13.1 KB gzip

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

four modules54 KB
Space2.5 KB
over the wire13.7 KB gzip

Space is 2.5 KB — the spacing utilities cost almost nothing now.

Application screen · Core, Headers, Content, Forms, Components

Core20 KB
Headers10 KB
Content19 KB
Forms6 KB
Components20 KB
over the wire13.5 KB gzip

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

PolyFishCSS.min99 KB
over the wire18.2 KB gzip

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

ModuleMinifiedGzippedLoad it when
Core20.0 KB5.1 KBAlways
Headers10.4 KB2.6 KBThe page has a nav or a hero
Content18.7 KB4.2 KBAlmost always
Components19.6 KB4.0 KBApp UI: tabs, menus, drawers, toasts
Forms5.8 KB1.6 KBBeyond the four basic controls
Footers4.6 KB1.2 KBThe page has a footer
Space2.5 KB0.6 KBYou want spacing utilities
Effects9.4 KB1.8 KBFrost, gradients or animation
Utilities8.5 KB2.6 KBOptional helpers
Variants1.8 KB0.6 KBCurated hover and focus states
Classless4.8 KB1.3 KBStyles bare semantic HTML
Fonts3.3 KB0.6 KBYou want the shipped typefaces
polyfish.js9.9 KB3.7 KBOptional, 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. crossorigin is mandatory on a font preload — without it the file downloads twice.
  • font-display: swap means 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 /fonts and 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.

CauseWhat 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.
Fontsswap 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-revalidate on HTML, so a page is never stale while its assets are cached forever.
  • gzip on, with text/html left out of gzip_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>

Delete this record?

This cannot be undone.

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, immutable and 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() and clamp() replace most breakpoint work: width: min(100%, 60ch) is a responsive column in one line.
  • ch for measure, because it tracks the font rather than the pixel.
  • dvh instead of vh on phones, where the address bar makes vh lie. Write 100vh first as the fallback, then 100dvh.
  • lh sizes something to a line of text: a 4-line textarea is block-size: 4lh.
  • cqi is vw for 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.