Benjamin Crozat "What if you cut code review time & bugs in half, instantly?" Start free for 14 days→

Style labels on focus the right way in CSS

7 minutes read

Style labels on focus the right way in CSS

TL;DR

I almost never use label:focus. Labels aren’t focusable by default and putting them in the tab order is bad for keyboard users. What I ship instead:

  1. Label wraps the inputlabel:focus-within { … } (labels react when descendants are focused). See :focus-within.
  2. Label comes before the inputlabel:has(+ input:focus-visible) { … } with :has() and :focus-visible.
  3. Input comes before the labelinput:focus-visible + label { … } using the adjacent sibling combinator.

I use :focus-visible instead of :focus to avoid noisy rings on mouse clicks. :has() is mainstream now (Chrome 105+, Safari 15.4+, Firefox 121+) per the MDN compatibility tables on :has().

Why label:focus rarely does what you think

Focus goes to interactive controls (inputs, buttons, links), not labels. You can make almost any element focusable, but adding tabindex to labels creates extra Tab stops and hurts flow. If I need the label to reflect focus, I style it based on the input’s state instead. If you need a refresher on proper association, see the <label> element.

Pattern 1: Label wraps the input (implicit label)

This is my go-to when I control the markup.

<label class="field">
  <span class="field__label">Email</span>
  <input type="email" required>
</label>
/* Highlight the label text when the input inside is focused */
.field:focus-within .field__label { 
  color: var(--accent); 
}

/* Give keyboard users a clear focus only when it matters */
.field input:focus-visible { 
  outline: 2px solid currentColor; 
  outline-offset: 2px; 
}

label:focus-within lights up when any descendant is focused. See :focus-within.

Pattern 2: Label comes before the input

<label class="field__label" for="search">Search</label>
<input id="search" class="field__input" type="search">
/* If the adjacent input is keyboard-focused, style the preceding label */
.field__label:has(+ .field__input:focus-visible) {
  color: var(--accent);
}

:has() can “look forward” to the next sibling via +. Check :has() and the compatibility table there for exact versions. If you can’t rely on :has(), wrap both nodes and use :focus-within on the wrapper.

Pattern 3: Input comes before the label

<input id="agree" class="checkbox__input" type="checkbox">
<label for="agree" class="checkbox__label">I agree</label>
/* Adjacent (or use ~ for non-adjacent siblings) */
.checkbox__input:focus-visible + .checkbox__label { 
  text-decoration: underline; 
}

This uses the adjacent sibling combinator. For non-adjacent siblings, there’s the general sibling combinator ~.

Floating labels I actually ship

<label class="float">
  <input required placeholder=" ">
  <span>Email</span>
</label>
.float { position: relative; display: grid; }
.float > input { padding: 1rem .75rem .25rem; }
.float > span {
  position: absolute; left: .75rem; top: .8rem; 
  transition: transform .15s ease, font-size .15s ease, opacity .15s;
}

/* On focus or when the field isn’t empty */
.float:focus-within > span,
.float > input:not(:placeholder-shown) + span {
  transform: translateY(-.6rem);
  font-size: .75rem;
  opacity: .75;
}

This relies on :focus-within and :placeholder-shown. No JavaScript.

My accessibility rules

  • I use :focus-visible, not just :focus, so keyboard users get a clear ring without spamming mouse users. This lines up with WCAG’s “Focus Visible”.
  • I don’t add tabindex="0" to labels. If I ever need to focus one programmatically for tooling, I use tabindex="-1" and keep the actual Tab order clean.
  • I always associate labels explicitly (for + id) or by wrapping the input. Both are valid and assistive-tech friendly per the <label> docs.

Browser support cheat sheet

If you need a feature-query fallback, branch on support with @supports:

/* Modern */
label:has(+ input:focus-visible) { color: var(--accent); }

/* Fallback idea */
@supports not (selector(:has(*))) {
  /* re-order DOM or wrap and use :focus-within */
}

Tailwind CSS versions of these patterns

Tailwind gives you variants for focus-visible, focus-within, peer, group, and arbitrary variants that let you target :has() or @supports. See the Tailwind docs on state variants, focus-visible, focus-within, group, peer, and arbitrary variants. You can also gate features with the supports-[…] variant.

1) Label wraps input → group + group-focus-within

<label class="group block">
  <span class="block group-focus-within:text-accent-600">Email</span>
  <input
    type="email"
    class="mt-1 block w-full
           focus-visible:outline focus-visible:outline-2 focus-visible:outline-current" />
</label>

The group marks the parent; group-focus-within:* styles children when anything inside is focused.

2) Label before input → :has() with an arbitrary variant

<label class="block has-[+input:focus-visible]:text-accent-600" for="search">Search</label>
<input id="search" class="block w-full focus-visible:outline focus-visible:outline-2" type="search">

That has-[+input:focus-visible] compiles down to &:has(+ input:focus-visible).

Optional safety net with a feature query

If you want to scope the :has() styling to supporting browsers using Tailwind:

<label
  class="block
         supports-[selector(:has(*))]:has-[+input:focus-visible]:text-accent-600">
  Search
</label>
<input id="search" class="block w-full focus-visible:outline focus-visible:outline-2" type="search">

The supports-[…] variant emits an @supports(...) wrapper.

3) Input before label → peer + peer-focus-visible

<input id="agree" type="checkbox" class="peer">
<label for="agree" class="peer-focus-visible:underline">I agree</label>

peer marks the input; the label reacts to its state as a subsequent sibling.

Floating label with Tailwind (zero JS)

<label class="relative grid group">
  <input
    required placeholder=" "
    class="pt-4 pb-1 px-3 block w-full
           focus-visible:outline focus-visible:outline-2 focus-visible:outline-current" />
  <span
    class="pointer-events-none absolute left-3 top-3
           transition-transform transition-opacity
           group-focus-within:-translate-y-2 group-focus-within:text-xs group-focus-within:opacity-75
           ">
    Email
  </span>
</label>

This mirrors the plain-CSS version using group-focus-within. If you prefer, you can also drive it with a has-[input:not(:placeholder-shown)] arbitrary variant on the wrapper.

Copy-paste recipes

Label wraps input

label:focus-within .label-text { color: var(--accent); }

Label before input

label:has(+ input:focus-visible) { color: var(--accent); }

Input before label

input:focus-visible + label { color: var(--accent); }

Highlight a whole row on focus

.form-row:focus-within { outline: 2px solid var(--accent); outline-offset: 3px; }

Tailwind equivalents: group-focus-within:*, has-[+input:focus-visible]:*, peer-focus-visible:*, and supports-[…] for feature queries. See state variants.

FAQ

Why doesn’t label:focus work for me?

Labels aren’t focusable by default and the browser sends focus to the associated control. Style the label based on the input’s focus using :focus-within, :has(), or sibling selectors.

Is :has() safe to ship now?

Yes for mainstream targets. Check the MDN table for versions on :has(). If you still support older browsers, add a :focus-within or sibling-selector fallback.

Should I ever add tabindex="0" to a label?

Almost never. It adds a useless stop in the tab order. Keep focus on the control and reflect that state on the label. See tabindex.


Did you like this article? Then, keep learning:

Help me reach more people by sharing this article on social media!

0 comments

Guest

Markdown is supported.

Hey, you need to sign in with your GitHub account to comment. Get started →

Great tools for developers

Search for posts and links

Try to type something…