Facet card
The clipped corner is --pf-cut. Set it to 0 and every card squares off.
If a component appears under Content, it needs PolyFishCSS-Content.css. Core is assumed everywhere.
PolyFishCSS-Core.css
Most frameworks ship a modal as JavaScript because until recently it had to be. The popover attribute and invoker commands reached every engine in 2025, so these open from HTML alone — focus trap, Escape and light dismiss handled by the browser. Try them with JavaScript turned off.
command="show-modal" on the button,
closedby="any" on the dialog.
A popover element, anchored to its button
where the browser supports anchor positioning.
name="native-acc" on each <details> makes the group
exclusive: opening one closes the others. Native since 2024.
Through ::details-content and interpolate-size, which let a
transition reach height: auto. Where unsupported it opens instantly.
The only class is pf-accordion on the wrapper, for the styling.
Older browsers: the dialog and menu buttons do nothing and the
content stays hidden. For a dialog that must open everywhere, add data-pf-dialog
as well — the optional script only acts where the browser did not.
Hover, focus and press these. Every effect is a pf-<state>-<utility>
class from the Variants module — plain CSS, no compiler, no script.
pf-hover-lift pf-hover-shadow-lg pf-hover-border-accent pf-focus-ring
The heading and this text react to the card. pf-group on the parent, pf-group-hover-fg-accent on the child.
The press states above are .pf-active-sink and .pf-active-scale-down from the Variants module. Switch themes with the toggle in the header — the roles flip on their own, with no scheme-specific classes.
Hover effects sit inside @media (hover: hover), so a tap on a phone does not leave a card stuck lifted. Transitions sit inside the reduced-motion guard.
The second column is the one worth reading. A framework that documents only its features leaves you to find the edges by hitting them, usually at the point where you have already built something on top.
.pf-btn
Can
aria-busy alone — no markup change--icon, still 44px on touchCannot, deliberately
data-pf-menu and data-pf-dialog, or your own code<button> still needs type="submit"aria-disabled and handle the click yourself<div> inside will not wrap.pf-tabs
Can
data-pf-tabsCannot, deliberately
name colliding — give the inner set its own.pf-table
Can
.pf-table-wrap rather than pushing the page sideways--stack and data-pf-labeldata-pf-sort, numerically where the column is numeric--stickyCannot, deliberately
.pf-card
Can
.pf-stretched-link on the one anchor inside--pf-cut, set once for the whole pageCannot, deliberately
.pf-grid they already match.pf-dialog
Can
command="show-modal" — focus trap, inert page and Escape from the browserclosedby="any"@starting-styledata-pf-dialogCannot, deliberately
data-pf-dialog and the optional script as a fallback.pf-nav
Can
data-pf-menu is on a disclosure inside itCannot, deliberately
Rendered component above, the HTML that produced it below — the structure each one needs is the part prose is worst at conveying.
Heads up
Colour is carried by a rule and a wash, so an alert can sit mid-article without shouting.
<div class="pf-alert pf-alert--warn">
<p class="pf-alert__title">Heads up</p>
<p>Colour is carried by a rule and a wash, so an alert can sit mid-article without shouting.</p>
</div>
The clipped corner is --pf-cut. Set it to 0 and every card squares off.
<article class="pf-card pf-card--facet">
<span class="pf-card__icon" aria-hidden="true">01</span>
<h3 class="pf-card__title">Facet card</h3>
<p class="pf-card__body">The clipped corner is --pf-cut. Set it to 0 and every card squares off.</p>
</article>
We only use this to send the receipt.
<div class="pf-field">
<label class="pf-label pf-label--required" for="email">Email</label>
<input class="pf-input" id="email" type="email" aria-describedby="email-help">
<p class="pf-help" id="email-help">We only use this to send the receipt.</p>
</div>
| Module | Size |
|---|---|
| Core | 20 KB |
<div class="pf-table-wrap">
<table class="pf-table pf-table--zebra pf-table--hover">
<thead><tr><th scope="col">Module</th><th scope="col">Size</th></tr></thead>
<tbody>
<tr><th scope="row">Core</th><td>20 KB</td></tr>
</tbody>
</table>
</div>
<div class="pf-tabs" data-pf-tabs>
<input class="pf-tabs__radio" type="radio" name="t" id="t1" checked>
<label class="pf-tabs__label" for="t1">First</label>
<input class="pf-tabs__radio" type="radio" name="t" id="t2">
<label class="pf-tabs__label" for="t2">Second</label>
<div class="pf-tabs__panel">First panel</div>
<div class="pf-tabs__panel">Second panel</div>
</div>
Each toggle is a visually hidden checkbox with a label, so it is reachable by keyboard and announced as a control rather than as styled text.
Core ships five, and they are the hardest part of the framework to understand from prose. Each one reads the gap tokens, so the second control changes all of them at once.
.pf-stack — one column, evenly spaced. A grid rather than margins, so the gap never collapses and the last child needs no exception.
.pf-cluster — a wrapping row that keeps its spacing in both directions. For button groups, tag rows and anything whose item count you do not control.
.pf-grid — equal columns that collapse on their own. --3 asks for three; below the breakpoint it takes fewer without a media query from you.
.pf-split — two tracks that become one when either would go under about 420px. Every child carries min-width: 0, which is what stops a wide table blowing the track out.
.pf-shell — a fixed sidebar beside a fluid main column, stacking below lg. The documentation you are reading is built on it.
Radio inputs and :has(). The boxes are plain divs — the arrangement is entirely the primitive.
These respond to the width of their own container, not the window. Each frame below can be resized from its corner — drag it narrower and watch the layout change at the container's width, whatever the size of your screen.
.pf-switcher — all in a row, or all in a column. Never an orphan.
.pf-sidebar — stacks only when the main part would get too narrow.
.pf-cover — one principal element centred, the rest pinned. Shown here at a fixed height.
.pf-compact and .pf-airy — the same card at three densities, one class each.
<button class="pf-btn pf-btn--secondary pf-btn--ghost pf-btn--accent-2 pf-btn--link pf-btn--sm pf-btn--lg pf-btn--block" type="button">Save changes</button>
Fifteen real buttons, one revealed — not one element restyled to look like the others. The class string above is assembled from the same controls.
The resting state. Everything below is the same markup with one attribute changed.
The ring comes from :focus-visible, so it appears for keyboard focus and not for a mouse click. Shown here without holding the tab key.
disabled and aria-disabled="true" both dim and block pointers. The second stays focusable, which is usually what you want — a control nobody can reach cannot explain why it is off.
Driven by aria-busy, so the spinner and the announced state come from one attribute. The spinner is a pseudo-element — no markup changes when the state flips.
That does not look like an email address.
Styling keys off aria-invalid, and the message is joined with aria-describedby — without it a screen reader says "invalid" and never says why.
Radio inputs and :has(). Each row is real markup with a real attribute, not a screenshot of a state.
Heading one, display face
Body copy sits at one rem with a 1.65 line height and a measure capped at 66 characters, because a line you can't track back to is a line nobody reads. Inline code, an inline link, bold and a ⌘K key all live at this size.
Small print, muted role colour.
PolyFishCSS-Headers.css
The sticky bar at the top of this page is the navigation component. Below are the pieces that sit around it.
Centred variant
The scale mesh is a masked background on the hero itself, so it never needs an image request or a wrapper element.
Breadcrumbs, title, one line of context.
PolyFishCSS-Content.css
Surface tint, hairline border, large radius. The icon slot is optional.
One clipped corner, sized by --pf-cut. Use it for the cards you want read first, not for all of them.
The whole card is the anchor. Hover and focus states are handled.
Components read roles, never raw hex values.
Everything starts with pf-, so nothing collides.
Hand-written CSS with section headers, meant to be edited.
Tokens, type and buttons.
5 KB one file
Core, Headers, Content, Footers.
33 KB four files
Core and Content.
23 KB two files
| File | Holds | Needs Core | Size |
|---|---|---|---|
| Core | Tokens, reset, type, layout, buttons | Is Core | 20 KB |
| Headers | Top bar, nav, heroes, page headers | Yes | 10 KB |
| Content | Sections, cards, tables, forms | Yes | 19 KB |
| Footers | Footer layouts and link columns | Yes | 5 KB |
| Utilities | Spacing, colour, visibility helpers | Yes | 8.5 KB |
| Release | Theme | Note |
|---|---|---|
| 0.14.0 | Tokens | First custom-property pass |
| 0.15.0 | Layout | Intrinsic primitives landed |
| 0.16.0 | Forms | Field, switch and range |
| 0.17.0 | Content | Cards, tables, quotes |
| 0.18.0 | Motion | Scroll-driven reveals |
| 0.19.0 | Docs | Search, anchors, pager |
| 0.20.0 | Cut | Grid module removed |
| 0.21.0 | Polish | Ten refinement passes |
--sticky needs a scroll container, which is what --tall gives the wrap. --bordered grids every cell, --roomy opens the padding, --fixed makes columns even. data-pf="yes" tints a cell accent; data-pf="no" mutes it.
Default
Left rule in the accent colour.
Success
Deployed. The file is live at the path you set.
Careful
Utilities loads last. Move it above Content and the helpers stop winning ties.
Broken
Core is missing. Every other file reads its custom properties and renders unstyled without it.
A framework you can read end to end in an afternoon is a framework you can fix at 2am.
Six additions to families that already existed, each for a job the existing variants did badly.
The counter comes from the card's position, so reordering the markup renumbers the set and deleting one leaves no gap.
Nothing in the markup says "02". .pf-card--numbered and a counter do it.
A fixed 16:9 band at the top, cropped rather than letterboxed, so a row of cards lines up whatever the image dimensions. Shown here with a plain ground in place of a photo.
Saved. Inline alerts are one line.
That field needs a value.
The word carries the meaning, the dot carries the scan — colour is never the only signal.
| Module | Bytes | Share |
|---|---|---|
| Core | 7,412 | 38.4% |
| Content | 4,096 | 21.2% |
| Components | 3,180 | 16.5% |
A framework you can read end to end in an afternoon is a framework you can fix at 2am.
<article class="pf-card pf-card--facet">
<h3 class="pf-card__title">Facet card</h3>
<p class="pf-card__body">Two classes, no wrapper.</p>
</article>
Open and close state is handled by the browser. No script, and it prints expanded.
Wrap them in .pf-accordion for the spacing and the marker.
Numbered markers are only used where the content is a real sequence.
Core first.
Mesh background, one clipped edge of contrast, two actions.
Replaced in 0.20
The Grid module is gone. It was 114 classes — twelve spans across three breakpoints, plus offsets, order and row gutters — and everything it did is covered by primitives that carry their own breakpoints.
| You used to write | Write instead | Why it is better |
|---|---|---|
.pf-row + .pf-col-6 | .pf-grid.pf-grid--2 | Drops to one column when a track would be unreadable, instead of at a fixed width |
.pf-col-12.pf-col-md-8 beside .pf-col-md-4 | .pf-sidebar with __main and __side | Stacks when the main part gets too narrow, whatever the screen is doing |
.pf-col-6.pf-col-lg-3 ×4 | .pf-grid.pf-grid--4 | One class, and the column count is a ceiling rather than a promise |
| Three equal columns that must match | .pf-switcher | All become a column at once — never two on a row and a lonely third |
.pf-offset-4 | .pf-ms-auto | A start margin, which is what an offset always was |
.pf-order-* | Reorder the markup | Visual order that disagrees with focus order is an accessibility bug waiting to happen |
The layout guide covers the full set, and migrating has the Bootstrap mapping.
PolyFishCSS-Components.css
Eighteen components for the screens behind the login: badges, groups, pagination, progress, lists, switches, tabs, menus, dialogs. All of them work with no script.
Bar and segmented meter.
Stops rotating under reduced motion, which is why the label is required.
Radio inputs and sibling selectors. Arrow keys move between tabs natively, which is the behaviour a scripted tablist has to reimplement.
Fourteen regions, two must agree before an alert is raised.
Thirty days of raw check results, exportable as CSV.
Opens on :target with no script. With the optional script it closes on Escape, returns focus and locks the page behind it.
Below 620px each row becomes a card and each cell shows its column name. Narrow this window to see it.
| Check | Region | Status |
|---|---|---|
| api-gateway | eu-west | Healthy |
| billing-worker | us-east | Degraded |
Snap points, no autoplay, no hidden slides.
Swipe or scroll. Keyboard reaches every card.
This is what PolyFish has instead of a carousel.
Nothing is hidden from a screen reader.
Point PolyFish at a URL and it will start watching. Nothing here is a dead end.
Add a checkThe one component that needs JavaScript: PolyFish.toast("Saved") drops a message into a fixed .pf-toasts stack — a polite live region that queues behind whatever the screen reader is saying. Rendered inline here so the page does not pop one at you.
With the optional script, data-pf-sort gives every column a .pf-th-sort button: the arrow lights up when the column carries aria-sort, so the sort state is announced rather than just drawn. data-pf-nosort exempts a column. Try it — the table below really sorts.
| Check | Region | Latency |
|---|---|---|
| API | us-east | 38 ms |
| Docs | global | 112 ms |
| Store | eu-west | 96 ms |
.pf-facet clips the corner at --pf-cut; .pf-facet--sw clips the other side.
Well, rule and pf-fg-brand — the mark's colours, kept out of the accent roles.
.pf-imposter centres an element over its positioned parent — a spinner over a panel, a badge over an image. .pf-center centres a column at the reading measure; .pf-scroll-lock freezes the page behind an open drawer.
Full-screen chrome lives on the examples: .pf-screen powers the sign-in page, and .pf-bottombar + .pf-has-bottombar power the app shell's mobile tab bar — with the toast stack raised clear of it.
PolyFishCSS-Forms.css
Sizes, floating labels, validation feedback, option cards, range and file inputs, and the layout rows to arrange them.
Collapses to stacked below 760px.
Why aria-invalid, not a class
The red border and the announced error come from one attribute, so they cannot drift apart. The message is joined to the field with aria-describedby — without it a screen reader says "invalid" and never says why.
PolyFishCSS-Utilities.css
Every demo box on this page is built from these classes, so the file documents itself.
| Group | Classes | Notes |
|---|---|---|
| Spacing | pf-mt-0…8, pf-mb-0…8, pf-p-4…6 | Maps to the 4px space scale |
| Gap | pf-gap-1…6 | Works on any flex or grid parent |
| Flex | pf-flex, pf-col, pf-items-center, pf-justify-between | Enough to stop writing one-off rules |
| Text | pf-text-sm, pf-font-display, pf-font-mono, pf-balance | Sizes come from the type tokens |
| Colour | pf-fg-accent, pf-bg-deep, pf-bg-mesh | Role-based, so themes follow |
| Visibility | pf-hide-mobile, pf-hide-desktop | One breakpoint at 860px, plus print rules |
Other things you can poke
Most documentation pages on this site have something interactive in them:
swap page regions,
retint a live page,
change the type scale,
see what each module unlocks,
assemble your link block,
frame a real page at 320px,
weigh a page type,
build an attribute list,
turn the invisible guards on,
flip a component through its states and
filter the changelog by tag.
All of it is radio inputs, checkboxes and :has() — no script runs any of it.