Breakpoints

Two named breakpoints in active use, both above the phone range. Apps built on this library are expected to be desktop-first: the named set supports richer layouts on wide displays and identifies when to use a single-pane layout. Smaller breakpoints (sm, md) are intentionally not part of the named vocabulary; a single component that wants to stack at narrow phone widths can still use a one-off @media without joining the shared set.

The minimum supported application width is lg — 768 px. Narrower application layouts are outside the current support range. Recorded in full on Browser support: desktop and iPad are supported (iPad on current iOS Safari only) and phone form factors are deferred, so 768 px is both the narrowest popular iPad in portrait matching the existing lg breakpoint. This uses the existing breakpoint listed below. Ruled 2026-08-06.

Phone widths are out of the product's scope, not merely out of the named set. That is why sm and md are absent here: a consequence of the scope ruling rather than an independent style choice. xs below is a reserved Open Props name this project has not adopted, and it is not a commitment to 360 px.

Vocabulary

Name Threshold Use when
lg / lg-n-above 48em (768px) One-pane layouts can become two-pane: a sidebar appears next to <main>, dialogs are no longer pressed against the viewport edge, side rails can sit beside their primary content.
xl / xl-n-above 90em (1440px) Use the extra display width for extra columns, side-by-side panes that were stacked, persistent reference content (outline, metadata, secondary panel) instead of a popover. Below this is "laptop"; above this is "wide monitor."

xxs (240), xs (360), sm (480), md (768), xxl (1920) are also reserved by name in Open Props' props.media.css. Adopt another named breakpoint only when a feature needs it.

Syntax — plain @media with range syntax

Modern range syntax (width >= …) is Baseline 2023 and reads more naturally than min-width / max-width. Always include the Open Props name in a comment so the rule is greppable and ready to swap to @media (--lg-n-above) if the project ever adopts a CSS build step.

Show code /* Mobile-first base */ .layout { display: grid; grid-template-columns: 1fr; } /* TODO(future): switch to @media (--lg-n-above) once PostCSS Custom Media lands. https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-custom-media */ @media (width >= 48em) { /* lg-n-above */ .layout { grid-template-columns: 16rem 1fr; } } @media (width >= 90em) { /* xl-n-above */ .layout { grid-template-columns: 18rem 1fr 22rem; } }

Live demo

The element below carries different background and label per breakpoint. Resize the window past 48em (768px) and 90em (1440px) to see the swap.

@container queries — prefer over @media for component layout

@container (Baseline 2023) reacts to the size of an element's container, not the viewport. Use it when a component might be placed in different layouts (a card grid in the main column vs in a side panel) and should adapt to whatever space it gets.

Reach for @media for page-level shifts (sidebar appears, content reflow). Reach for @container for component-internal reflow (column count, label position, density).

Show code .card-grid { container-type: inline-size; } .card-grid .cards { display: grid; grid-template-columns: 1fr; } @container (width >= 28rem) { .card-grid .cards { grid-template-columns: 1fr 1fr; } } @container (width >= 44rem) { .card-grid .cards { grid-template-columns: 1fr 1fr 1fr; } }

Live demo

Drag the bottom-right corner of the dashed area to resize. The grid reflows when the container itself reaches 28rem and again at 44rem, regardless of viewport width.

A
B
C
D
E
F

JS detection — last resort

Almost every responsive decision is layout, and CSS expresses layout better than JS does. JS-side viewport detection is appropriate only when the decision can't be expressed in CSS — for example, swapping component implementations or attaching different event handlers per viewport.

Until that case appears, no shared viewport.mjs helper exists. When the first consumer arises, instantiate matchMedia locally:

Show code const lg = window.matchMedia('(width >= 48em)') if (lg.matches) { /* desktop-only behavior */ } lg.addEventListener('change', (e) => { // react when the viewport crosses the boundary })

Hoist to public/lib/viewport.mjs only when a second consumer appears. One use site is not enough to justify a shared module.

Open Props and @custom-media

Open Props ships props.media.css with @custom-media declarations for every named breakpoint (--lg-n-above, --xl-n-above, …). These are not Baseline — without a build step that processes them via PostCSS Custom Media, browsers ignore the @custom-media at-rule and a stylesheet that writes @media (--lg-n-above) never matches.

Without that build step, write the numeric form (@media (width >= 48em)) with the Open Props name in a comment. If the project ever adopts PostCSS, the comment makes a mechanical swap easy.

Notes

See also