# Focus and keyboard (/cloud/foundations/focus)



## The ring [#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:

```css
: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 [#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 [#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](/cloud/headless), reproduced from Base UI.

## Disabled [#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.
