Skip to content
PolyFish CSS

Responsive

Where the breakpoints are, what changes at each one, and the two one-line rules that prevent most of the layout bugs a phone will find.

Device previewer

A real page in a real frame at a real width. Media queries inside it evaluate against the frame, not your screen, so this is what a phone actually gets — not a scaled screenshot and not a container-query approximation. Resize it and watch the navigation collapse, the table become cards and the tab strip start scrolling.

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.

width
height

320px — smallest phone still in use. Grids are single column, the nav is a burger, the hero stacks, tables scroll. 390px — iPhone 14/15. Same layout as 320 with more breathing room; nothing new engages. 430px — iPhone Pro Max. Still below every breakpoint: the framework is mobile-first, so this is the base stylesheet. 768px — tablet portrait. Two-column grids engage, the form rows split, the sidebar is still hidden. 1024px — small laptop. Three-column grids, sticky sidebars, the full navigation. Full width — four-column grids and the container's 1160px ceiling, which is a measure limit rather than a breakpoint.

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.

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
576 / 768 / 992 / 1200 / 1400Grid, Space, UtilitiesThe sm, md, lg, xl and xxl infixes — mobile first, so each applies from that width up

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, 68ch.

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


Next: what the framework handles for you, and what it cannot.

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.