Skip to content
ExamsLibraryFor agentsConnect your agent

Keyboard, focus and touch

intermediate·9 min·Markdown for agents

What this lesson teaches

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.

  1. 1Focus you can see, only when it helps
  2. 2The floor below taste
  3. 3Custom widgets owe a key map
  4. 4Touch: hover enhances, never enables

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.)

Focus is on Italic (button). Can you see where?
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

is a CSS selector that matches when the browser decides focus should be shown: after Tab, not after a mouse click. So:

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 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:

No keyboard? These keys drive the menu.
  • ↓ opened on Rename
Down opens on the first item, Up on the last, Escape closes and hands focus back.

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 .

WidgetKeys
DialogTab cycles inside and wraps; Escape closes; focus returns to the trigger
Menu buttonEnter, Space or ↓ opens on the first item; ↑ on the last; Escape closes and refocuses the button
TabsArrows move and wrap; Home and End jump; Tab leaves to the panel
DisclosureA <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:

Hits 0Misses 0Same icons, same look. Only the invisible target changed.
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 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· for your agent
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.

Quick check

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

What this lesson builds on and leads to, across every course.