Focus and keyboard
The focus ring, the pointer/keyboard modality switch, and the keyboard model every primitive inherits from Base UI
The ring
Every focusable primitive draws the same ring: --nyte-control-focus-width solid
--nyte-color-focus-ring, offset by --nyte-control-focus-offset, on :focus-visible only.
@nyte-ai/ui/styles.css restates the rule globally so ad-hoc focusables in an app land on the
same ring:
:focus-visible {
outline: var(--nyte-control-focus-width) solid var(--nyte-color-focus-ring);
outline-offset: var(--nyte-control-focus-offset);
}Text fields (Input, Textarea) use a 2px box-shadow in --nyte-color-focus-ring plus a
--nyte-color-ring border instead of an outline, so the ring hugs the field's radius.
Why the ring has its own color token
Chromium matches :focus-visible on every text field focus, pointer included. A plain
:focus-visible rule therefore snaps a ring around any input the user merely clicked into. No
selector can tell the two apart, so the color is the gate:
--nyte-color-focus-ringdefaults tovar(--nyte-color-ring).- The host marks the document with
data-nyte-focus-modality="pointer"when the last focus movement came from a pointer, andkeyboardwhen it came from a key. @nyte-ai/ui/styles.csssets--nyte-color-focus-ring: transparentwhile the attribute readspointer.
The desktop's implementation is desktop/src/renderer/src/theme/focus-modality.ts. Its rules,
which a host must preserve:
- Launch quiet. The attribute starts as
pointer, so a window that autofocuses its composer does not open ringed. The first Tab earns the ring. pointerdown(capture phase) setspointer.keydown(capture phase) setskeyboardonly when the key moved focus:- inside a text field (
inputwithout a non-texttype,textarea,contenteditable) only Tab qualifies; letters and Cmd+A / Cmd+V are edits; - elsewhere any non-modifier key qualifies (arrows walk a menu, Enter activates, Escape backs out).
- inside a text field (
Capture phase matters: the modality is settled before any handler moves focus, so the very first focused element already has the right ring state.
A web host that does not run this script gets a ring on click as well as on Tab. That is acceptable; hiding the ring on keyboard focus is not.
Keyboard model
Base UI owns keyboard behavior, and the primitives do not intercept it. The guarantees that a wrapper must not break:
| Component | Keys |
|---|---|
| Button | Enter and Space activate. A render-replaced non-button needs nativeButton={false} to keep this. |
| Dialog, Alert dialog | Focus moves into the popup on open and returns to the trigger on close. Tab is trapped inside. Escape closes a Dialog; Alert dialog ignores outside press and Escape unless disablePointerDismissal/… say otherwise (see the headless page). |
| Dropdown menu | Arrow Down/Up move highlight, Home/End jump, type-ahead matches item text, Enter/Space activate, Escape closes, Arrow Right opens a submenu and Arrow Left closes it. |
| Input | Native. |
The full key tables are on the headless pages, reproduced from Base UI.
Disabled
A disabled control drops to --nyte-control-disabled-opacity (0.56) and, for Button, sets
pointer-events: none. When a button enters a loading state after activation, pass
focusableWhenDisabled so focus stays on it; otherwise focus falls to <body> and a screen reader
loses its place.