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.

Label placement changes, field wiring does not Below the threshold each label sits above its input; above it the label moves to a left column. The label-to-input association and tab order are unchanged. same markup, two grid templates narrow: label above input 1 input 2 submit wide: label beside input 1 input 2 submit Tab order is label-free and identical: input 1, input 2, submit.

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.

Live demoForm that switches between label-beside and label-above
Drag the handle. Above 30rem labels sit beside their inputs; below that they move above. Tab through the fields at both widths — the focus order never changes. Drag the bottom-right corner to resize the frame in either direction.
<!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 articles

More pages in the same section.