Responsive Forms with Container Queries: Reflow Without Losing the Wiring
Forms are where responsive layout stops being cosmetic. A card that reflows badly looks awkward; a form that reflows badly separates a label from its input, drops a field group's heading, or sends Tab bouncing across the screen in an order nobody would choose. And forms are exactly the component most likely to be rendered at several widths in one product — full page at signup, in a 420px drawer for editing, in a dashboard column at checkout. This page builds a form whose layout is a function of its own width, which is the same premise as the rest of responsive component patterns and of Mastering Container Queries & Responsive Layouts generally, and then spends most of its time on the part that actually goes wrong: keeping the wiring intact across the switch.
Why layout, and only layout, should move
The useful mental model is that a form has two structures. One is semantic: which label names which control, which controls form a group, what order a person moves through them. The other is presentational: how many columns, whether the label sits above or beside. The semantic structure belongs entirely to the HTML and must not change with width. The presentational structure is the only thing a @container rule is allowed to touch.
That constraint sounds limiting and is actually liberating, because CSS Grid can rearrange boxes across two axes without touching a single element's position in the document. label before input in the source can paint above it or to the left of it purely by changing grid-template-columns. Nothing about for/id, aria-labelledby, or sequential focus order is even aware that the change happened.
Contrast this with the JavaScript approach, which typically re-renders the field with a different wrapper at a breakpoint. That works until it re-mounts an input mid-typing, loses the caret, drops the :focus state, or reads out a fresh label to a screen reader that was already inside the field. There is no version of a resize listener that is cheaper or safer than a @container rule here.
The complete form
Drag the frame from wide to narrow. Two thresholds fire at different moments: the paired city and postcode fields drop to one column first, then every label moves from beside its input to above it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Container-driven form</title>
<style>
body { margin: 0; padding: 1rem; font: 16px/1.5 system-ui, sans-serif; }
/* The form is its own container. Every rule below asks how wide this form
is — never how wide the window is. */
.signup {
container: form / inline-size;
display: grid;
gap: 0.85rem;
max-inline-size: 46rem;
}
/* Base state: label above input. This is the state a browser without
@container support gets, and it is the state that survives 400% zoom. */
.field { display: grid; gap: 0.3rem; }
.field__label { font-size: 0.9rem; font-weight: 600; }
.field__control,
.subgroup__item {
font: inherit;
/* Form controls carry an intrinsic default width of roughly 20
characters. Without these two declarations a grid track will refuse
to shrink below it and the control overflows the container. */
inline-size: 100%;
box-sizing: border-box;
padding: 0.5rem 0.65rem;
border: 1px solid #d4d4d8;
border-radius: 8px;
}
.field__control:focus-visible,
.subgroup__item:focus-visible {
outline: 2px solid #3b6df5;
outline-offset: 2px;
}
.subgroup { display: grid; gap: 0.5rem; }
.submit {
font: inherit;
padding: 0.55rem 1.1rem;
border: 0;
border-radius: 8px;
background: #3b6df5;
color: #fff;
}
/* Threshold 1: two fields can share a row. This is a question about the
pair of controls, not about label placement, so it gets its own rule. */
@container form (min-width: 24rem) {
.subgroup { grid-template-columns: 1fr 1fr; }
}
/* Threshold 2: there is room for a label column. Only grid placement
changes here — no order, no grid-row, no source reshuffling. */
@container form (min-width: 30rem) {
.field {
grid-template-columns: 9rem minmax(0, 1fr);
align-items: start;
column-gap: 1rem;
}
/* Optically align the label baseline with the input's first line. */
.field__label { padding-block-start: 0.55rem; text-align: end; }
/* The actions row has no label, so place the button in column 2 to keep
it aligned with the inputs above it. */
.field--actions > .submit { grid-column: 2; justify-self: start; }
}
</style>
</head>
<body>
<form class="signup" action="#" method="post">
<div class="field">
<label class="field__label" for="name">Full name</label>
<input class="field__control" id="name" name="name" type="text" autocomplete="name">
</div>
<div class="field">
<label class="field__label" for="mail">Email</label>
<input class="field__control" id="mail" name="email" type="email" autocomplete="email">
</div>
<!-- Two controls, one conceptual field. The group is declared in the
markup so it survives every layout the CSS can produce. -->
<div class="field field--group">
<span class="field__label" id="loc-label">Location</span>
<div class="field__control subgroup" role="group" aria-labelledby="loc-label">
<input class="subgroup__item" type="text" name="city" aria-label="City" autocomplete="address-level2">
<input class="subgroup__item" type="text" name="postcode" aria-label="Postcode" autocomplete="postal-code">
</div>
</div>
<div class="field field--actions">
<button class="submit" type="submit">Create account</button>
</div>
</form>
</body>
</html>
Key technique: place, never reorder
The whole reflow is one property. In the base state .field is a single-column grid, so its two children stack in source order. In the wide state it gains grid-template-columns: 9rem minmax(0, 1fr), and its two children flow into columns one and two — still in source order. The label was already first in the DOM, so it lands in the label column without anyone naming a row, a column, or an order value.
That is the discipline worth internalising for every form: a field's markup must already be in the order you want it read, and every layout state must be reachable by auto-placement alone. The moment you reach for grid-row: 1 or order: -1 to drag something back into position, the visual sequence and the focus sequence have diverged, and a keyboard user is now Tabbing through your form in a different order than the one they can see.
The minmax(0, 1fr) on the input column matters more than it looks. A grid track's default minimum is auto, which resolves to the item's min-content size — and an <input> reports a min-content size derived from its size attribute default, historically around twenty characters. Leave the track as plain 1fr and a narrow form will simply overflow rather than shrink; the general form of this problem is unpacked in preventing flex and grid overflow.
Note also which state is the base. Labels-above is written as the default and labels-beside is the enhancement, not the other way round. That ordering means the layout that works in the most constrained situation — narrow container, no @container support, heavy zoom — is the one that needs no rules to fire.
Variation: focus context that survives the reflow
Because the field wrapper is a real element in both states, it can carry state styling that reads correctly in either arrangement. :focus-within on the wrapper lets the label respond when its own input takes focus, which is genuinely useful when the label is a full column away:
.field:focus-within > .field__label {
color: #3b6df5;
}
@media (prefers-reduced-motion: no-preference) {
.field__label { transition: color 150ms ease; }
}
/* An invalid control after the user has interacted with it. :user-invalid
waits for interaction, unlike :invalid, which flags empty required
fields on first paint. */
.field__control:user-invalid {
border-color: #b42318;
}
.field:has(.field__control:user-invalid) > .field__label {
color: #b42318;
}
Colour alone must never be the only signal — pair it with a text error message referenced by aria-describedby. Related patterns for state feedback are collected in focus-within form patterns, and the transition guard follows the advice in reducing motion preferences in CSS.
Browser support
@container and container-type: inline-size shipped in Chrome 105, Edge 105, Safari 16.0 and Firefox 110. :user-invalid is available in Chrome 119, Edge 119, Safari 16.5 and Firefox 88, and :has() in Chrome 105, Edge 105, Safari 15.4 and Firefox 121 — both degrade to simply not applying, which costs a colour change rather than a layout. :focus-within predates all of them and is supported in every current engine.
Because labels-above is the base state, an engine that ignores every rule above still renders a correct, usable form. No @supports guard is needed for correctness; add one only if you want a viewport-based approximation of the two-column state, as described in handling container query fallbacks for older browsers.
FAQ
Does moving a label with CSS grid break its association with the input?
No. The association comes from the label's for attribute matching the input's id, which is a DOM relationship that CSS cannot touch. Grid placement only moves the painted boxes.
Why do my inputs overflow the form when it gets narrow?
Form controls have a default intrinsic width of around twenty characters that does not shrink, and a grid or flex item will not shrink below its min-content size. Set width: 100% with box-sizing: border-box on the control and minmax(0, 1fr) on the track it sits in.
Should the label sit above or beside the input at wide widths? Labels above the input are faster to scan and never truncate, so they are the safer default. Labels beside the input save vertical space in dense settings forms, which is why making the choice a container query rather than a hardcoded decision is useful.
How do I group two fields onto one row accessibly?
Wrap them in an element with role="group" and an aria-labelledby pointing at the group's visible heading, or use a fieldset with a legend. The grouping must exist in the markup, because a two-column grid conveys nothing to a screen reader.
Related
- Container Query Sidebar Layouts — the same place-don't-reorder rule at panel scale.
- Building Responsive Cards with Container Queries — the canonical two-threshold component.
- Responsive Component Patterns — the parent guide for this family of components.
- Focus-within Form Patterns — state feedback that works in either layout.
- Creating Accessible Focus Indicators — focus rings that stay visible against input borders.
Related articles
More pages in the same section.