Design system
Docs
Foundations

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-ring defaults to var(--nyte-color-ring).
  • The host marks the document with data-nyte-focus-modality="pointer" when the last focus movement came from a pointer, and keyboard when it came from a key.
  • @nyte-ai/ui/styles.css sets --nyte-color-focus-ring: transparent while the attribute reads pointer.

The desktop's implementation is desktop/src/renderer/src/theme/focus-modality.ts. Its rules, which a host must preserve:

  1. Launch quiet. The attribute starts as pointer, so a window that autofocuses its composer does not open ringed. The first Tab earns the ring.
  2. pointerdown (capture phase) sets pointer.
  3. keydown (capture phase) sets keyboard only when the key moved focus:
    • inside a text field (input without a non-text type, 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).

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:

ComponentKeys
ButtonEnter and Space activate. A render-replaced non-button needs nativeButton={false} to keep this.
Dialog, Alert dialogFocus 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 menuArrow 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.
InputNative.

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.