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.
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.)
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:
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 .
| 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:
The touch rules:
- 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. - Targets of 44px on touch (40px on desktop). Keep the icon small and grow the with a pseudo-element:
inset: -10pxon a 24px icon makes 44px. Hit areas never overlap. touch-action: manipulationon controls kills the double-tap zoom delay.- Hidden means hidden. A closed drawer moved off-screen with a transform, or faded to
opacity: 0, is still in the Tab order. Useinert,visibility: hiddenordisplay: none. - 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.
- Timers pause when the tab is hidden, and on hover and focus.
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.
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?
In the skill
The nodes your agent reads when it does this work.
- Accessibility baselineKeyboard-everywhere, :focus-visible, hit targets, ARIA names on icon buttons, polite aria-live for async. The a11y floor below taste.
components/accessibility-baseline - Keyboard patterns
components/keyboard-patterns - Touch and focusHover enhances, never enables. 44px hit areas by pseudo-element, touch-action set on purpose, muted + playsinline, aria-label on icon buttons, hidden content out of the tab order, focus moved in and restored, timers paused when hidden, tooltip warm state, submenu safe area — plus the mobile-native fixes table.
components/touch-and-focus
Connected lessons
What this lesson builds on and leads to, across every course.