Foundations
Focus
Real focus on real controls: where it lands, how it is shown, and where it goes when what held it opens, closes or goes.
MarkdownFocus is where the keyboard's keys go. Someone using a keyboard, a switch or a screen reader works through focus alone: they need to see where it is, to know where it will go, and never to find it gone. An interface that handles focus well puts it on real controls, shows it where it was brought by the keyboard, and moves it only where the person would expect.
Best practices
Give focus to real controls. What takes keys holds the browser's own focus, on the browser's own control wherever there is one: the active cell of a grid holds a text field, a checkbox or a button, and an item of a list is a real element with focus. Typing, an input method's composition, the clipboard, spell checking and assistive technology then work as the platform makes them work, rather than as a reconstruction of it.
Move focus itself through a composite, rather than pointing at an item. A list, a grid or a canvas is one stop in the Tab order. Within it the arrow keys move the browser's focus from item to item, and the item that last had it keeps the one Tab stop (a "roving" tab stop). The alternative — focus staying on the container while it names an active item for assistive technology — leaves the item without the focus its own control needs, and leaves the browser's own scrolling, focus styles and text input unaware of where the person is. This library uses it nowhere.
Enter where the person was. Tabbing into a composite lands on the item last focused, else on the first selected, else on the first. Tab again leaves it.
Show focus the keyboard brought. A ring is drawn around what has focus when the keyboard brought it there, and not after a press, where the pointer already says where it is. It is an outline two pixels wide in the focus colour, a little apart from the control's edge, or within it where the control sits flush against its neighbours or inside a region that clips.
Draw it so a forced palette keeps it. Where the system forces its own colours, a ring drawn as a shadow disappears; an outline is kept and takes the system's colour. The same holds for showing that a value is not allowed: a heavier edge, which does not rest on colour alone.
Move focus into what opens, and back when it closes. An overlay takes focus to its first control when it opens, or to itself when it has none. When it closes, focus returns to the control that opened it — unless the person has moved it elsewhere — or, with no such control, to what had it before.
When what has focus goes, give it to what is beside it. A removed item hands focus to the next, else the one before, else to the control that adds another; a removed last connection hands it to the nodes. Focus is never left on something that is no longer in the page.
Never lose focus to the page. Focus that falls to the document's body is lost: the next Tab starts again from the top, and a screen reader announces nothing useful. Whatever removes, closes or replaces what had focus says where focus goes instead. Something that is pressed outlives the press, since a browser may drop focus to the page if what was pressed is removed before the press ends.
Do not move focus to say something. What happened elsewhere is announced where the person is (see Announcements); focus stays on their work.
Do not take focus that was not asked for. An item focused by the application is brought into view; an item pressed by the pointer is not moved from under it by being focused.
Where each rule is verified
| Rule | Clauses |
|---|---|
| One Tab stop, entered where it was | Listbox: “Tab enters on the first selected option”, “returning to the list returns to the option last focused”; Data grid: “the grid is one stop in the Tab sequence: Tab enters at the active cell, and Tab again leaves” |
| Reading order through a plane | Canvas: “Tab into the canvas lands on one item: the first in reading order, wherever it is among the items” |
| Into an overlay and back | Overlays: “opening moves focus to the first thing inside that takes it”, “closing returns focus to the control that opened it”, “focus returns to the control when the content is removed in the act of closing” |
| With nothing to return to | Overlays: “with no control to return to, closing returns focus to what had it before” |
| What had focus goes | Listbox: “when the option that has focus goes away, focus stays in the list”; Canvas: “when the item that has focus is taken away, focus stays in the canvas” |
| Removing hands focus on | Object editor: “removing an item moves focus to the next item, else the one before, else the button that adds one”; Diagram: “when the last connection is removed, focus goes to the nodes” |
| The ring | this page's spec: “focus the keyboard brought is drawn”, “focus a press brought is not drawn”, “where the system forces its own colours, the ring is still drawn” |
Platform considerations
Keyboard on a Mac. Safari leaves buttons out of the Tab order unless the person has asked for every control; a composite that must be reachable is reachable through its own items, not only through buttons.
Touch. A tap does not show a focus ring. A field given focus on a phone brings up the on-screen keyboard, so focus is moved into a field on a phone only when the person asked to type — a day chosen from a date field's calendar on a phone does not put focus back in the field, which would raise the keyboard over the date just chosen.
Assistive technology. A screen reader reads what takes focus: its name, its role, its state and its place among its siblings. That is the best announcement there is, and is not repeated by a live region.
Behaviour
The ring
Focus the keyboard brought is drawn: an outline two pixels wide in the focus colour, a little apart from the edge
const { screen, element } = await control(focusRingClassName);
await tabTo(screen);
expect(document.activeElement).toBe(element);
const style = getComputedStyle(element);
expect([style.outlineStyle, style.outlineWidth, style.outlineOffset]).toEqual(["solid", "2px", "2px"]);
expect(style.outlineColor).toBe(colour("text-focus-ring"));Focus a press brought is not drawn: the press already says where it is
const { element } = await control(focusRingClassName);
await userEvent.click(element);
expect(document.activeElement).toBe(element);
expect(getComputedStyle(element).outlineStyle).toBe("none");Where the system forces its own colours, the ring is still drawn, in the system's colour
const { screen, element } = await control(focusRingClassName);
await commands.forceColors(true);
await tabTo(screen);
const style = getComputedStyle(element);
expect([style.outlineStyle, style.outlineWidth]).toEqual(["solid", "2px"]);
expect(style.outlineColor).not.toBe("rgba(0, 0, 0, 0)");For what sits flush against its neighbours, the ring is drawn within its edge
const { screen, element } = await control(focusRingInsetClassName);
await tabTo(screen);
expect(getComputedStyle(element).outlineOffset).toBe("-2px");For a field, the ring is drawn over its own border, which it thickens
const { screen, element } = await control(focusRingEdgeClassName);
await tabTo(screen);
expect(getComputedStyle(element).outlineOffset).toBe("-1px");What is not allowed
A control holding what is not allowed has a heavier edge in the danger colour, not a colour alone
const { element } = await control(invalidClassName, true);
const style = getComputedStyle(element);
expect([style.outlineStyle, style.outlineWidth]).toEqual(["solid", "2px"]);
expect(style.outlineColor).toBe(colour("text-danger"));React
Install
npx shadcn@latest add https://ui.publictheta.com/r/focus.jsonSource
src/lib/focus-class.ts
/**
* How a control shows that the keyboard is on it: an outline, which a system
* that forces its own colours still draws — a shadow it would discard. Shown
* only for focus that the keyboard brought.
*/
const ring =
"outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-focus-ring";
/** Around the control, a little apart from its edge. */
export const focusRingClassName = `${ring} focus-visible:outline-offset-2`;
/** Within the control's edge: for what sits flush against its neighbours, or inside a clipped area. */
export const focusRingInsetClassName = `${ring} focus-visible:-outline-offset-2`;
/** Over the control's own border: for a field, whose edge the outline thickens. */
export const focusRingEdgeClassName = `${ring} focus-visible:-outline-offset-1`;
/**
* How a control shows that what it holds is not allowed: a heavier edge in the
* danger colour — heavier, so that it does not rest on colour alone.
*/
export const invalidClassName =
"data-invalid:border-danger data-invalid:outline-2 data-invalid:-outline-offset-2 data-invalid:outline-danger data-invalid:outline-solid data-invalid:focus-visible:outline-danger";