Hover, Focus & Other States
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:
<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
| Variant | Applies 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.
<!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:
| Variant | Matches |
|---|---|
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 |
<!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-180applies whenaria-expanded="true". The same short form exists foraria-checked,aria-disabled,aria-selectedand other true/false ARIA attributes.aria-[current=page]:font-boldmatches a specific value. Note that the shortaria-current:only matchesaria-current="true", so use the bracket form for navigation links.data-active:bg-sky-100applies when the element has adata-activeattribute;data-[state=open]:blockmatches a value.
<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-400styles the placeholder text of an input.before:andafter:create generated content. Set the text withcontent-['...']:after:content-['*'] after:text-red-600adds a red star after a label.marker:text-sky-600colours list bullets and numbers.file:styles the button inside<input type="file">.selection:bg-amber-200colours text the user highlights.first-letter:andfirst-line:style the first letter or line of a paragraph.
<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:
<!-- 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-whitechanges only the background on hover. Each changing class needs its ownhover:. - Removing focus outlines with nothing in their place. If you add
outline-none, add afocus-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 valuetrue; usearia-[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 usefocus-within:to style a wrapper when its input is focused. first:,last:,odd:,even:andnot-last:style items by position, ideal for lists made in loops.aria-*anddata-*variants style elements from their state attributes.placeholder:,before:,after:,marker:,file:andselection: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
<!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?
A variant applies only to the class it is attached to; text-white has no variant, so it always applies.
2. Which variant shows a focus ring for keyboard users but usually not for mouse clicks?
focus-visible: matches when the browser decides a visible focus indicator is needed, typically keyboard navigation.
3. How do you style the link that has aria-current="page"?
The short aria-current: only matches the value true; the bracket form matches any value, such as page.
4. In Tailwind 4, how are stacked variants like *:first:pt-0 applied?
Version 4 applies stacked variants left to right; v3 applied them right to left.
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
