Layout, forms and utilities
Containers and measures, the intrinsic primitives and breakpoints, every form control, and the spacing helpers.
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".
| Use | When | When space runs out |
|---|---|---|
.pf-stack | Anything vertical | Nothing to run out of; the gap stays even |
.pf-cluster | Tags, buttons, meta rows | Wraps item by item |
.pf-grid | Cards, features, a set of equals | Drops a column rather than crushing one |
.pf-switcher | Two or three peers that must match | All become a column at once, never one orphan |
.pf-sidebar | Narrow beside wide | Stacks when the main part would get too narrow |
.pf-split | Two halves of a section | Stacks at the breakpoint |
.pf-shell | Docs page: nav beside article | Nav hides, the article takes the width |
.pf-cover | Full-height band, one hero element | Grows 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>
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: 0on their children for exactly this; do the same in yours. - Panel rules in
.pf-tabscountdiv: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-coverusesmin-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-checksand.pf-stepsposition 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.
Or drag the bottom-right corner of the stage.
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
| Service | Status | p95 |
|---|---|---|
| api-gateway | Healthy | 184ms |
| billing-worker | Degraded | 1.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
base <40em sm 40em md 48em lg 64em xl 80em
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.
| Width | Module | What changes |
|---|---|---|
| 620 | Content, Forms, Components | Stacking tables become cards, form rows collapse to one column, the tab strip scrolls, grids drop to a single column |
| 640 (height) | Core | Short landscape: section and hero rhythm is capped rather than scaled |
| 760 | Forms, Footers | Horizontal fields stack, footer columns collapse |
| 860 | Headers, Components | Navigation becomes the checkbox menu, bottom bar appears, changelog entries stack |
| 900 | Core | Shell drops its sidebar, three and four column grids become two |
| 1100 | Core | Four column grids become three |
| 768 / 992 | Space, Utilities | The 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.
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.
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.
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.
| Query | Why it matters more than width |
|---|---|
pointer: coarse | A tablet is desktop-width with finger targets. Raises hit areas to 44px and floors fields at 16px so iOS does not zoom. |
hover: hover | Hover affordances only exist where a pointer can hover. On touch they are a state you can only reach by pressing and holding. |
prefers-reduced-motion | Vestibular triggers are not a screen size. Every transition drops to 0.01ms. |
prefers-color-scheme | The whole palette flips, unless the page pins a theme. |
forced-colors | Windows High Contrast replaces every colour; backgrounds vanish and borders have to be reinstated. |
@container | A 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
| Class | For |
|---|---|
.pf-input | Text inputs of every type, with --sm and --lg |
.pf-select | Native select, arrow styled without hiding the control |
.pf-textarea | Multi-line; --auto grows with content between 4 and 20 lines |
.pf-range | Native range slider |
.pf-file | File input with a styled button |
.pf-choice-card | A radio or checkbox presented as a selectable card |
.pf-fieldset / .pf-legend | Grouping related controls; --plain drops the border |
.pf-form__row | Fields 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 anaria-labelwhere 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
fieldsetwith alegend. 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-ringand--pf-focusexist 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.
| Step | Token | Default |
|---|---|---|
| 1 – 3 | --pf-space-1…3 | 4, 8, 12px |
| 4 – 6 | --pf-space-4…6 | 16, 24, 32px |
| 7 – 9 | --pf-space-7…9 | 56, 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.
| Prefix | From | Example |
|---|---|---|
| (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,--3and--4set a column ceiling that drops a column when a track would be too narrow;.pf-sidebarand.pf-switchercover the two asymmetric cases. They need no breakpoint prefix, which is the point of them..pf-flex-1,.pf-growand.pf-shrink-0for flex children — three names for what used to be nine..pf-content-start,--centerand--betweensetalign-content— which is what a grid uses to distribute slack between rows..pf-items-*setsalign-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.