|Kit

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 controls

Width 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

v0.19.0
  • focusStyles({ inset: true }) draws the ring inside the element, for rows stacked flush in a scroller

    #326
v0.11.0
  • focusStyles takes ring (brand or palette) and source (focus-visible or data-focus-visible)

    #149
v0.9.2
  • focusStyles, a new root export that puts the kit's focus ring on your own elements

    #142

On this page