Tailwind focus-visible: 37 Uses, 28 focus:, and the Hover Trap
Where focus-visible: beats focus: in a real codebase, the ring recipe behind every CTA, and the invisible-overlay bug no variant can fix.
Tailwind's focus-visible: variant applies a utility only when the browser decides a focus indicator is useful, which in practice means keyboard focus. Use it instead of focus: for rings and outlines on buttons and links, so mouse clicks stay clean while keyboard users keep a visible position. This codebase uses it 37 times across 7 components.
What focus-visible: changes
focus: compiles to the :focus pseudo-class, which matches any focused element however it got focus. focus-visible: compiles to :focus-visible, which the browser matches only when it judges that showing focus helps the user. For a button, that is a keyboard Tab. For a text input, it is every focus, mouse included, because people need to see where they are typing.
focus: | focus-visible: | |
|---|---|---|
| Mouse click on a button | Ring appears | No ring |
| Tab to a button | Ring appears | Ring appears |
| Click into a text input | Ring appears | Ring appears (browsers treat text entry as always visible) |
Programmatic .focus() after a keyboard action | Ring appears | Usually appears |
| Best for | Form fields, skip links | Buttons, links, cards, tabs, carousel controls |
The census
Counted across src/components and src/app (.tsx only):
focus-visible: 37 uses 7 files (all buttons, links, controls)
focus: 28 uses 11 files (form fields, the skip link)
outline-none 26 uses 17 files
focus-within: 0 uses
The split is deliberate. Every interactive control that is not a text field uses focus-visible:; every form field and the skip link uses focus:. The 7 files with focus-visible: are Button, EditionPicker, PreviewModal, ProductDownloadGroup, TemplateCard, TemplateGallery and UseCaseCard.
The shared recipe
One class string defines the ring for every CTA on the site:
// src/components/atoms/Button.tsx
const base =
"inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-base font-medium shadow-xs duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";
Three parts, each doing one job:
focus-visible:outline-noneremoves the browser's default outline, but only in the case where we replace it.focus-visible:ring-2 focus-visible:ring-primarydraws a 2px ring in the brand color (--color-primary: #465ffffrom@themeinsrc/app/globals.css).focus-visible:ring-offset-2leaves a gap between the control and the ring so it reads on both theprimaryandoutlinevariants, light or dark.
Notice what is missing: there is no bare focus:outline-none. Removing the outline on plain :focus is the classic mistake. It deletes the keyboard indicator and replaces it with nothing, and it is only safe when a focus-visible: style takes over.
Smaller controls repeat the same three-class idea without the offset. The carousel thumbnails in TemplateGallery carry focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none, because a 2px offset on an 80px thumbnail strip would crowd its neighbour.
Where focus: is still right
Two places in this repo use plain focus: on purpose.
The skip link in src/app/layout.tsx is sr-only until focused, then jumps into view:
<a
href="#main"
className="sr-only rounded-lg font-medium focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[10001] focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:text-white focus:shadow-lg"
>
Skip to main content
</a>
It is not a ring. It is the element's whole appearance, and it must appear on any focus. If it waited for :focus-visible and a browser heuristic disagreed, the link would be focused and invisible.
The other focus: uses are text inputs in the login, signup, contact and settings forms, where the field's focus state is always worth showing.
A defect this caught
Hover overlays hide a second trap. The preview overlay on TemplateCard fades in with group-hover/media:, and it was invisible until hovered. Two things followed:
- A touch screen has no hover, so the overlay never appeared.
- An
invisibleelement cannot receive focus, so a keyboard user could never reach it either.
focus-visible: cannot fix an element nobody can focus. The fix was structural: the overlay became a pointer-only shortcut (aria-hidden, out of the tab order), and each card now carries an always-visible Preview button that has its own ring:
// src/components/molecules/TemplateCard.tsx
<button
type="button"
onClick={openPreview}
className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-lg border border-stroke-tertiary bg-white px-3.5 text-sm font-medium text-text-color duration-200 hover:bg-gray-50 hover:text-title-color focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-white/80 dark:hover:bg-gray-700 dark:hover:text-white"
>
The same rule applies in reverse, and the mobile menu in src/components/organisms/Header.tsx hit it. The closed drawer was pointer-events-none, which stops the mouse but not the Tab key, so on a phone-width viewport a keyboard user tabbed through links parked off-screen. Adding invisible to the closed state removed them from the tab order. Focus styling and focus reachability are separate problems, and you need both.
Dark mode and the ring
The ring token is the same in both themes, so the useful check is the contrast of #465fff against each surface, computed with the WCAG relative-luminance formula:
| Surface | Token | Ring contrast |
|---|---|---|
| White card | #ffffff | 4.84:1 |
| Dark container | gray-900 #101828 | 3.67:1 |
| Elevated dark surface | gray-800 #1d2939 | 3.04:1 |
WCAG asks 3:1 for non-text UI such as a focus indicator, so all three pass, but the elevated dark surface only just. That is the one place to watch: if you put a control on a lighter dark surface, swap the ring with dark:focus-visible:ring-primary-300 rather than trusting the shared token.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Ring shows on mouse click | You used focus: | Use focus-visible: for buttons and links |
| No indicator at all when tabbing | focus:outline-none with nothing replacing it | Pair focus-visible:outline-none with a focus-visible:ring-* |
| Ring never shows on a custom element | The element is not focusable (div with onClick, no tabIndex) | Use a real <button> or <a>; do not add tabIndex to fake it |
| Ring is clipped | A parent has overflow-hidden and the ring extends outside | Remove the offset, use ring-inset, or move the ring to the inner element |
| Control invisible to Tab | Hidden with invisible, hidden or display:none | Correct for off-screen drawers; wrong for primary actions |
| Ring vanishes in Windows High Contrast mode | outline-none removes the outline that forced-colors would draw | In Tailwind v4, outline-hidden keeps a transparent outline that high contrast mode still paints |
| Ring appears after a mouse click on an input | Expected: browsers always show focus for text entry | Leave it; it helps the user |
Frequently asked questions
Should I replace every focus: with focus-visible:?
No. Form fields and skip links want the indicator on every focus. Replace focus: on buttons, links, tabs, cards and carousel controls, which are the elements where a mouse click should stay quiet.
Is focus-visible: supported in all browsers?
Current evergreen browsers support :focus-visible, so no polyfill is needed on a modern build. Older Tailwind v2 docs recommend one; that advice dates from when support was partial.
Why use ring instead of outline?
ring-* utilities draw a box-shadow that follows the element's border-radius and supports an offset color. This repo uses rings for every control so the focus shape matches the rounded corners. Outlines also follow border radius in current browsers; the choice here is consistency, not a hard rule.
How do I show focus on a whole card when a link inside it is focused?
Use focus-within: on the card. This repo has zero uses of it, because each card's link and button carry their own ring instead. Reach for focus-within: only when the container, not the control, should change.
Templates in this post
ASoc Surge (an AI startup landing page), ASoc Synth (an AI workspace SaaS site) and ASoc Tempo (a time tracking SaaS site) ship the Button recipe above, ring included.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the hover overlay that this fix replaced, see Tailwind group-hover; for trapping focus inside a modal once it is open, React focus trap.
