# Keyboard, focus and touch

> Make every control reachable by keyboard and thumb, show focus without showing it to mouse users, and give custom widgets the key maps people expect.

Module: Components and states · Level: intermediate · 9 min · https://school.agents.org.in/design/components/keyboard-and-touch

Focus is already on one of these buttons. Can you tell which? Click into the toolbar and press Tab a few times in both modes. (On a phone, use Step.)

*[Interactive demo: With outline: none, focus is still moving. You just can't see where.]*

`outline: none` is one of the most common lines an agent writes, because the default focus outline looks a little clumsy after a mouse click. The fix is not to delete it. It is to show it only to the people who need it.

## Focus you can see, only when it helps

`:focus-visible` (A CSS selector that matches when the browser decides focus should be shown, after Tab but not after a mouse click. Use it to show a focus ring only to keyboard users) is a CSS selector that matches when the browser decides focus should be shown: after Tab, not after a mouse click. So:

```css
button:focus { outline: none; }               /* no ring on click */
button:focus-visible {
  outline: 2px solid currentColor;            /* ring for the keyboard */
  outline-offset: 2px;
}
```

Even simpler: keep the browser's own ring and add only `outline-offset: 2px`. It already adapts to the platform and to high-contrast modes. A custom ring is fine if it clears 3:1 against every color it sits on, and the focused element is never hidden under a sticky header.

## The floor below taste

Accessibility is below polish and below brand. A screen that fails it is broken, whatever it looks like. The baseline every component passes:

- **Everything works by keyboard**, reached by Tab in source order. The DOM order is the visual order; never reorder with CSS, never use a positive `tabindex`.
- **Real elements.** `<button>` beats `<div onClick>`, which keyboards and screen readers can't use.
- **Icon-only buttons get an `aria-label` (An attribute that gives an element its spoken name for screen readers. Every button that shows only an icon needs one that names the action, like "Close dialog")** that names the action: "Close dialog", never "icon".
- **Skip link** at the top: "Skip to content", hidden until focused.
- **One `<main>`, one `<h1>`**, headings that never skip a level, and `<html lang>` set.
- **Not by color alone.** Errors get an icon and red; color-blind users (about 8% of men) need the second cue.

The four-key test catches most of it: Tab through the page. Can you reach everything? Is the ring visible? Do Enter and Space activate? Does Escape dismiss?

## Custom widgets owe a key map

Native elements ship their keyboard behavior. A custom menu, tabs or combobox has to rebuild it, and the rebuild is where it fails. Drive this menu with the keyboard, or with the keys beside it:

*[Interactive demo: Down opens on the first item, Up on the last, Escape closes and hands focus back.]*

`tabindex` (An attribute that controls whether something can take keyboard focus. Only two values are legal: 0 (joins the Tab order) and -1 (focusable from code only). A positive number breaks the page's order) has two legal values. `0` adds a custom control to the Tab order; `-1` makes something focusable from code only. A composite widget (tabs, menu, toolbar, radio group) takes **one** Tab stop: the active item is `0`, the rest `-1`, and the arrow keys move both focus and the `0`. That is a roving tabindex (A way to give a group of controls (a menu, tabs, a toolbar) a single Tab stop: the active item has tabindex 0, the rest -1, and the arrow keys move focus between them).

| Widget | Keys |
|---|---|
| Dialog | Tab cycles inside and wraps; Escape closes; focus returns to the trigger |
| Menu button | Enter, Space or ↓ opens on the first item; ↑ on the last; Escape closes and refocuses the button |
| Tabs | Arrows move and wrap; Home and End jump; Tab leaves to the panel |
| Disclosure | A `<button aria-expanded>`; Enter and Space toggle |
| Combobox | ↓ opens; Enter accepts; Escape closes; typing filters |

Escape always closes whatever opened **last**: tooltip, then menu, then dialog. Prefer `<dialog>` with `showModal()`, which makes the background inert and handles Escape for free. And after a client-side route change, set the page title and move focus to the new `<h1>`, or a screen reader hears nothing happen.

**A `role="tab"` with no key handler looks finished in every screenshot and fails every keyboard.** Grep for `role="button"`, `role="tab"` and `role="menuitem"` and check each one.

## Touch: hover enhances, never enables

A thumb has no hover and is about 44px wide. Tap the icons quickly in both modes:

*[Interactive demo: The icons look the same either way. Grown, their invisible targets reach 44px.]*

The touch rules:

1. **Hover is a bonus.** Gate it with `@media (hover: hover) and (pointer: fine)`, and never put the only way to a control behind it. Card actions shown on hover are always visible on touch.
2. **Targets of 44px** on touch (40px on desktop). Keep the icon small and grow the hit area (The invisible region that responds to a click or tap. It can be bigger than the icon you see, so a 24px icon can still have a 44px target for a thumb) with a pseudo-element: `inset: -10px` on a 24px icon makes 44px. Hit areas never overlap.
3. **`touch-action: manipulation`** on controls kills the double-tap zoom delay.
4. **Hidden means hidden.** A closed drawer moved off-screen with a transform, or faded to `opacity: 0`, is still in the Tab order. Use `inert`, `visibility: hidden` or `display: none`.
5. **Focus moves in and comes back.** A dialog focuses its first control on open and returns focus to its trigger on close. Delete the focused row, and focus goes to the next row.
6. **Timers pause** when the tab is hidden, and on hover and focus.

*(Tabbing into a closed drawer is the classic miss: the keyboard user lands inside something they can't see.)*

A few phone fixes come up in every project: `-webkit-tap-highlight-color: transparent` for the gray tap flash, `100dvh` instead of `100vh`, 16px inputs to stop zoom, and `user-select: none` on controls (never on the whole page) so a long press doesn't select button text.

**Keyboard and touch pass (paste into your agent):**

```text
Run a keyboard and touch pass on this component:
- Replace outline: none with :focus-visible rings (2px, offset 2px, 3:1 against the background).
- Every icon-only button gets an aria-label naming the action. No div onClick.
- Custom menu: Enter/Space/Down open on the first item, Up on the last, arrows wrap, Home/End, Escape closes and refocuses the trigger, roving tabindex (one Tab stop).
- Hover styles inside @media (hover: hover) and (pointer: fine); nothing reachable only by hover.
- Touch targets ≥ 44px via a pseudo-element; touch-action: manipulation on controls.
- Hidden panels use inert or visibility: hidden, never opacity or transform alone.
```

**Check yourself:** A closed side drawer is hidden with transform: translateX(100%). What breaks?

- [ ] Nothing, it is off-screen
- [x] Its links are still in the Tab order, so keyboard focus lands on invisible controls
- [ ] It can't animate open
- [ ] Screen readers announce it twice

*Why:* Transforms and opacity hide things visually but leave them focusable. Use inert, visibility: hidden or display: none when it is closed.

## In the skill

- https://school.agents.org.in/skill/components/accessibility-baseline.md
- https://school.agents.org.in/skill/components/keyboard-patterns.md
- https://school.agents.org.in/skill/components/touch-and-focus.md
