Focus indicator
With keyboard navigation, the focused element always has a 2px high-contrast ring. The control's own color stays the same.
How it works
A global rule in index.css draws an outline for :focus-visible. Browsers only apply it when the user moves with the keyboard, not on mouse clicks.
/* src/index.css — @layer base: applies to EVERY element that gets keyboard focus */
:focus-visible {
outline: 2px solid var(--colorStrokeFocus2); /* black in light, white in dark */
outline-offset: 1px;
border-radius: var(--borderRadiusMedium);
}The matching utility is outline-stroke-focus (token colorStrokeFocus2). Radio draws the ring around both the button and its label with has-[button:focus-visible], so the indicator matches the click area.
Why the control color does not change on focus
Fluent keeps focus as a separate channel: hover / pressed / selected change color, focus only adds a ring.
| State | Signal | Reason |
|---|---|---|
| Rest → Hover → Pressed | Background / border gets darker (X → XHover → XPressed) | Feedback for the pointer. |
| Selected / Checked | Brand (background, indicator bar, semibold text) | A lasting state of the data. |
| Focus (keyboard) | 2px black / white outline, 1px offset | The current position. It has to combine with every state above. |
| Disabled | Disabled colors, no opacity | Not interactive. |
- A tab that is both selected and focused still shows both: the brand bar at the bottom and the focus ring around it.
- Black / white always reaches ≥ 3:1 against every neutral background of the theme, whether the brand is Sakura, Web or your own. A brand color cannot guarantee that.
- A color change on focus is easy to mistake for hover or pressed, so users may think they already clicked.
Inputs & list items
Two intended exceptions. Both replace the outline with an equivalent indicator.
- Inputs (Input, Textarea, Select, Dropdown, Combobox, TagPicker, SpinButton, DatePicker): a 2px brand bar grows from the center along the bottom edge, driven by
:focus-within. It also shows on click, because users need to know which field receives their typing. The inner<input>hasoutline-nonesince the bar replaces the ring. - Items in composite widgets (Menu items, Dropdown / Combobox options, Command items): focus sits on the highlighted item, shown with the
background-1-hoverbackground throughdata-highlighted/data-selected.
Custom elements
Because the rule lives in the base layer, interactive elements you write get the focus ring for free. Your job is not to remove it by accident.
The list sits in an overflow-hidden frame: -outline-offset-2 pulls the ring inward so it is not clipped.
{/* A custom element gets the focus ring from the base style; it only needs tabIndex and the right role */}
<div
role="button"
tabIndex={0}
onClick={open}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
}}
className="flex items-center gap-2 rounded-medium p-3 hover:bg-subtle-hover"
>
…
</div>
{/* Inside an overflow-hidden frame (list, rounded card): pull the outline inward so it is not clipped */}
<a href="/files/42" className="block px-3 py-2 hover:bg-subtle-hover focus-visible:-outline-offset-2">…</a>
{/* To change the ring style, REPLACE it with an equivalent indicator; don't remove it */}
<button className="outline-none focus-visible:ring-2 focus-visible:ring-stroke-focus focus-visible:ring-offset-1">…</button>- Use focus-visible: for every keyboard-only style.
- Pull the outline inward (-outline-offset-2) when the element touches the edge of an overflow-hidden frame.
- If you replace the outline, use an equivalent indicator: ≥ 2px, contrast ≥ 3:1, around the element.
- Using outline-none / focus:outline-none without a replacement indicator.
- Using focus: for the ring. It also shows on mouse clicks.
- Changing only the background or brand color as the sole focus signal.
Focus management: dialogs, menus, popovers
Radix handles the hard parts: it traps focus in Dialog / Drawer, moves focus into the surface on open and returns it to the trigger on close.
Open with Enter, close with Esc; focus returns to the button.
function RenameDialog() {
const nameRef = React.useRef<HTMLInputElement>(null);
return (
<Dialog>
<DialogTrigger asChild>
<Button>Rename</Button>
</DialogTrigger>
<DialogSurface
// By default Radix focuses the first element (the X button). Move focus to the input instead:
onOpenAutoFocus={(e) => {
e.preventDefault();
nameRef.current?.select();
}}
>
<DialogTitle>Rename file</DialogTitle>
<DialogContent>
<Field label="New name">
<Input ref={nameRef} defaultValue="Q3 report" />
</Field>
</DialogContent>
<DialogActions>
<DialogClose asChild>
<Button>Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button appearance="primary">Save</Button>
</DialogClose>
</DialogActions>
</DialogSurface>
</Dialog>
);
}
{/* Closing with Esc, the X button or Cancel → focus returns to the "Rename" button */}| Situation | What to do |
|---|---|
| Opening a Dialog / Drawer | Focus goes to the first focusable element by default. Use onOpenAutoFocus to pick the main input. |
| Closing an overlay | Focus returns to the trigger. Use onCloseAutoFocus to send it somewhere else. |
| The trigger disappears (deleting the item whose menu was open) | Move focus to the next item or the list heading yourself. Don't let focus fall back to <body>. |
| Page navigation in an SPA | Move focus to the h1 heading (tabIndex={-1}) or the main region of the new page. |
| Loading more content | Keep focus where it is; announce the number of new items in a live region instead of moving focus. |