Skip to content
PolyFish CSS

Every component, grouped by file

If a component appears under Content, it needs PolyFishCSS-Content.css. Core is assumed everywhere.

Documentation menu

PolyFishCSS-Core.css

Type, buttons, layout

Native overlays: dialogs, menus and accordions with no script

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.

Modal dialog

command="show-modal" on the button, closedby="any" on the dialog.

Popover menu

A popover element, anchored to its button where the browser supports anchor positioning.

Edit Duplicate

Growing textarea

Opened without a script

This dialog is modal: the page behind it is inert, focus is trapped inside, and Escape or a click on the backdrop closes it. None of that is JavaScript.

An exclusive accordion

name="native-acc" on each <details> makes the group exclusive: opening one closes the others. Native since 2024.

That animates its height

Through ::details-content and interpolate-size, which let a transition reach height: auto. Where unsupported it opens instantly.

And needs no class to be exclusive

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.

Interactive: state variants

Hover, focus and press these. Every effect is a pf-<state>-<utility> class from the Variants module — plain CSS, no compiler, no script.

Lift on hover

pf-hover-lift pf-hover-shadow-lg pf-hover-border-accent pf-focus-ring

Group hover

The heading and this text react to the card. pf-group on the parent, pf-group-hover-fg-accent on the child.

A link inside, for keyboard focus

Press and theme

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.

What each component can and cannot do

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.

Buttons

.pf-btn

Can

  • Sit on a <button>, an <a>, or a <label> and look identical
  • Carry a variant, a size and a full-width modifier together
  • Show a loading state from aria-busy alone — no markup change
  • Reach 44px on a coarse pointer without you adding padding
  • Be a square icon button with --icon, still 44px on touch

Cannot, deliberately

  • Open a menu or a dialog on their own — that is data-pf-menu and data-pf-dialog, or your own code
  • Submit a form by existing; a <button> still needs type="submit"
  • Fake a disabled state that is still focusable — use aria-disabled and handle the click yourself
  • Contain a block element. They are inline-flex; a <div> inside will not wrap

Tabs

.pf-tabs

Can

  • Work with no JavaScript at all, as radio inputs and labels
  • Become a real ARIA tablist with roving tabindex when you add data-pf-tabs
  • Scroll the strip horizontally below 620px instead of wrapping
  • Hold any content in a panel, including another component

Cannot, deliberately

  • Deep-link to a panel — a radio has no URL. Use an accordion if the URL matters
  • Lazy-load a panel; every panel is in the DOM from the start, which is also why they print
  • Nest inside another tab set without the radio name colliding — give the inner set its own

Tables

.pf-table

Can

  • Scroll horizontally inside .pf-table-wrap rather than pushing the page sideways
  • Stack into labelled rows below 620px with --stack and data-pf-label
  • Sort on click with data-pf-sort, numerically where the column is numeric
  • Keep the header visible while the body scrolls with --sticky

Cannot, deliberately

  • Do any of that without the wrapper. The wrapper is the scroll container, and the check suite fails a bare table
  • Paginate, filter or search — that is your data layer, not a stylesheet
  • Resize columns by dragging

Cards

.pf-card

Can

  • Be a link in full with .pf-stretched-link on the one anchor inside
  • Clip a corner via --pf-cut, set once for the whole page
  • Hold any content — the padding and rhythm come from the card

Cannot, deliberately

  • Have two links and still be fully clickable. A stretched link covers the card; a second link under it is unreachable
  • Equalise height with a neighbour outside a grid. In .pf-grid they already match

Dialogs

.pf-dialog

Can

  • Open from HTML alone with command="show-modal" — focus trap, inert page and Escape from the browser
  • Close on a backdrop click with closedby="any"
  • Animate in and out, via @starting-style
  • Trap and restore focus with data-pf-dialog
  • Be dismissed by clicking the backdrop

Cannot, deliberately

  • Open without JavaScript in a browser older than the 2025 invoker-command baseline. There, add data-pf-dialog and the optional script as a fallback
  • Stack — a second dialog over a first is a design problem, not a styling one

Navigation

.pf-nav

Can

  • Open and close on mobile with no script, as a checkbox and a label
  • Close on an outside click or Escape once data-pf-menu is on a disclosure inside it
  • Scroll its own menu when it is taller than the screen, and clear the safe area

Cannot, deliberately

  • Do multi-level dropdowns. One level, by design: a nested hover menu is unusable on touch and hard to make accessible
  • Hide on scroll down and reappear on scroll up

Interactive: the markup behind each component

Rendered component above, the HTML that produced it below — the structure each one needs is the part prose is worst at conveying.

Alerts

Heads up

Colour is carried by a rule and a wash, so an alert can sit mid-article without shouting.

alertshtml
<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>

Cards

Facet card

The clipped corner is --pf-cut. Set it to 0 and every card squares off.

cardshtml
<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>

Form field

We only use this to send the receipt.

form fieldhtml
<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>

Tables

ModuleSize
Core20 KB
tableshtml
<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>

Tabs

First panel
Second panel
tabshtml
<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.

Interactive: the five layout primitives

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.

primitive
--pf-gap
one
two
three
short
a longer one
mid
another
last
1fr
1fr
1fr
1fr
1fr
1fr
content
media
sidebar
main

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

Intrinsic layouts: no breakpoints

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.

one
two
three

.pf-sidebar — stacks only when the main part would get too narrow.

side
main

.pf-cover — one principal element centred, the rest pinned. Shown here at a fixed height.

top
principal
bottom

.pf-compact and .pf-airy — the same card at three densities, one class each.

Compact0.8
Default1
Airy1.4

Interactive: build a button

variant
size
width

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

Interactive: states you cannot see by looking

state

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.

Type scale

Heading one, display face

Heading two

Heading three

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.

Buttons

Pills and markers

Default Green Orange Facet marker

Grid and split

.pf-grid--2
column
column
column
.pf-grid--auto
fits to --pf-min
no media query needed

PolyFishCSS-Headers.css

Announcement bars, navigation, heroes

The sticky bar at the top of this page is the navigation component. Below are the pieces that sit around it.

Announcement bar

Green bar, for the announcement that actually matters. Read it

Centred hero

Centred variant

One column, mesh behind it

The scale mesh is a masked background on the hero itself, so it never needs an image request or a wrapper element.

Shorter, for pages that aren't the front door

Breadcrumbs, title, one line of context.

PolyFishCSS-Content.css

The middle of the page

Cards

Default card

Surface tint, hairline border, large radius. The icon slot is optional.

Facet card

One clipped corner, sized by --pf-cut. Use it for the cards you want read first, not for all of them.

Accent link card

The whole card is the anchor. Hover and focus states are handled.

Feature grid and checklists

Tokens first

Components read roles, never raw hex values.

Prefixed classes

Everything starts with pf-, so nothing collides.

Readable source

Hand-written CSS with section headers, meant to be edited.

  • Checklist item with a facet bullet
  • Second item, wrapping onto a second line to show the alignment
  • Third item

Plan cards

Core only

Tokens, type and buttons.

5 KB one file

  • Reset and type scale
  • Layout primitives
  • Buttons and pills

Docs page

Core and Content.

23 KB two files

  • Sections and prose
  • Code blocks and tables
  • Accordions and alerts

Stats

  • 5Files
  • 0Dependencies
  • 1Required file
  • MITLicence

Table

FileHoldsNeeds CoreSize
CoreTokens, reset, type, layout, buttonsIs Core20 KB
HeadersTop bar, nav, heroes, page headersYes10 KB
ContentSections, cards, tables, formsYes19 KB
FootersFooter layouts and link columnsYes5 KB
UtilitiesSpacing, colour, visibility helpersYes8.5 KB
ReleaseThemeNote
0.14.0TokensFirst custom-property pass
0.15.0LayoutIntrinsic primitives landed
0.16.0FormsField, switch and range
0.17.0ContentCards, tables, quotes
0.18.0MotionScroll-driven reveals
0.19.0DocsSearch, anchors, pager
0.20.0CutGrid module removed
0.21.0PolishTen refinement passes
Scroll inside the frame — the header row stays put.

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

Alerts and quotes

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.
Design note, PolyFish 0.1.0

Variants added in this release

Six additions to families that already existed, each for a job the existing variants did badly.

  1. Numbered cards

    The counter comes from the card's position, so reordering the markup renumbers the set and deleting one leaves no gap.

  2. No typed digits

    Nothing in the markup says "02". .pf-card--numbered and a counter do it.

Media cards

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.

Operational Degraded Retired

The word carries the meaning, the dot carries the scan — colour is never the only signal.

Gzipped size per module
ModuleBytesShare
Core7,41238.4%
Content4,09621.2%
Components3,18016.5%
A framework you can read end to end in an afternoon is a framework you can fix at 2am.
Design note, PolyFish 0.1.0

Forms

Shown in the page title.

You can change this by deleting a link tag.

Code block

card.htmlhtml
<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>

Accordion, steps and gallery

Native details element

Open and close state is handled by the browser. No script, and it prints expanded.

Second panel

Wrap them in .pf-accordion for the spacing and the marker.

  1. Copy the files

    Numbered markers are only used where the content is a real sequence.

  2. Link them in order

    Core first.

CTA band

Sits above the footer

Mesh background, one clipped edge of contrast, two actions.

Replaced in 0.20

What happened to the twelve-column grid

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 writeWrite insteadWhy it is better
.pf-row + .pf-col-6.pf-grid.pf-grid--2Drops 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 __sideStacks when the main part gets too narrow, whatever the screen is doing
.pf-col-6.pf-col-lg-3 ×4.pf-grid.pf-grid--4One class, and the column count is a ceiling rather than a promise
Three equal columns that must match.pf-switcherAll become a column at once — never two on a row and a lonely third
.pf-offset-4.pf-ms-autoA start margin, which is what an offset always was
.pf-order-*Reorder the markupVisual order that disagrees with focus order is an accessibility bug waiting to happen
.pf-grid--2
two tracks, or one when they would be too narrow
.pf-sidebar__side
.pf-sidebar__main — stacks on content, not on a breakpoint

The layout guide covers the full set, and migrating has the Bootstrap mapping.

PolyFishCSS-Components.css

Application pieces

Eighteen components for the screens behind the login: badges, groups, pagination, progress, lists, switches, tabs, menus, dialogs. All of them work with no script.

Badges and tags

Default Accent Secondary Failing Outline Muted 12 css no-build

Button groups, segment, pagination and toolbar

24 results filtered

Progress, spinner and skeleton

Progress

Bar and segmented meter.

Spinner

Stops rotating under reduced motion, which is why the label is required.

List group, avatars and switches

api-gateway
10 second interval
Healthy
billing-worker
30 second interval
Degraded
Add a check +
https://
or

Tabs, menu and dialog

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.

Actions
Open dialog (CSS only)

Native dialog

Opened with showModal(), so focus is trapped, Escape closes it and focus returns to the button that opened it. The CSS-only version beside it does none of that — which is exactly the gap the optional script fills.

Built for a phone

Drawer

Opens on :target with no script. With the optional script it closes on Escape, returns focus and locks the page behind it.

Stacking table

Below 620px each row becomes a card and each cell shows its column name. Narrow this window to see it.

CheckRegionStatus
api-gatewayeu-westHealthy
billing-workerus-eastDegraded

Scroller

Snap points, no autoplay, no hidden slides.

Second

Swipe or scroll. Keyboard reaches every card.

Third

This is what PolyFish has instead of a carousel.

Fourth

Nothing is hidden from a screen reader.

Empty state and ratio box

No checks yet

Point PolyFish at a URL and it will start watching. Nothing here is a dead end.

Add a check

Toasts

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

Sortable columns

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.

CheckRegionLatency
APIus-east38 ms
Docsglobal112 ms
Storeeu-west96 ms

Facets, icons and the small pieces

Facet

.pf-facet clips the corner at --pf-cut; .pf-facet--sw clips the other side.

Icon and lockup

Sized to 1cap, sits on the baseline

pf-lockup

Brand pieces

Well, rule and pf-fg-brand — the mark's colours, kept out of the accent roles.

Social row:

Overlays and page chrome

Centred by pf-imposter

.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

Forms that tell you what went wrong

Sizes, floating labels, validation feedback, option cards, range and file inputs, and the layout rows to arrange them.

That does not look like an email address.

Resolves from all fourteen regions.

42 / 280

Every 30 seconds.

Disabled fields dim to the surface tint and take the not-allowed cursor.

Alert routing
Digest

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-Footers.css

Three footers

The full column footer is at the bottom of this page. These are the other two.

PolyFishCSS-Utilities.css

Helpers, if you want them

Every demo box on this page is built from these classes, so the file documents itself.

GroupClassesNotes
Spacingpf-mt-0…8, pf-mb-0…8, pf-p-4…6Maps to the 4px space scale
Gappf-gap-1…6Works on any flex or grid parent
Flexpf-flex, pf-col, pf-items-center, pf-justify-betweenEnough to stop writing one-off rules
Textpf-text-sm, pf-font-display, pf-font-mono, pf-balanceSizes come from the type tokens
Colourpf-fg-accent, pf-bg-deep, pf-bg-meshRole-based, so themes follow
Visibilitypf-hide-mobile, pf-hide-desktopOne 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.