Form Patterns: Styling a Group From the Field Inside It
Forms are the one place where the focused element is almost never the element you want to style. Focus lands on an <input>; the thing that should react is the field group around it, the label beside it, or the help text below it. Before :focus-within this required a focusin/focusout listener toggling a class, and every stale class was a visual bug. The narrow problem this page solves is expressing all of those group-level reactions in CSS, and knowing precisely when :focus-within is the right selector and when :has(:focus) is. It belongs to Hover & Focus State Design and complements the input-capability work in Pointer and Hover Media Queries.
Patterns covered:
- Highlighting the active field group
- A floating label with no JavaScript
- Revealing help text only while the group is in use
- Choosing between
:focus-withinand:has(:focus)
What :focus-within actually matches
:focus-within matches an element if that element matches :focus, or if any element in its flat-tree descendants matches :focus. Two details in that definition do real work. First, the element itself counts — a focusable container matches its own :focus-within. Second, the flat tree is what is traversed, so focus inside a shadow root propagates up through the host: a custom element wrapping a native input will match :focus-within when that input is focused, which a naive descendant selector could not express.
There is no upper bound on the distance. Focus five levels deep still matches every ancestor along the chain, which is why the selector is normally scoped to a specific container class rather than applied broadly.
:has(:focus) looks equivalent and is not. It matches an element that contains a focused element, and stops there: the focused element itself does not match :has(:focus), because nothing inside it is focused. In practice :focus-within is equivalent to :is(:focus, :has(:focus)).
The other difference is weight. :focus-within is an ordinary pseudo-class contributing (0, 1, 0) to specificity. :has() contributes nothing itself but takes the specificity of its most specific argument, so .group:has(#save:focus) inherits an ID's weight and will beat rules you did not expect it to. When the two selectors would do the same job, :focus-within is the predictable one.
Pattern 1: the active field group
Tab or click into either input — the whole group takes a ring and its help text appears.
<form class="form">
<fieldset class="group">
<legend class="group__legend">Card details</legend>
<label class="field"><span class="field__label">Number</span>
<input class="field__input" type="text" inputmode="numeric" placeholder="4242 4242 4242 4242"></label>
<label class="field"><span class="field__label">Expiry</span>
<input class="field__input" type="text" placeholder="MM / YY"></label>
<p class="group__help">We never store the full number — only the last four digits.</p>
</fieldset>
</form>
.group {
display: grid;
gap: 0.7rem;
margin: 0;
padding: 1rem;
border: 1px solid #cbd5e1;
border-radius: 10px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.field { display: grid; gap: 0.25rem; }
.field__input {
padding: 0.5rem 0.6rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
font: inherit;
}
/* The fieldset is never itself focusable, so :focus matching it is
impossible — the group reacts entirely to its descendants. */
.group:focus-within {
border-color: #2d5bff;
box-shadow: 0 0 0 3px rgb(45 91 255 / 0.25);
}
/* Reserve the help text's space at all times and animate only opacity,
so revealing it cannot reflow the form while the user is tabbing. */
.group__help {
margin: 0;
font-size: 0.8rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.group:focus-within .group__help { opacity: 1; }
/* The group ring is decorative context; the input keeps its own real
indicator so the focused field is still individually identifiable. */
.field__input:focus-visible {
outline: 2px solid #2d5bff;
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
.group, .group__help { transition-duration: 0.01ms; }
}
The rule that is easy to get wrong is the last one. A group ring is not a focus indicator — it tells you which region is active, not which control has focus, and a form with four fields in one group would leave a keyboard user unable to tell where they are. Keep the per-field indicator, and treat the group treatment as additional context. The contrast and thickness requirements for the real indicator are covered in Creating Accessible Focus Indicators.
Pattern 2: the floating label
The floating label needs two conditions joined by OR: float while the field is focused, and stay floated afterwards if the user typed something. :focus-within on the wrapper supplies the first; :placeholder-shown on the input supplies the second.
<label class="float">
<input class="float__input" type="email" placeholder=" ">
<span class="float__label">Email address</span>
</label>
.float {
position: relative;
display: block;
max-width: 20rem;
padding-top: 0.55rem; /* room for the label to travel into */
}
.float__input {
width: 100%;
padding: 0.8rem 0.7rem 0.45rem;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: 400 0.95rem/1.2 system-ui, sans-serif;
}
.float__label {
position: absolute;
left: 0.75rem;
top: 1.25rem;
font: 400 0.95rem/1 system-ui, sans-serif;
/* Clicks pass through to the input underneath. */
pointer-events: none;
transition: translate 0.16s ease, font-size 0.16s ease, color 0.16s ease;
}
/* Condition A: the wrapper contains focus.
Condition B: the placeholder is gone, i.e. the field has a value. */
.float:focus-within .float__label,
.float__input:not(:placeholder-shown) + .float__label {
translate: 0 -1.15rem;
font-size: 0.72rem;
}
.float:focus-within .float__label { color: #2d5bff; }
@media (prefers-reduced-motion: reduce) {
.float__label { transition-duration: 0.01ms; }
}
The placeholder=" " — a single space, not an empty attribute — is not decoration. :placeholder-shown matches only while a placeholder is actually being displayed, and an input with no placeholder attribute never matches it, so the second condition would never fire. A space is a placeholder that renders as nothing, which gives you the state without any visible text competing with the label.
The technique that makes it work
Both patterns rely on the same structural move: put the state on a wrapper, and let the wrapper's state drive its descendants and its own box. CSS still has no way to select an earlier sibling or a parent from the focused element, and :has() is only recently universal. :focus-within sidesteps the problem entirely — instead of asking "which element is focused, and how do I reach its label from there", you ask "does this container hold focus", and every descendant is then addressable with an ordinary descendant selector.
That inversion is why the floating label works with a <span> after the input in source order. You are not selecting backwards; you are selecting forwards from a wrapper that happens to know about the focus. The + combinator in the :placeholder-shown half is a plain next-sibling selection, and the :focus-within half does not need a combinator at all.
Variation: keyboard-only group highlighting
:focus-within fires for pointer focus too, so clicking into a field lights up its group — which some designers dislike for the same reason they disliked the old :focus ring, a debate covered in :focus-visible-within pseudo-class, but :has() composes to the same thing:
/* Baseline: every engine gets the group ring. */
.group:focus-within {
border-color: #2d5bff;
}
/* Where :has() is available, restrict the ring to keyboard-driven focus
by asking whether a descendant matches the focus-visible heuristic. */
@supports selector(:has(*)) {
.group:focus-within { border-color: #cbd5e1; }
.group:has(:focus-visible) { border-color: #2d5bff; }
}
The @supports selector() guard is what keeps this safe: without it, an engine lacking :has() would discard the second rule as invalid but still apply the first override, leaving the group with no highlight at all.
Browser support
:focus-within is old and universally available across every current engine, so no fallback is needed. :placeholder-shown is comparably broad and equally safe to use unguarded. :has() is the newest piece, at Chrome and Edge 105+, Safari 15.4+ and Firefox 121+, which is why the keyboard-only variation is wrapped in an @supports selector() block rather than used as the baseline. The standalone translate property used for the label needs Chrome and Edge 104+, Firefox 72+ or Safari 14.1+; transform: translate() substitutes exactly if you need older engines.
FAQ
What is the difference between :focus-within and :has(:focus)?
Both match an ancestor of a focused element, but :focus-within also matches when the element itself is focused, whereas :has(:focus) only matches when a descendant is focused. They also differ in weight: :focus-within counts as one pseudo-class, while :has() takes the specificity of its most specific argument.
Can :focus-within replace JavaScript focus and blur listeners?
For styling, almost always. A focusin and focusout listener toggling a class does the same job with more code and a chance of the class going stale. Keep script only where focus must change behaviour rather than appearance, such as opening a dialog.
Why does my layout jump when a field gains focus?
Because the rule inside :focus-within changes a property that affects layout, such as height, padding or display. Reserve the space up front and animate opacity or a transform instead, so tabbing through the form never reflows the page.
Is there a keyboard-only version of :focus-within?
There is no :focus-visible-within pseudo-class, but :has(:focus-visible) produces the same effect, matching a container only when a descendant shows the focus-visible heuristic. It needs :has() support, so keep :focus-within as the baseline.
Related
- Hover & Focus State Design — the parent guide on interactive state styling.
vs — the modality heuristic these patterns can borrow. - Pointer and Hover Media Queries — pairing focus reveals with hover reveals safely.
- Creating Accessible Focus Indicators — the per-field ring a group highlight must never replace.
- Responsive Forms With Container Queries — laying these field groups out against available width.
Related articles
More pages in the same section.