Log 05

Group-has-[.magic]

See on GitHub

The problem: styling distant elements based on state

Sometimes you need to style an element based on the state of another element that's far away in the DOM tree. Traditional CSS makes this difficult since selectors flow downward, not sideways or upward.

Consider this scenario: you have a card with a checkbox deep inside, and you want the card's border to change when the checkbox is checked.

// The checkbox is buried deep, but we want to style the outer card
<div className="card">
  <div className="card-header">
    <h3>Settings</h3>
  </div>
  <div className="card-content">
    <div className="form-group">
      <label>
        <input type="checkbox" /> Enable feature
      </label>
    </div>
  </div>
</div>

Traditional solutions:

  1. Lift state up and pass isChecked prop down (React overhead)
  2. Use JavaScript to toggle classes manually (defeats CSS-only approach)
  3. Restructure your HTML (often not possible)

Until "has" kicks in

Tailwind's has-[*] modifier lets you query for the existence of a state or class anywhere inside a group container, then style any element within that group based on it.

<div className="group rounded-lg border border-gray-200 has-[:checked]:border-blue-500">
  <div className="p-4">
    <h3>Settings</h3>
  </div>
  <div className="p-4">
    <label className="flex items-center gap-2">
      <input type="checkbox" className="peer" />
      <span>Enable feature</span>
    </label>
  </div>
</div>

But has-[:checked] only styles the container itself. What if you want to style a sibling element based on the checkbox state?

Styling distant siblings with "group-has"

This is where it gets powerful. Mark the parent as a group, then use group-has-* to style any descendant:

<div className="group">
  {/* This header changes when checkbox below is checked */}
  <div className="p-4 bg-gray-100 group-has-[:checked]:bg-blue-100">
    <h3 className="text-gray-900 group-has-[:checked]:text-blue-900">
      Settings
    </h3>
    <p className="text-gray-500 group-has-[:checked]:text-blue-600">
      Feature is disabled
    </p>
    <p className="hidden group-has-[:checked]:block text-blue-600">
      Feature is enabled!
    </p>
  </div>
 
  {/* The checkbox that controls everything above */}
  <div className="p-4 border-t">
    <label className="flex items-center gap-2 cursor-pointer">
      <input type="checkbox" />
      <span>Enable feature</span>
    </label>
  </div>
</div>

What's happening here:

  1. The outer div is marked as group
  2. The checkbox is a descendant of this group
  3. group-has-[:checked] on the header elements queries "does this group contain a checked element?"
  4. When the checkbox is checked, all group-has-[:checked]:* styles activate

Querying by class existence

You're not limited to pseudo-classes like :checked. You can query for any CSS selector, including classes:

<div className="group">
  {/* Style changes when .active class exists anywhere in the group */}
  <nav className="opacity-50 group-has-[.active]:opacity-100">
    <a href="#" className="active">Home</a>
    <a href="#">About</a>
  </nav>
 
  {/* Indicator appears when active link exists */}
  <div className="hidden group-has-[.active]:block">
    You're on an active page
  </div>
</div>

Real-world example: form validation feedback

Style a form header based on whether any input inside has an error:

<form className="group">
  {/* Header reacts to validation state anywhere in form */}
  <div className="p-4 border-b group-has-[.field-error]:border-red-300 group-has-[.field-error]:bg-red-50">
    <h2 className="group-has-[.field-error]:text-red-900">
      Contact Form
    </h2>
    <p className="text-sm text-gray-500 group-has-[.field-error]:text-red-600">
      Please fill out all required fields
    </p>
  </div>
 
  {/* Fields somewhere deep in the form */}
  <div className="p-4 space-y-4">
    <div>
      <input
        type="email"
        className="field-error border-red-500"
        placeholder="Email"
      />
      <span className="text-red-500 text-sm">Invalid email</span>
    </div>
    <div>
      <input type="text" placeholder="Name" />
    </div>
  </div>
</form>

Try the interactive demo below. Enter invalid values in the form fields to see how the header reacts to validation errors using group-has-[.field-error]:

Contact Form

Please fill out all required fields

Try typing invalid values in the fields above and see the header react!

'use client'

import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Mail, User, Phone, AlertCircle, CheckCircle2 } from 'lucide-react'

interface FieldState {
  value: string
  touched: boolean
}

function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

function validateName(name: string): boolean {
  return name.trim().length >= 2
}

function validatePhone(phone: string): boolean {
  return /^\+?[\d\s-]{10,}$/.test(phone)
}

function FormValidationGroupHasDemo() {
  const [email, setEmail] = useState<FieldState>({ value: '', touched: false })
  const [name, setName] = useState<FieldState>({ value: '', touched: false })
  const [phone, setPhone] = useState<FieldState>({ value: '', touched: false })

  const emailHasError = email.touched && !validateEmail(email.value)
  const nameHasError = name.touched && !validateName(name.value)
  const phoneHasError = phone.touched && !validatePhone(phone.value)

  const allValid =
    email.touched &&
    name.touched &&
    phone.touched &&
    validateEmail(email.value) &&
    validateName(name.value) &&
    validatePhone(phone.value)

  return (
    <div className="bg-muted/50 flex min-h-[500px] w-full items-center justify-center p-4 sm:p-8">
      {/* The form has "group" class - all group-has-* selectors query within this scope */}
      <form
        className="bg-card border-border group w-full max-w-md overflow-hidden rounded-xl border shadow-lg"
        onSubmit={(e) => e.preventDefault()}
      >
        {/* 
          Header section - reacts to validation state anywhere in the form
          group-has-[.field-error] queries: "does this group contain any element with .field-error class?"
        */}
        <div
          className={cn(
            'border-border border-b p-5 transition-colors duration-300',
            'group-has-[.field-error]:border-red-500/30 group-has-[.field-error]:bg-red-500/10',
            'group-has-[.field-success]:border-emerald-500/30 group-has-[.field-success]:bg-emerald-500/10'
          )}
        >
          <div className="flex items-center gap-3">
            {/* Icon changes based on validation state */}
            <div
              className={cn(
                'bg-muted flex size-10 items-center justify-center rounded-full transition-colors duration-300',
                'group-has-[.field-error]:bg-red-500/20',
                'group-has-[.field-success]:bg-emerald-500/20'
              )}
            >
              <AlertCircle
                className={cn(
                  'hidden size-5 text-red-500',
                  'group-has-[.field-error]:block'
                )}
              />
              <CheckCircle2
                className={cn(
                  'hidden size-5 text-emerald-500',
                  'group-has-[.field-success]:block'
                )}
              />
              <Mail
                className={cn(
                  'text-muted-foreground size-5',
                  'group-has-[.field-error]:hidden',
                  'group-has-[.field-success]:hidden'
                )}
              />
            </div>

            <div className="flex-1">
              <h2
                className={cn(
                  'text-foreground font-semibold transition-colors duration-300',
                  'group-has-[.field-error]:text-red-500',
                  'group-has-[.field-success]:text-emerald-500'
                )}
              >
                Contact Form
              </h2>
              {/* Description text changes based on state */}
              <p
                className={cn(
                  'text-muted-foreground text-sm transition-colors duration-300',
                  'group-has-[.field-error]:text-red-500/80',
                  'group-has-[.field-success]:text-emerald-500/80'
                )}
              >
                <span className="group-has-[.field-error]:hidden group-has-[.field-success]:hidden">
                  Please fill out all required fields
                </span>
                <span className="hidden group-has-[.field-error]:inline group-has-[.field-success]:hidden">
                  Please fix the errors below
                </span>
                <span className="hidden group-has-[.field-success]:inline">
                  All fields are valid!
                </span>
              </p>
            </div>
          </div>
        </div>

        {/* Form fields - each can have .field-error class when invalid */}
        <div className="space-y-4 p-5">
          {/* Email field */}
          <div className="space-y-1.5">
            <label className="text-foreground flex items-center gap-2 text-sm font-medium">
              <Mail className="size-4" />
              Email
            </label>
            <input
              type="email"
              placeholder="you@example.com"
              value={email.value}
              onChange={(e) =>
                setEmail({ value: e.target.value, touched: true })
              }
              onBlur={() => setEmail((prev) => ({ ...prev, touched: true }))}
              className={cn(
                'border-border bg-background text-foreground placeholder:text-muted-foreground w-full rounded-lg border px-3 py-2.5 text-sm outline-none transition-all duration-200',
                'focus:border-foreground focus:ring-foreground/10 focus:ring-2',
                // Add field-error class when invalid - this triggers group-has-[.field-error]
                emailHasError &&
                  'field-error border-red-500 bg-red-500/5 focus:border-red-500 focus:ring-red-500/20'
              )}
            />
            {emailHasError && (
              <p className="flex items-center gap-1 text-xs text-red-500">
                <AlertCircle className="size-3" />
                Please enter a valid email address
              </p>
            )}
          </div>

          {/* Name field */}
          <div className="space-y-1.5">
            <label className="text-foreground flex items-center gap-2 text-sm font-medium">
              <User className="size-4" />
              Full Name
            </label>
            <input
              type="text"
              placeholder="John Doe"
              value={name.value}
              onChange={(e) =>
                setName({ value: e.target.value, touched: true })
              }
              onBlur={() => setName((prev) => ({ ...prev, touched: true }))}
              className={cn(
                'border-border bg-background text-foreground placeholder:text-muted-foreground w-full rounded-lg border px-3 py-2.5 text-sm outline-none transition-all duration-200',
                'focus:border-foreground focus:ring-foreground/10 focus:ring-2',
                nameHasError &&
                  'field-error border-red-500 bg-red-500/5 focus:border-red-500 focus:ring-red-500/20'
              )}
            />
            {nameHasError && (
              <p className="flex items-center gap-1 text-xs text-red-500">
                <AlertCircle className="size-3" />
                Name must be at least 2 characters
              </p>
            )}
          </div>

          {/* Phone field */}
          <div className="space-y-1.5">
            <label className="text-foreground flex items-center gap-2 text-sm font-medium">
              <Phone className="size-4" />
              Phone Number
            </label>
            <input
              type="tel"
              placeholder="+1 234 567 8900"
              value={phone.value}
              onChange={(e) =>
                setPhone({ value: e.target.value, touched: true })
              }
              onBlur={() => setPhone((prev) => ({ ...prev, touched: true }))}
              className={cn(
                'border-border bg-background text-foreground placeholder:text-muted-foreground w-full rounded-lg border px-3 py-2.5 text-sm outline-none transition-all duration-200',
                'focus:border-foreground focus:ring-foreground/10 focus:ring-2',
                phoneHasError &&
                  'field-error border-red-500 bg-red-500/5 focus:border-red-500 focus:ring-red-500/20'
              )}
            />
            {phoneHasError && (
              <p className="flex items-center gap-1 text-xs text-red-500">
                <AlertCircle className="size-3" />
                Please enter a valid phone number
              </p>
            )}
          </div>

          {/* Hidden success marker - appears when all fields are valid */}
          {allValid && <div className="field-success hidden" />}

          {/* Submit button */}
          <button
            type="submit"
            disabled={!allValid}
            className={cn(
              'w-full rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200',
              'bg-primary text-primary-foreground hover:bg-primary/90',
              'disabled:cursor-not-allowed disabled:opacity-50'
            )}
          >
            Submit
          </button>
        </div>

        {/* Footer hint */}
        <div className="border-border bg-muted/50 border-t px-5 py-3">
          <p className="text-muted-foreground text-center text-xs">
            Try typing invalid values in the fields above and see the header
            react!
          </p>
        </div>
      </form>
    </div>
  )
}

export default FormValidationGroupHasDemo

Nested groups with "group/{name}"

When you have nested groups, use named groups to be specific about which ancestor you're querying:

<div className="group/card">
  <div className="group/header">
    {/* Queries the card group, not the header group */}
    <h3 className="group-has-[:checked]/card:text-blue-600">
      Card Title
    </h3>
  </div>
 
  <div className="group/content">
    <input type="checkbox" />
  </div>
</div>

The mental model

Think of group-has as asking a question:

"Does this group container have any descendant matching this selector?"

If yes, apply the styles. The power is that you can style any element inside the group based on any other element's state inside that same group.

When to use "group-has"

Use it when:

  • You need CSS-only state propagation
  • The controlling element and styled element are in different branches of the DOM
  • You want to avoid JavaScript state management for purely visual changes
  • Building components where internal state affects external appearance

Consider alternatives when:

  • State needs to be shared with JavaScript logic (use React state)
  • The relationship is simple parent-child (use regular has-* or peer-*)
  • You need to track complex state (context/state management is clearer)

Quick reference

PatternUse case
has-[:checked]Style self based on descendant state
group-has-[:checked]Style any group descendant based on other descendant's state
group-has-[.classname]Query by class existence
group-has-[:focus]React to focus anywhere in group
group/name + group-has-[...]/nameNamed groups for nested scenarios

Related Logs