Skip to main content
ASoc
Tutorial

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.

The ASoc Team7 min read

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 buttonRing appearsNo ring
Tab to a buttonRing appearsRing appears
Click into a text inputRing appearsRing appears (browsers treat text entry as always visible)
Programmatic .focus() after a keyboard actionRing appearsUsually appears
Best forForm fields, skip linksButtons, 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-none removes the browser's default outline, but only in the case where we replace it.
  • focus-visible:ring-2 focus-visible:ring-primary draws a 2px ring in the brand color (--color-primary: #465fff from @theme in src/app/globals.css).
  • focus-visible:ring-offset-2 leaves a gap between the control and the ring so it reads on both the primary and outline variants, 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:

  1. A touch screen has no hover, so the overlay never appeared.
  2. An invisible element 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:

SurfaceTokenRing contrast
White card#ffffff4.84:1
Dark containergray-900 #1018283.67:1
Elevated dark surfacegray-800 #1d29393.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

SymptomCauseFix
Ring shows on mouse clickYou used focus:Use focus-visible: for buttons and links
No indicator at all when tabbingfocus:outline-none with nothing replacing itPair focus-visible:outline-none with a focus-visible:ring-*
Ring never shows on a custom elementThe element is not focusable (div with onClick, no tabIndex)Use a real <button> or <a>; do not add tabIndex to fake it
Ring is clippedA parent has overflow-hidden and the ring extends outsideRemove the offset, use ring-inset, or move the ring to the inner element
Control invisible to TabHidden with invisible, hidden or display:noneCorrect for off-screen drawers; wrong for primary actions
Ring vanishes in Windows High Contrast modeoutline-none removes the outline that forced-colors would drawIn Tailwind v4, outline-hidden keeps a transparent outline that high contrast mode still paints
Ring appears after a mouse click on an inputExpected: browsers always show focus for text entryLeave 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.

Keep reading

Tutorial10 min read

Tailwind Font Weight: 9 Named Steps, 4 This Codebase Uses

193 font-weight utility calls across this codebase, spanning only 4 of Tailwind's 9 named steps — traced to two atoms that set the hierarchy once.

Read more
Tutorial8 min read

Is Tailwind Good or Bad? The Trade, Measured Against a Real Codebase

A real 19-class hero attribute, the 'keep every class' policy that keeps it that way, and the one compiled stylesheet — 81,866 bytes for 111 products — that pays for it.

Read more