Focus
One focus ring for the whole kit, and the utility that lets your own elements wear it.
Every focusable thing in the kit draws the same keyboard-focus ring. focusStyles is that ring,
exported so your own elements can wear it without copying class strings out of the kit's compiled
CSS.
import { cn, focusStyles } from '@42-central/ui-react';
<button type="button" className={cn(focusStyles(), 'rounded-full')}>
Custom trigger
</button>Tab to the examples below to see it — the ring is :focus-visible, so it appears for keyboard
focus and stays out of the way on a mouse click.
Code
<Button>Kit component</Button>
<button type="button" className={cn(focusStyles(), 'rounded-md px-3 py-1.5')}>
Your own element
</button>The ring doesn't track color
Unlike every other visual axis in the kit, focus is deliberately not part of the
color system. It's a fixed brand-500 ring, so a green badge and a red one
focus identically:
Code
<Badge asChild color="green"><button type="button">Validated</button></Badge>
<Badge asChild color="red"><button type="button">Failed</button></Badge>Focus is an accessibility affordance, not decoration — keeping it one recognisable shape means a keyboard user never has to re-learn what "focused" looks like as they move across a page.
The exception is a control that sits inside an already-accented surface — Pill's remove ×,
Slider's thumb, Rating's symbols, Schedule's chips. There a brand ring reads as a second,
unrelated colour stacked on the one the surface already carries, so those pass
focusStyles({ ring: 'palette' }) and ring in the active palette's --c-text:
<button type="button" className={cn(focusStyles({ ring: 'palette', offset: false }))}>…</button>--c-text rather than --c-solid, and at full opacity. It's the one slot that flips with the
theme — 700 in light, 300 in dark — so the ring can be dark on a pale surface and pale on a dark one.
--c-solid is 500 in both, which leaves it a mid-tone with nowhere to go against a light
background; measured on a Schedule chip it rings at 2.69:1 against the grid, where
SC 1.4.11 asks for 3:1. Lowering the
opacity can't rescue that — alpha only composites the ring toward whatever sits behind it.
Display components
Badge, Kbd and ThemeIcon are display elements — a <span>, a <kbd>, a <div>. They carry
the ring classes anyway, which costs nothing: :focus-visible never matches a non-focusable
element. The moment you compose one onto a real control with asChild, the ring is already there.
Code
<Kbd asChild>
<button type="button" onClick={openPalette}>⌘K</button>
</Kbd>This also means you don't have to tell the component it's interactive — there's no interactive
prop to forget, and nothing that can fall out of sync with whether the element is actually
focusable. If you reach for tabIndex instead of asChild, that works too.
Turning off the offset
By default the ring sits 2px clear of the element, over a background-colored gap, so it stays
legible against the element's own fill. For something flush inside a container — a cell in a grid,
a row in a scrolling list — that gap punches a hole in the surrounding surface. Pass
offset: false:
<div className={cn(focusStyles({ offset: false }), 'border-b')} tabIndex={0}>
Flush row
</div>The rule behind that: offset: true means the gap is painted in the page background (white,
or gray-dark-950 in dark). An element sitting on any other surface — a tinted table header, a
coloured chip — needs a gap that matches that surface, so it turns the offset off and supplies
its own pair. DataTable's sortable header is the worked example: its thead is bg-brand-50/90,
so a page-coloured gap would read as a white band cut out of the header.
cn(
focusStyles({ offset: false }),
'focus-visible:ring-offset-2 focus-visible:ring-offset-brand-50 dark:focus-visible:ring-offset-brand-950',
)Rows stacked in a scroller
Rows that sit flush against each other inside a scrolling list — TreeSelect's tree — can't take
an outer ring at all: the scroller clips it at the edges, and the next row paints over its bottom
edge. inset: true draws the ring inside the element's own edge instead. Pair it with
offset: false:
<div className={cn(focusStyles({ offset: false, inset: true }), 'rounded-sm')} tabIndex={-1}>
Tree row
</div>Controls whose focus lives elsewhere
Some controls put the real focus on a visually-hidden <input> and mirror the state onto the parts
you can actually see — Checkbox, RadioGroup, Switch, ChoiceCardGroup. :focus-visible can
never match those parts, because they are never focused. Zag sets a data-focus-visible attribute
on them instead, and source keys the ring off that:
focusStyles({ source: 'data-focus-visible' });This source emits no outline-none: the part it lands on isn't focusable, so there's no native
outline to suppress.
Most consumers never need this — reach for it only when you're styling a part of an Ark/Zag
primitive yourself. Zag only sets the attribute for keyboard focus, so the ring stays out of the
way on a mouse click, exactly like :focus-visible.
API
import { focusStyles, type FocusStylesOptions } from '@42-central/ui-react';
focusStyles(); // brand ring + offset (default)
focusStyles({ offset: false }); // brand ring only
focusStyles({ ring: 'palette' }); // --c-text, for a nested control
focusStyles({ offset: false, inset: true }); // inside the edge, for flush rows
focusStyles({ source: 'data-focus-visible' }); // Zag-driven controlsWidth and alpha are deliberately not options. Every ring the kit draws is 2px and fully opaque — that consistency is the whole point of having one utility.
Prop
Type
focusStyles lives on the package root (@42-central/ui-react), alongside cn and
COLORS. It returns a plain class string, so it composes with cn like any other.
Your Tailwind build needs to see the kit's classes for these to be generated. The
@source "../node_modules/@42-central/ui-react/dist/**/*.js" line from the
install guide already covers it.
Changelog
- #326
focusStyles({ inset: true })draws the ring inside the element, for rows stacked flush in a scroller
- #149
focusStylestakesring(brandorpalette) andsource(focus-visibleordata-focus-visible)
- #142
focusStyles, a new root export that puts the kit's focus ring on your own elements