EmpireUI
Get Pro
← Blog7 min read#component-states#ui-design#tailwind-css

Component State Design: Default, Hover, Active, Disabled, Error

Learn how to design every UI component state — default, hover, active, disabled, error — with real Tailwind v4 code, token strategies, and accessibility guidance.

Developer working on UI component states with code editor open on a dark screen

Why Component States Are the Most Underestimated Part of UI

Honestly, most developers think about component states as an afterthought — something you bolt on after the happy path is working. That's backwards. States are the component. The default visual is just one frame in a movie that plays every time a user touches your interface.

Think about a button. It exists in at least five distinct moments: resting on the page, being hovered over, pressed down, unavailable for interaction, and reporting a problem. Each moment needs its own visual contract. Skip one and your interface feels broken — even if the engineer can't immediately say why.

The good news is that if you're already building with a color system and a consistent spacing scale, wiring up states becomes systematic rather than creative. You're not inventing — you're applying.

The Five Core States Every Interactive Element Needs

Default is the resting state. It's what the user sees before they've done anything. This needs to communicate affordance — the element should look like it's ready to be used. That means sufficient contrast, a clear boundary or fill, and appropriate sizing. Don't make users guess whether something is clickable.

Hover signals availability. On pointer devices, the cursor entering the element's hit area triggers this. The change should be subtle — not a total redesign. A background shift of around 10-15% lightness, or a box-shadow appearing, is enough. In Tailwind v4.0.2 you'd reach for hover:bg-blue-600 on a bg-blue-500 base. The gap between default and hover should be perceptible but not jarring.

Active — sometimes called pressed — happens during the actual click or tap. It's the most fleeting state, lasting only milliseconds, but it closes the feedback loop. Users feel the interaction land. Drop the brightness by another step, or shift the element 1-2px downward with translate-y-px to simulate a physical press. It's a tiny thing that makes interactions feel real.

Disabled needs its own paragraph because developers get it wrong constantly. The element should not look interactive. It also shouldn't disappear. Reduce opacity to around 40-50% — opacity-40 in Tailwind — and swap the cursor to cursor-not-allowed. Do not rely on color alone to signal disabled status; pair it with reduced contrast and the cursor change. And if you're removing pointer events, add pointer-events-none explicitly.

Error state is where things went wrong and the UI needs to communicate that clearly without being alarming. A red border, an error-colored label, and an inline message below the field covers most cases. The component should still be fully interactive — the user needs to fix the problem, so don't disable it.

Focus State: The One State You Probably Broke

Here's the thing: focus state is technically distinct from hover and active, and it's the one that most component libraries quietly destroy with outline: none in their base reset. If you've done that and not replaced it with something visible, you've broken keyboard navigation for your entire app.

The WCAG 2.1 success criterion 2.4.7 requires a visible focus indicator for keyboard users. This isn't optional if you care about accessibility. Check the full picture in the WCAG accessibility guide — it covers focus requirements alongside color contrast ratios in detail.

In practice, a ring-2 ring-blue-500 ring-offset-2 in Tailwind gives you a clean focus ring that works on light and dark backgrounds. The ring-offset-2 is important — it creates a 2px gap between the ring and the element edge, which prevents the ring from blending into the element's own border or background. Use focus-visible: instead of focus: so the ring only appears for keyboard navigation, not mouse clicks.

Building State Styles with Tailwind v4 Variants

Tailwind's variant system maps almost directly onto component states, which makes it a natural fit for this kind of work. Here's a realistic button implementation that covers all five states plus focus, written for Tailwind v4.0.2:

type ButtonProps = {
  disabled?: boolean;
  hasError?: boolean;
  children: React.ReactNode;
  onClick?: () => void;
};

export function Button({ disabled, hasError, children, onClick }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={[
        // Base / Default
        "inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium",
        "transition-all duration-150 ease-out",
        // Default colour
        !hasError && "bg-blue-500 text-white",
        // Hover
        !hasError && "hover:bg-blue-600",
        // Active / Pressed
        !hasError && "active:bg-blue-700 active:translate-y-px",
        // Focus visible (keyboard)
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
        // Error state
        hasError && "bg-red-500 text-white hover:bg-red-600 active:bg-red-700",
        // Disabled
        disabled && "opacity-40 cursor-not-allowed pointer-events-none",
      ]
        .filter(Boolean)
        .join(" ")}
    >
      {children}
    </button>
  );
}

A few things worth noting in that code. The transition-all duration-150 keeps the state transitions fast — 150ms is the sweet spot for UI feedback, long enough to see, short enough to feel instant. Going above 200ms starts feeling sluggish. Also notice that pointer-events-none is paired with cursor-not-allowed — both matter because cursor-not-allowed alone won't prevent clicks in all browsers when used on a <button> element.

Form Input States: Default, Focused, Filled, Error, Disabled

Inputs have a slightly expanded state set compared to buttons. Beyond the core five, you also deal with a "filled" or "value-present" state (where the user has typed something) and sometimes a "success" state (validation passed). This starts to feel complex fast, but it doesn't have to be.

The foundation is a consistent border treatment. Default gets a neutral border — border-gray-300 on light, border-gray-600 on dark. Focus gets your brand ring. Error swaps the border to border-red-500 and adds ring-red-500/20 as a soft glow using rgba under the hood — that's approximately rgba(239,68,68,0.20). Disabled drops opacity and changes background to bg-gray-100 to visually lock the field.

Don't forget the label. An input in error state should have its label color shift to match the error color — text-red-600 — so the visual grouping is clear. And the error message itself should appear below the field, not in a tooltip. Tooltips are invisible to screen readers by default and require hover to reveal, which doesn't work on touch devices.

Using Design Tokens to Keep State Logic Consistent

If you're building more than a handful of components, hard-coding color values per-component gets painful to maintain. One designer updates the brand blue and you're editing twenty files. The fix is design tokens — semantic variables that map intent to value.

Your token structure for states might look like this in CSS custom properties:

:root {
  /* Interactive base */
  --color-interactive: theme(colors.blue.500);
  --color-interactive-hover: theme(colors.blue.600);
  --color-interactive-active: theme(colors.blue.700);
  --color-interactive-disabled: theme(colors.blue.500);
  --opacity-disabled: 0.4;

  /* Error */
  --color-error: theme(colors.red.500);
  --color-error-hover: theme(colors.red.600);
  --color-error-ring: rgba(239, 68, 68, 0.20);

  /* Focus ring */
  --ring-focus-color: theme(colors.blue.500);
  --ring-focus-offset: 2px;
  --ring-focus-width: 2px;
}

This pairs well with a spacing system that also uses tokens, giving you a single source of truth for the whole design system. When your tokens live in CSS custom properties, they automatically respect dark mode when you swap values at the :root.dark level — no component-level changes needed.

Testing States in Storybook and in Real Apps

How do you actually verify all five states exist and look correct? The answer is Storybook. Each component story should include a named story per state — Default, Hover, Active, Disabled, Error — and ideally a combined AllStates story that renders all variants in a grid. This makes visual regression testing tractable.

If you're not already using Storybook for your component library, the Storybook component library setup guide covers the setup from scratch including interaction testing with the @storybook/addon-interactions package. That addon lets you simulate hover and focus states programmatically, so your CI pipeline can screenshot every state on every component.

Beyond Storybook, manually test with keyboard-only navigation at least once per sprint. Tab through your interface. Does every interactive element show a focus ring? Can you activate buttons with Enter and Space? Can you move through dropdowns with arrow keys? These aren't edge cases — they're the minimum bar for a usable interface. And they break far more often than you'd expect after seemingly unrelated CSS changes.

Dark Mode and State Design: What Changes

Dark mode isn't just inverting colors. The relative lightness shifts used for hover and active states work differently on dark surfaces. On a light background, hover gets darker. On a dark background, hover typically gets lighter — approaching the user, rather than retreating.

So your hover state on a dark button might go from bg-blue-600 (default dark) to bg-blue-500 (hover, lighter). The active press goes lighter still or you reduce the brightness with a filter. The direction reverses. This is one reason why defining states through semantic tokens rather than raw color values pays off — you can flip the direction by swapping token values in your dark theme, without touching component logic.

Is this extra work? Yes, honestly. But getting it right is what separates a polished interface from one that just looks like someone turned the lights off. Your theme toggle implementation should test each of these states in both modes before shipping.

FAQ

Should disabled buttons use `disabled` HTML attribute or just visual styling?

Both. The disabled HTML attribute prevents form submission and browser-native interactions, and is read by screen readers. CSS classes alone (opacity-40, pointer-events-none) won't communicate the disabled state to assistive technology. Use the attribute for semantic correctness and the CSS classes for visual feedback.

What's the difference between `focus` and `focus-visible` in Tailwind?

The focus: variant applies styles whenever the element has focus — including from mouse clicks. focus-visible: only applies when the browser determines the focus should be visible, which typically means keyboard navigation. Use focus-visible: for focus rings so mouse users don't see the ring on click, while keyboard users still get clear navigation feedback.

How do I handle error state on a component that's also disabled?

Disabled takes priority. If a field is both in error and disabled, show the disabled visual — the user can't act on the error anyway. When the field becomes enabled again, surface the error state then. Stacking both visual states simultaneously creates confusion about what's actually happening.

What transition duration should I use for state changes?

100-150ms for most state changes (hover, active, focus). Anything faster than 100ms won't register consciously. Anything slower than 200ms starts feeling laggy for immediate interactions like button presses. Error and success state transitions can be slightly longer — 200-250ms — since they communicate more meaningful information.

Can I use the CSS `:has()` selector to style parent elements based on child input state?

Yes, and it's genuinely useful for form field components. .field:has(input:focus) lets you apply focus styles to the wrapper element rather than just the input, which is handy when your design has labels and borders on a container div rather than directly on the input. Browser support is solid as of 2024 across all modern browsers.

My component library has 40+ components. How do I keep state styles consistent without copy-pasting?

Extract a set of base class strings — a interactiveBase, focusRing, disabledState constant — that you import and spread into component class lists. Pair this with CSS custom properties for your state colors so the values live in one place. This is more maintainable than a shared utility function and easier to override per-component when needed.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Responsive Design Systems: Mobile-First Component VariantsSpacing Scale Design: T-shirt Sizes vs Fibonacci vs 8pt GridMonochrome UI Design: One-Color Systems That Look ExpensiveDark UI Design Patterns to Follow in 2027