Hover, Focus & Other States

Tailwind CSS · Lesson 15 of 27 7 min read Updated

Interfaces react: a button darkens when you hover over it, a field glows when you click into it, a disabled button looks faded. In CSS you write these with pseudo-classes like :hover. In Tailwind you put a variant in front of a class, and that class only applies in that state: hover:bg-sky-700.

In this lesson you will learn the most useful state variants, how to style children by position, how to react to ARIA and data attributes, how to style pseudo-elements such as placeholders and list markers, and how to stack several variants on one class.

The pattern: variant, colon, class

Every state works the same way. Write the normal style, then add the changed style with a prefix:

HTML
<button class="bg-sky-600 text-white hover:bg-sky-700 active:bg-sky-800">Pay ₹1,299</button>

The button is bg-sky-600 normally, bg-sky-700 while the pointer is over it, and bg-sky-800 while it is being pressed. The variant only affects the one class it is attached to, so you repeat it for each property that changes: hover:bg-sky-700 hover:shadow-lg.

Interaction states

VariantApplies when
hover:The pointer is over the element (only on devices that can hover)
focus:The element has focus, from a click, a tap or the keyboard
focus-visible:The element has focus and the browser thinks a focus ring should show, typically keyboard use
focus-within:The element or anything inside it has focus
active:The element is being pressed
visited:A link has been visited
disabled:, enabled:A form control is disabled or enabled

For focus rings, prefer focus-visible:. A mouse user clicking a button does not need a big ring, but a keyboard user pressing Tab absolutely does, and focus-visible: gives each of them the right result.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Button states</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="flex flex-col items-start gap-4 p-8">
  <button class="rounded-lg bg-sky-600 px-5 py-2 font-semibold text-white transition hover:bg-sky-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 active:scale-95">
    Book ticket
  </button>
  <button disabled class="rounded-lg bg-sky-600 px-5 py-2 font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50">
    Sold out
  </button>
  <div class="flex w-72 items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 focus-within:border-sky-600 focus-within:ring-2 focus-within:ring-sky-200">
    <svg viewBox="0 0 24 24" class="size-5 shrink-0 fill-none stroke-slate-500 stroke-2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
    <input type="search" placeholder="Search trains" aria-label="Search trains" class="w-full outline-none">
  </div>
</body>
</html>

Hover the first button, press it to see it shrink slightly, and press Tab to see the outline. Click into the search field: the whole box lights up because of focus-within: on the wrapper. (The search icon is decorative, so it is hidden from screen readers with aria-hidden.)

Styling by position: first, last, odd, even

These variants check where an element sits among its siblings, so you can style lists and tables from one template:

VariantMatches
first:, last:The first or last child
odd:, even:Odd or even children (1st, 3rd... or 2nd, 4th...)
only:An element that is the only child
nth-3:The third child (any number works)
not-last:Every child except the last
empty:An element with no content
HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Striped results</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="p-6">
  <ul class="max-w-sm overflow-hidden rounded-xl border border-slate-200">
    <li class="flex justify-between p-3 odd:bg-white even:bg-slate-50 first:font-bold first:text-amber-700 not-last:border-b not-last:border-slate-200">
      <span>Meera</span><span>98</span>
    </li>
    <li class="flex justify-between p-3 odd:bg-white even:bg-slate-50 first:font-bold first:text-amber-700 not-last:border-b not-last:border-slate-200">
      <span>Kabir</span><span>94</span>
    </li>
    <li class="flex justify-between p-3 odd:bg-white even:bg-slate-50 first:font-bold first:text-amber-700 not-last:border-b not-last:border-slate-200">
      <span>Priya</span><span>91</span>
    </li>
    <li class="flex justify-between p-3 odd:bg-white even:bg-slate-50 first:font-bold first:text-amber-700 not-last:border-b not-last:border-slate-200">
      <span>Rohan</span><span>89</span>
    </li>
  </ul>
</body>
</html>

Every row has identical classes, which is exactly what you get when rows come from a loop in PHP, React or any template. The browser works out which rows are odd, even, first or last.

ARIA and data attributes

Many components already store their state in attributes: a menu button has aria-expanded="true", the current page link has aria-current="page", and JavaScript libraries often set data-state="open". Tailwind can style based on these directly:

  • aria-expanded:rotate-180 applies when aria-expanded="true". The same short form exists for aria-checked, aria-disabled, aria-selected and other true/false ARIA attributes.
  • aria-[current=page]:font-bold matches a specific value. Note that the short aria-current: only matches aria-current="true", so use the bracket form for navigation links.
  • data-active:bg-sky-100 applies when the element has a data-active attribute; data-[state=open]:block matches a value.
HTML
<nav class="flex gap-4">
  <a href="/" class="text-slate-600 aria-[current=page]:font-bold aria-[current=page]:text-sky-700" aria-current="page">Home</a>
  <a href="/courses" class="text-slate-600 aria-[current=page]:font-bold aria-[current=page]:text-sky-700">Courses</a>
</nav>

This is a great habit: the attribute does two jobs, telling screen readers the state and driving the style, so the two can never disagree.

Pseudo-elements

Some variants style parts of an element rather than a state:

  • placeholder:text-slate-400 styles the placeholder text of an input.
  • before: and after: create generated content. Set the text with content-['...']: after:content-['*'] after:text-red-600 adds a red star after a label.
  • marker:text-sky-600 colours list bullets and numbers.
  • file: styles the button inside <input type="file">.
  • selection:bg-amber-200 colours text the user highlights.
  • first-letter: and first-line: style the first letter or line of a paragraph.
HTML
<label for="mobile" class="font-medium after:ms-0.5 after:text-red-600 after:content-['*']">Mobile number</label>
<ul class="list-disc ps-5 marker:text-sky-600">
  <li>Aadhaar card</li>
  <li>Two passport photos</li>
</ul>

Stacking variants

You can chain variants: md:hover:underline underlines on hover, but only on medium screens and up. dark:hover:bg-slate-700 applies in dark mode while hovering. In Tailwind 4, stacked variants apply from left to right, like reading a sentence. This mostly matters with variants that select other elements, such as *:, which targets all direct children:

HTML
<!-- Every direct child gets padding; the first child gets none at the top -->
<div class="*:py-2 *:first:pt-0">...</div>

*:first:pt-0 reads "for each child, if it is the first one, remove top padding". In v3 the same thing was written first:*:pt-0, because v3 applied stacked variants right to left.

Common mistakes

  • Expecting one variant to cover several classes. hover:bg-sky-700 text-white changes only the background on hover. Each changing class needs its own hover:.
  • Removing focus outlines with nothing in their place. If you add outline-none, add a focus-visible: ring or outline too.
  • Hover-only content. Phones cannot hover, and v4 does not apply hover: on touch devices.
  • aria-current: for page links. It only matches the value true; use aria-[current=page]:.

Key points

  • Put a variant before a class to apply it only in that state: hover:, focus-visible:, active:, disabled:.
  • Prefer focus-visible: for focus rings, and use focus-within: to style a wrapper when its input is focused.
  • first:, last:, odd:, even: and not-last: style items by position, ideal for lists made in loops.
  • aria-* and data-* variants style elements from their state attributes.
  • placeholder:, before:, after:, marker:, file: and selection: style pseudo-elements.
  • Variants stack and, in v4, apply from left to right: md:hover:underline, *:first:pt-0.

Exercise

Create a link list for a Jaipur school's website with four links. The current page link ("Admissions") should be marked with aria-current="page" and appear bold and sky-coloured. All links should underline on hover, show a clear outline when focused with the keyboard, and every link except the last should have a bottom border.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>School links</title>
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.3"></script>
</head>
<body class="p-6">
  <nav class="flex max-w-xs flex-col rounded-xl border border-slate-200 bg-white">
    <a href="#" class="px-4 py-3 not-last:border-b not-last:border-slate-200 hover:underline focus-visible:outline-2 focus-visible:outline-sky-600 aria-[current=page]:font-bold aria-[current=page]:text-sky-700">Home</a>
    <a href="#" aria-current="page" class="px-4 py-3 not-last:border-b not-last:border-slate-200 hover:underline focus-visible:outline-2 focus-visible:outline-sky-600 aria-[current=page]:font-bold aria-[current=page]:text-sky-700">Admissions</a>
    <a href="#" class="px-4 py-3 not-last:border-b not-last:border-slate-200 hover:underline focus-visible:outline-2 focus-visible:outline-sky-600 aria-[current=page]:font-bold aria-[current=page]:text-sky-700">Fees</a>
    <a href="#" class="px-4 py-3 not-last:border-b not-last:border-slate-200 hover:underline focus-visible:outline-2 focus-visible:outline-sky-600 aria-[current=page]:font-bold aria-[current=page]:text-sky-700">Contact</a>
  </nav>
</body>
</html>

All four links share the same classes; the aria-current="page" attribute alone switches on the bold sky style, and not-last: skips the final border.

Quick Quiz

1. What does hover:bg-amber-500 text-white do on hover?

2. Which variant shows a focus ring for keyboard users but usually not for mouse clicks?

3. How do you style the link that has aria-current="page"?

4. In Tailwind 4, how are stacked variants like *:first:pt-0 applied?

Learn Tailwind CSS with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call