Skip to content
PolyFish CSS

Layout, forms and utilities

Containers and measures, the intrinsic primitives and breakpoints, every form control, and the spacing helpers.

Documentation menu

01 / 03

Layout and responsive

Nine primitives, one measure and no twelve-column scaffold. How to pick a layout, what each does when space runs out, and the traps that only appear at 320px.

One measure, then everything inside it

Every layout decision starts from the container. .pf-container caps content at --pf-container (987px) and adds the gutter; --narrow drops to 720px for reading, --wide is the same 987px unless you raise the token. There is no twelve-column scaffold holding the page together — the container sets the measure and the primitives inside it decide their own widths.

<section class="pf-section">
  <div class="pf-container">
    <!-- content -->
  </div>
</section>

.pf-section owns the vertical rhythm through --pf-section-pad. Use --tight, --roomy and --vast to change the amount of air without touching padding by hand, and --alt to drop the section onto the recessed background when it carries dense content.

Choosing a primitive

Eight layouts cover everything the twelve-column system used to, and the column system itself was removed in 0.20. The question is never "how many columns" but "what should happen when there is not enough room".

UseWhenWhen space runs out
.pf-stackAnything verticalNothing to run out of; the gap stays even
.pf-clusterTags, buttons, meta rowsWraps item by item
.pf-gridCards, features, a set of equalsDrops a column rather than crushing one
.pf-switcherTwo or three peers that must matchAll become a column at once, never one orphan
.pf-sidebarNarrow beside wideStacks when the main part would get too narrow
.pf-splitTwo halves of a sectionStacks at the breakpoint
.pf-shellDocs page: nav beside articleNav hides, the article takes the width
.pf-coverFull-height band, one hero elementGrows past the screen rather than clipping

Layouts that ignore the viewport

The primitives added in 0.19 respond to the width of their own container, not the window, so the same component works in a sidebar and in a full-width band with no media query and no container query.

Switcher

A row of equal items that becomes a column all at once below --pf-switch (default 40rem). All-or-nothing, so you never get two items on one row and a third stranded below.

.hero-pair { --pf-switch: 32rem; }

<div class="pf-switcher hero-pair">
  <div>One</div>
  <div>Two</div>
  <div>Three</div>
</div>

Sidebar

A narrow element beside a wide one. It stacks only when the wide part would fall below --pf-main-min (55%), so the decision is made by the content rather than by a guess about screen size.

<div class="pf-sidebar">
  <nav class="pf-sidebar__side">…</nav>
  <div class="pf-sidebar__main">…</div>
</div>

Main: takes the rest, and drops below the side column when there is not room for both.

Set --pf-side for the narrow part's ideal width, 16rem by default. The sidebar can sit on either side: put __side second in the markup and it renders second, with no class change.

Cover, centre and imposter

.pf-cover gives a band a minimum height (--pf-cover-min, 100dvh) and vertically centres the child marked .pf-cover__main, pinning anything else to the top and bottom. .pf-center centres a column at the reading measure with the gutter outside it. .pf-imposter centres an element over a positioned container — a loading state, a badge over an image — and --contain keeps it inside rather than letting it overflow.

Grids that drop columns instead of crushing them

.pf-grid--2, --3 and --4 set a maximum number of columns, not a promise. If a track would be narrower than --pf-col-min (17rem), the grid uses fewer columns. That is why a three-column card set becomes two columns at a narrow measure instead of producing 220px tracks that break body text every three words.

/* at most three, fewer when a column would be under 20rem */
.feature-set { --pf-col-min: 20rem; }

/* as many as fit, each at least --pf-min wide */
.logo-wall { --pf-min: 320px; }

<div class="pf-grid pf-grid--3 feature-set"> … </div>
<div class="pf-grid pf-grid--auto logo-wall"> … </div>

Use --auto when the count genuinely does not matter — a logo wall, a set of swatches — and a numbered variant when it does.

Density and scoping

--pf-density multiplies the whole space scale, both gaps and the section rhythm, with one number. 0.85 suits an admin screen, 1.2 an editorial page.

:root { --pf-density: 0.9; }

For one region, use .pf-compact (0.8) or .pf-airy (1.4). They exist because custom properties resolve where they are declared: the space tokens are computed on :root, so setting --pf-density on an element deeper in the page would change nothing below it. Those two classes redeclare the scale on the element itself, which is what makes a local change work.

Traps worth knowing

  • A grid item's automatic minimum width is its longest unbreakable word, so a long URL or identifier can widen a column past the screen. The framework's own grids set min-inline-size: 0 on their children for exactly this; do the same in yours.
  • Panel rules in .pf-tabs count div:nth-of-type(), so an extra <div> placed directly inside a tab group shows the wrong panel.
  • A grid with many narrow tracks spends most of a small screen on gutters. This is why .pf-grid--* treats its column count as a ceiling and drops a column rather than letting every track collapse; check your own grids at 320px for the same reason.
  • .pf-cover uses min-block-size, not height, so a tall child grows the band instead of overflowing it.
  • A list item styled as a grid turns every inline element inside it into a grid item. That is why .pf-checks and .pf-steps position their markers instead: a <code> chip in the text used to land in the marker column and wrap one character per line.

Responsive

Prefer container queries where you can

Breakpoints answer "how wide is the window", which is the wrong question for a component that might sit in a sidebar on one page and a full-width band on another. The utilities page covers .pf-container-q and the .pf-cq-* classes, which ask about the component's own container instead.

Resize it yourself

A stage you can drag wider and narrower, with real components inside it. They respond to the stage rather than to your window, which is what container queries are for — and the section below explains where that stops being the same thing as a breakpoint.

width

Or drag the bottom-right corner of the stage.

Northbeam ProductPricingDocs

Hero

Copy on one side, evidence on the other.

Below 34rem of stage this stacks; above it, two tracks.

The card keeps its padding and its clipped corner at every width.

One

Two

Three

ServiceStatusp95
api-gatewayHealthy184ms
billing-workerDegraded1.8s

320px — one column throughout, the bar collapses to a burger, the table scrolls inside its wrapper. 390px — same as 320 with more room. Nothing new engages: the framework is mobile-first, so this is the base. 600px — the split goes to two tracks and the card grid takes two columns. 900px — three columns, the full link row, and the table fits without scrolling. Full width — the stage is as wide as the article column, which is where the documentation itself sits.

Container queries, not the viewport — and the difference matters

This stage resizes itself, and the components inside respond to its width because they use the container-query helpers. A media query would not move: it asks about the browser window, which has not changed.

That is also the honest limit of demonstrating responsiveness inside a page. To see the framework's own breakpoints engage, resize the browser and watch the readout below, or open any example page and narrow the window. The previous version of this page used an iframe to fake a viewport; it has been removed, and the site now refuses to be framed at all.

320px is not a legacy width

It is the iPhone SE and every Android device in landscape-locked kiosk mode, and it is what a 390px phone becomes at 125% text zoom. If a layout survives 320, it survives.

Why an iframe and not a scaled screenshot

Media queries inside an iframe resolve against the iframe's own width, so a page framed at 320px behaves exactly as it would on a 320px phone. A CSS transform would shrink the picture without changing a single breakpoint, which is the trap most "device preview" widgets fall into. The cost is stated on the performance page: this site's policy allows same-origin framing, and refuses everything else.

Breakpoints

Two sets, and they exist for different reasons. The component breakpoints are where a layout genuinely stops working; the grid breakpoints are a conventional ladder for the column system.

Live

Your window, right now

Single column everywhere. The navigation is a burger, grids stack, the sidebar is hidden and tables scroll inside their wrapper. This is the base stylesheet — the framework is mobile-first, so nothing has been added yet.

Two-column grids engage, form rows split, and the scroller shows more than one card. The navigation is still a burger.

Three-column grids, the full navigation bar, split heroes side by side, and the stats row goes horizontal.

Four-column grids, sticky sidebars, and the shell layout puts navigation beside content.

Everything above, plus the container reaching its 987px ceiling — a measure limit rather than a breakpoint.

Resize the window and this updates. Five media queries, no script: each one reveals its own label and hides the one below it.

WidthModuleWhat changes
620Content, Forms, ComponentsStacking tables become cards, form rows collapse to one column, the tab strip scrolls, grids drop to a single column
640 (height)CoreShort landscape: section and hero rhythm is capped rather than scaled
760Forms, FootersHorizontal fields stack, footer columns collapse
860Headers, ComponentsNavigation becomes the checkbox menu, bottom bar appears, changelog entries stack
900CoreShell drops its sidebar, three and four column grids become two
1100CoreFour column grids become three
768 / 992Space, UtilitiesThe md and lg infixes — mobile first, so each applies at that width and above. 576, 1200 and 1400 were dropped in 0.20: the first duplicated the base layout, the other two never applied at a 987px measure.

Nothing above is hardcoded twice. If you change where the navigation collapses, you change it in Headers and nowhere else, because no other module refers to 860px.

The two guards

Most horizontal-scroll bugs on a phone come from one of two things, and both have a one-line fix that is easy to leave out. The panes below are identical markup; the right-hand one has the guard removed.

min-width: 0 on grid children

A grid track is 1fr, but 1fr means at least the content's minimum size. A wide child — a table, a long identifier, a <pre> — pushes its track past the container and scrolls the page sideways.

with the guard
a3f9c2e8b7d14a6f90e2c5b8d3a7f1e4
second
min-width: auto (the default)
a3f9c2e8b7d14a6f90e2c5b8d3a7f1e4
second

Both panes clip at their own border here so the page itself stays usable — in a real layout the right-hand case pushes the whole document sideways. Core applies the guard to .pf-grid, .pf-split, .pf-shell and every .pf-row child.

A measure on body copy

Line length is not a stylistic preference at wide widths; past roughly 80 characters the eye starts losing its place returning to the next line. Core caps p and li at --pf-measure, 66ch.

66ch measure

Reading a long line is easy enough at the start, but the return sweep to the beginning of the next line is where the eye gets lost, and a measure is what keeps that sweep short enough to land in the right place every time.

no measure

Reading a long line is easy enough at the start, but the return sweep to the beginning of the next line is where the eye gets lost, and a measure is what keeps that sweep short enough to land in the right place every time.

Opt out per element with .pf-measure-none, which is what card and alert bodies do — they are already narrow.

Tables on a phone

Two strategies, and choosing wrongly is worse than either.

Scroll — the default

The wrapper scrolls horizontally and columns keep their alignment. Right for comparison tables, where reading across is the point.

.pf-table-wrap

Stack — opt in

Each row becomes a card with its column names inline. Right for lists of records, wrong for anything you compare across, because stacking destroys the alignment that made it a table.

.pf-table--stack

Capability, not just width

Width is the least interesting thing about a device. Six other queries do more work in this framework than the breakpoints do.

QueryWhy it matters more than width
pointer: coarseA tablet is desktop-width with finger targets. Raises hit areas to 44px and floors fields at 16px so iOS does not zoom.
hover: hoverHover affordances only exist where a pointer can hover. On touch they are a state you can only reach by pressing and holding.
prefers-reduced-motionVestibular triggers are not a screen size. Every transition drops to 0.01ms.
prefers-color-schemeThe whole palette flips, unless the page pins a theme.
forced-colorsWindows High Contrast replaces every colour; backgrounds vanish and borders have to be reinstated.
@containerA card in a sidebar and the same card in a main column are the same width problem. A viewport query cannot tell them apart.

The dvh detail

On a phone 100vh is the viewport with the address bar hidden — taller than what you can see, so a "full height" element overflows by the height of the bar. Every full-height rule here declares vh first and dvh second, so older engines take the first and newer ones the second.

02 / 03

Forms

Fields, controls, validation that waits until someone has typed, the 16px touch floor that stops iOS zooming, and an accessibility checklist worth running before you ship.

Forms are where a framework either respects the platform or fights it. Everything here builds on native validity states and native controls; nothing depends on a script, and nothing intercepts submission.

The field is the unit

Label, control, help text. Wrap them in .pf-field and the spacing, sizing and error placement come for free.

<div class="pf-field">
  <label class="pf-label" for="forms-email">Email</label>
  <input class="pf-input" id="forms-email" name="email" type="email" required>
  <p class="pf-help">We only use this to reply.</p>
</div>

We only use this to reply.

Always a real <label> with for. A placeholder is not a label: it disappears the moment someone types, it fails contrast in most designs, and screen readers treat it inconsistently. Use .pf-label--required to mark a required field visually, and keep the required attribute, which is what actually communicates it.

.pf-field--horizontal puts the label beside the control for dense forms. .pf-label-row is for a label with something on the other end — an optional marker, a character count.

Controls

ClassFor
.pf-inputText inputs of every type, with --sm and --lg
.pf-selectNative select, arrow styled without hiding the control
.pf-textareaMulti-line; --auto grows with content between 4 and 20 lines
.pf-rangeNative range slider
.pf-fileFile input with a styled button
.pf-choice-cardA radio or checkbox presented as a selectable card
.pf-fieldset / .pf-legendGrouping related controls; --plain drops the border
.pf-form__rowFields side by side; --2, --3, --split

.pf-textarea--auto uses field-sizing: content, so the control grows as you type with no script and no hidden mirror element. Where it is unsupported the textarea simply keeps its fixed height.

Validation that waits its turn

Use :user-invalid, not :invalid. The difference matters: an empty required field is invalid from the moment the page loads, so styling :invalid paints a pristine form red before anyone has typed a character. :user-invalid only matches after the person has interacted with the control.

.pf-input:user-invalid { border-color: … }

For the message, .pf-feedback with --error, --ok or --hint, or .pf-alert--inline for a one-line note under the field. Point the control at it with aria-describedby so the text is announced with the field rather than stranded beside it.

.pf-counter shows a character count, and --over marks the state where the limit is exceeded. Put it in a .pf-label-row opposite the label.

Touch and zoom

Form fields are floored at 16px under (pointer: coarse). Below that, iOS Safari zooms the page when a field takes focus and does not zoom back — a bug we shipped for two minor versions because the floor was written in a module that lost to a later cascade layer. If you restyle fields, keep the floor in the same place you restyle them:

@media (pointer: coarse) {
  .pf-input, .pf-select, .pf-textarea { font-size: 16px; }
}

Interactive targets are floored at 44px on coarse pointers for the same reason: a control smaller than a fingertip is a control that gets mis-tapped.

Accessibility checklist

  • Every control has a real label with for, or an aria-label where a visible label genuinely cannot exist.
  • Errors are announced, not just coloured: text plus aria-describedby, never colour alone.
  • Related radios and checkboxes sit in a fieldset with a legend. Screen readers use it to announce what the group is for.
  • Focus is visible on every control. Never remove the ring without replacing it — --pf-ring and --pf-focus exist to restyle it.
  • Submit with a real <button type="submit"> so Enter works.
  • Do not disable the submit button while a form is invalid: the person is left with no way to trigger the browser's own error messages.

Where the framework stops

There is no client-side validation library here, no submission handling and no state management. Native constraint validation covers required fields, types and patterns; a server has to cover the rest, because anything checked only in the browser is not checked at all. The framework styles the states the platform already gives you, and stops.

03 / 03

Utilities

The spacing scale, responsive prefixes, container queries, layout helpers and 99 state variants — plus the cases where reaching for a utility is the wrong move.

Utilities exist for the gaps between components, not as a way to build them. If you find yourself assembling a card out of fifteen utility classes, the card already exists — and if it genuinely does not, a rule in your own stylesheet will read better in six months than a line of abbreviations will.

The spacing scale

Nine steps, all derived from --pf-density, so a single knob moves every one of them. Margin and padding utilities follow the same numbering.

StepTokenDefault
1 – 3--pf-space-1…34, 8, 12px
4 – 6--pf-space-4…616, 24, 32px
7 – 9--pf-space-7…956, 88, 136px

What survives is deliberately small. .pf-gap-0…9 for the space between items, .pf-mt-1…9 and .pf-mb-1…9 for the space before and after a block, .pf-p-1…9 for padding, and the auto margins — .pf-mx-auto, .pf-ms-auto, .pf-me-auto, .pf-my-auto.

The axis and side families are gone: mx, my, px, py, pt, pb, ps, pe, gap-x, gap-y and the per-step m-N. That is 194 classes, and the reason they are gone is gap. Before flexbox supported it, spacing a row meant a margin on every child and a negative margin on the parent, and a framework had to ship a utility for every side. It does not any more: put the gap on the layout and the children need nothing.

<!-- before: a margin on every child -->
<div><p class="pf-mb-4">…</p><p class="pf-mb-4">…</p><p>…</p></div>

<!-- now: one class on the parent -->
<div class="pf-stack pf-gap-4"><p>…</p><p>…</p><p>…</p></div>

If you genuinely need padding on one side only, that is one declaration in your own stylesheet, and your stylesheet is unlayered so it wins. Carrying a hundred classes so that line does not have to be written was the wrong trade.

Responsive prefixes

Two breakpoints, applied as a prefix on the utility. They are min-width queries, so a prefix applies at that width and above — write the small layout first and override upward.

There used to be four. sm- at 576px was close enough to the base layout that it mostly duplicated it, and xl- at 1200px never applied at all on a page capped at 987px. Dropping them removed 180 classes and, so far, nothing anyone was using.

PrefixFromExample
(none)0px.pf-hidden
md-768px.pf-md-hidden
lg-992px.pf-lg-block

If you raise --pf-container well past 987px and genuinely need a third tier, a media query in your own stylesheet is three lines and beats carrying a hundred classes nobody loads.

Container queries: the better default

A viewport query asks how wide the window is. That is the wrong question for a component that might sit in a full-width band on one page and a 20rem sidebar on another — both are the same width problem and a media query cannot tell them apart.

<div class="pf-container-q">
  <article class="pf-card pf-cq-row"> … </article>
</div>

.pf-container-q marks the element as a query container; .pf-cq-row, .pf-cq-2, .pf-cq-3, .pf-cq-4 and .pf-cq-side then respond at 30rem of that container, wherever it sits. .pf-cq-show and .pf-cq-hide reveal or hide content on the same condition. Engines without container query support keep the stacked layout, which is the correct fallback rather than a broken one.

Prefer these over responsive prefixes for anything reusable. Reach for a prefix when the thing really is about the page — a site header, a page-level shell.

Layout helpers

  • .pf-grid--2, --3 and --4 set a column ceiling that drops a column when a track would be too narrow; .pf-sidebar and .pf-switcher cover the two asymmetric cases. They need no breakpoint prefix, which is the point of them.
  • .pf-flex-1, .pf-grow and .pf-shrink-0 for flex children — three names for what used to be nine.
  • .pf-content-start, --center and --between set align-content — which is what a grid uses to distribute slack between rows. .pf-items-* sets align-items, the cross-axis alignment within a row. Mixing these two up is the most common reason a stack stretches unexpectedly inside an equal-height grid column.
  • There is no order utility. Visual order that disagrees with focus order is a WCAG failure waiting to happen, so reorder the markup instead.

Text and visibility

.pf-text-xs…5xl for size, .pf-fg-muted, .pf-fg-accent and .pf-fg-accent-2 for colour, .pf-balance for balanced headings, .pf-clamp-2 and .pf-clamp-3 to truncate at a line count. For hiding: .pf-hide-mobile and .pf-hide-desktop hide at a size, .pf-visually-hidden hides visually while leaving the text for screen readers, and plain hidden removes it for everyone. They are not interchangeable — use the visually-hidden one for labels and skip links, never to hide decoration.

State variants, without a build step

The optional Variants module adds 19 classes of the form pf-{state}-{utility}, and each state carries only the utilities that make sense on it: hover takes background, foreground, border, underline, shadow and lift; focus and focus-within take the ring; active takes the two press transforms; group-hover and group-focus take foreground and lift. It was a 99-class cross product until 0.20, most of which nothing could use well — a disabled lift, an active underline.

<a class="pf-card pf-hover-lift pf-hover-border-accent pf-focus-ring" href="/docs"> … </a>

<div class="pf-group">
  <img …>
  <span class="pf-group-show">Caption on hover</span>
</div>

Hover variants are scoped to @media (hover: hover), so they never fire on a touch device where hover means "first tap". Every hover variant has a focus counterpart, so a keyboard reaches whatever a pointer reaches.

When not to use a utility

  • More than four or five on one element: write a class.
  • To override a component: change the token instead, or write an unlayered rule, which beats the framework without !important.
  • To express something structural like "this is a card": the semantics belong in a class name, or the next person cannot search for it.