Default card
Surface tint, hairline border, large radius. The icon slot is optional.
If a component appears under Content, it needs PolyFishCSS-Content.css. Core is assumed everywhere.
PolyFishCSS-Core.css
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 68 characters, because a line you can't track back to is a line nobody reads. Inline code, a 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.
11 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 | 11 KB |
| Headers | Top bar, nav, heroes, page headers | Yes | 7 KB |
| Content | Sections, cards, tables, forms | Yes | 12 KB |
| Footers | Footer layouts and link columns | Yes | 4 KB |
| Utilities | Spacing, colour, visibility helpers | Yes | 4 KB |
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.
<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.
PolyFishCSS-Grid.css
For layouts that genuinely change proportion per breakpoint. Most pages should use the Core primitives instead — one class, own breakpoints.
Both controls set a custom property on the row. The column classes on the cells never change.
Grid or primitive?
A row of three equal cards is .pf-grid--3 — one class, and it collapses on its own. Reach for .pf-row when the proportions themselves change: an 8/4 split that becomes 7/5 before it stacks.
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 checkPolyFishCSS-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-display, pf-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-sm, pf-hide-lg | One breakpoint at 860px, plus print rules |
Other things you can poke
Every documentation page on this site has something interactive in it:
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.