Focus
StableA 2px ring in the text colour, outside or inside the edge, plus the lit-metal buttons’ own ring. Keyboard only.
focus-ring and focus-ring-inset are Tailwind utilities that draw a 2px outline in fg on :focus-visible, so it shows for the keyboard and not for clicks, and follows the element’s corners. Lit-metal buttons draw theirs 3px out, clear of the silver rim, in --lux-focus. Text fields mark focus on their own edge instead.
The rings
import { useState, type ReactNode } from 'react'import { cx } from '@rchursin/components/cx'import { Button, Switch } from '@rchursin/product/ui'
/** * The three focus rings. "Draw the rings" paints each one without focus so you can compare them; * turn it off and press Tab to see the real thing (they appear for the keyboard, not for clicks). */export default function FocusRings() { const [draw, setDraw] = useState(true) // What :focus-visible draws, copied as plain classes for the comparison const ring = draw && 'outline-2 outline-offset-2 outline-fg' const inset = draw && 'outline-2 -outline-offset-2 outline-fg' const rim = draw && 'outline-2 outline-offset-3 outline-[var(--lux-focus)]' const rimInverse = draw && 'outline-2 outline-offset-3 outline-[var(--lux-focus-inverse)]' const current = draw && 'outline-2 outline-offset-2 outline-current'
return ( <div className="@container flex w-full flex-col gap-6"> <Switch label="Draw the rings" hint="Off: press Tab to see them for real." checked={draw} onChange={setDraw} className="max-w-xs" />Usage
import '@rchursin/product.css' // brand tokens, fonts, the finishing layer and the app tokens<Link to="/about" className="rounded-md focus-ring">About</Link><button type="button" className="w-full px-3 text-left focus-ring-inset">Inbox</button>Examples
Your own controls
A card link and a region that scrolls on its own. Press Tab to reach them; the region then scrolls with the arrow keys.
import { Icon } from '@rchursin/product/icons/Icon'import { Link } from '@rchursin/product/lib/router'
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']const week = [ { kind: 'Focus', minutes: [150, 125, 175, 100, 150, 25, 0] }, { kind: 'Breaks', minutes: [30, 25, 35, 20, 30, 5, 0] },]
/** * Your own controls: add focus-ring next to the corner radius and the ring follows the shape. * A region that scrolls on its own is focusable too, so keyboard users can scroll it; it uses * the inset ring because its edges are clipped. */export default function FocusCustom() { return ( <div className="@container w-full max-w-3xl"> <div className="grid gap-5 @2xl:grid-cols-2"> <Link to="/library/foundations/color"API
CSS
| Class or variable | What it does |
|---|---|
focus-ring | On :focus-visible: outline 2px solid var(--rc-fg), offset 2px. Follows border-radius. |
focus-ring-inset | The same ring at offset −2px, for rows and cells that sit edge to edge or inside overflow: hidden. |
--lux-focus | The lit-metal button ring (lux-btn): 2px, offset 3px. #0a0a0a light, #fafafa dark. |
--lux-focus-inverse | The ring for lux-btn-inverse, which sits on inverse panels. lux-btn-outline uses currentColor. |
lux-input focus | Fields draw focus with box-shadow: a 1px edge at 70% black (white in dark mode) and a soft 4px halo, in the field’s own shape. In forced-colours mode they get a 2px system-colour outline instead. |
lux-range focus | The slider thumb gets a 2px ring in the text colour outside a 2px gap in the surface colour; forced-colours mode outlines the whole slider instead. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Tab | Moves to the next control and shows its ring. |
| ShiftTab | Moves to the previous control. |
- The ring is solid fg: 19.8:1 against the light page and 17.8:1 against the dark one, well past the 3:1 WCAG asks of focus indicators.
- :focus-visible means a mouse click does not leave a ring behind, but keyboard focus always shows one. Fields show focus for every input method, as browsers do.
- Outlines survive forced-colours mode (box-shadow rings do not), which is why the rings are outlines. Fields and the slider, which draw focus with box-shadow, switch to a system-colour outline there.
- On an inverse panel, follow the panel: lux-btn-inverse flips on its own; for your own controls add focus-visible:outline-current, or put .dark on the panel.
Guidelines
Do
- Put focus-ring on every custom interactive element, next to its rounded-* class.
- Use focus-ring-inset when a parent clips overflow or rows touch each other.
- Make scrolling regions focusable (tabIndex 0, a role and a label) so keyboard users can scroll them.
Don’t
- Don’t remove outlines (outline-none) without drawing a replacement.
- Don’t use :focus for the ring; it would also show after every click.
- Don’t let a sticky header cover the focused element; give targets scroll-margin.
In use
Related
- ButtonThe lit-metal button in five finishes and five sizes, with icons, a loading state and shortcut hints.
- Text inputA one-line field in the inset lux-input finish, in two sizes, with an optional icon inside and a slot on the right.
- Dark modeOne class flips every token: on <html> for the whole page, or on any wrapper for one part of it.
- Skip linkA “Skip to content” link that stays hidden until the first Tab, then jumps past the header to #main.
Source
The component’s own files, exactly as they run on this site.
/* RChursin_ product kit: app-level tokens and details on top of brand.css, effects.css and luxe.css. Import through src/product.css (which also pulls in the brand layer).
Semantic colours become Tailwind utilities: text-fg, text-fg-2, text-fg-3, bg-surface, bg-raised, bg-sunken, border-line, ring-line-strong, bg-hover, bg-selected … They switch with the .dark class, so components never need dark: variants for these.*/
@theme inline { --color-fg: var(--rc-fg); --color-fg-2: var(--rc-fg-2); --color-fg-3: var(--rc-fg-3); --color-surface: var(--rc-surface); --color-raised: var(--rc-raised); --color-sunken: var(--rc-sunken); --color-line: var(--rc-line); --color-line-strong: var(--rc-line-strong); --color-hover: var(--rc-hover); --color-selected: var(--rc-selected);