Touch, keyboard, and form interaction patterns for accessible UI
Touch device considerations, keyboard navigation, form patterns, and accessibility fundamentals for interactive UI.
/behavior
This skill provides guidance on making interfaces work well across input methods and devices. Use it when:
Disable hover effects on touch devices. Touch triggers hover on tap, causing false positives:
/* Only apply hover on devices that support it */
@media (hover: hover) and (pointer: fine) {
.element:hover {
transform: scale(1.05);
}
}
Important: Don't rely on hover effects for UI to work. Hover should enhance, not enable functionality.
Ensure minimum 44px tap targets on all interactive elements:
.icon-button {
/* Visual size can be smaller */
width: 24px;
height: 24px;
position: relative;
}
/* But hit area should be 44px */
.icon-button::before {
content: '';
position: absolute;
inset: -10px;
}
Or use minimum dimensions:
.small-button {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
For custom gestures, disable native behavior:
/* Disable all touch behaviors for custom canvas */
.custom-canvas {
touch-action: none;
}
/* Prevent double-tap zoom on controls */
button, a, input {
touch-action: manipulation;
}
Apply muted and playsinline for autoplay without fullscreen:
<video autoplay muted playsinline loop>
<source src="video.mp4" type="video/mp4" />
</video>
Show correct modifier key based on OS:
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const modKey = isMac ? 'Cmd' : 'Ctrl';
// Display: "Save (Cmd+S)" on Mac, "Save (Ctrl+S)" on Windows
Ensure consistent tabbing through visible elements only:
/* Hide from tab order when not visible */
.hidden-panel {
visibility: hidden;
}
/* Or use inert attribute */
<div inert={!isVisible}>...</div>
Ensure focused elements are visible:
function handleFocus(e) {
e.target.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
When opening modals:
function Modal({ isOpen, onClose, triggerRef }) {
const modalRef = useRef();
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
} else {
triggerRef.current?.focus();
}
}, [isOpen]);
return (
<div ref={modalRef} tabIndex={-1} role="dialog">
{/* Modal content */}
</div>
);
}
Always associate labels with inputs:
<label for="email">Email</label>
<input id="email" type="email" />
<!-- Or wrap the input -->
<label>
Email
<input type="email" />
</label>
Use appropriate types for mobile keyboards:
<input type="email" /> <!-- Email keyboard -->
<input type="tel" /> <!-- Phone keypad -->
<input type="url" /> <!-- URL keyboard -->
<input type="number" /> <!-- Numeric keypad -->
<input type="search" /> <!-- Search with clear -->
Ensure 16px minimum to prevent zoom on focus:
input, textarea, select {
font-size: 16px;
}
Position icons absolutely, not as siblings:
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.input-field {
padding-left: 40px;
}
Don't autofocus on touch devices:
const isTouchDevice = 'ontouchstart' in window;
<input autoFocus={!isTouchDevice} />
Wrap inputs with <form> for Enter key submission:
<form onSubmit={handleSubmit}>
<input type="text" />
<button type="submit">Submit</button>
</form>
Support Cmd/Ctrl+Enter for textareas:
function handleKeyDown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
handleSubmit();
}
}
Disable password managers when not needed:
<input
data-lpignore="true"
data-1p-ignore
spellcheck="false"
autocomplete="off"
/>
Always set labels on icon buttons:
<button aria-label="Close dialog">
<CloseIcon />
</button>
<button aria-label="Search">
<SearchIcon />
</button>
Decorative code-built illustrations need ARIA:
<div
role="img"
aria-label="Abstract geometric pattern"
className="decorative-illustration"
/>
Support prefers-reduced-motion:
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
<video
autoPlay={!prefersReducedMotion}
controls={prefersReducedMotion}
muted
playsinline
/>
Pause timers when tab is hidden:
let timeoutId;
let remainingTime;
let startTime;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearTimeout(timeoutId);
remainingTime -= Date.now() - startTime;
} else {
startTime = Date.now();
timeoutId = setTimeout(callback, remainingTime);
}
});
Always use <button> for buttons:
<!-- Good -->
<button onClick={handleClick}>Click me</button>
<!-- Bad -->
<div onClick={handleClick}>Click me</div>
Prevent duplicate requests:
const [isSubmitting, setIsSubmitting] = useState(false);
<button
disabled={isSubmitting}
onClick={async () => {
setIsSubmitting(true);
await submitForm();
setIsSubmitting(false);
}}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
Add scale on active for responsiveness:
.button:active {
transform: scale(0.97);
}
Add delay to prevent accidental activation:
.tooltip {
transition-delay: 200ms;
}
Sequential tooltips: Skip delay after first tooltip opens:
const [isWarm, setIsWarm] = useState(false);
// When any tooltip opens, set warm state
// Clear warm state after 300ms of no tooltip
Allow diagonal cursor movement to submenus:
.submenu-trigger::after {
content: '';
position: absolute;
clip-path: polygon(0 0, 100% 0, 100% 100%);
}
Make entire row clickable:
<label class="checkbox-row">
<input type="checkbox" />
<span>Remember me</span>
</label>
TOUCH:
├── @media (hover: hover) for hover effects
├── 44px minimum tap targets
├── touch-action: manipulation on controls
├── muted + playsinline for video autoplay
└── Detect OS for shortcut display
KEYBOARD:
├── visibility: hidden hides from tab order
├── inert attribute for inactive sections
├── scrollIntoView on focus
└── Focus trap in modals
FORMS:
├── Labels associated with inputs
├── Correct input types for keyboards
├── 16px minimum font (iOS zoom)
├── <form> wrapper for Enter submission
└── No autofocus on touch devices
ACCESSIBILITY:
├── aria-label on icon buttons
├── role="img" + aria-label on decorative
├── prefers-reduced-motion support
└── Pause timers on tab hide
BUTTONS:
├── Always use <button> element
├── Disable during submission
└── transform: scale(0.97) on :active
Touch:
├── [ ] Hover effects gated by @media (hover: hover)
├── [ ] All tap targets >= 44px
├── [ ] touch-action set appropriately
└── [ ] Videos have muted + playsinline
Keyboard:
├── [ ] Tab order is logical
├── [ ] Hidden elements removed from tab order
├── [ ] Focus managed in modals
└── [ ] Scroll into view on focus
Forms:
├── [ ] All inputs have labels
├── [ ] Correct input types used
├── [ ] Font size >= 16px
├── [ ] Form wrapper for submission
└── [ ] No autofocus on touch
Accessibility:
├── [ ] Icon buttons have aria-label
├── [ ] Reduced motion supported
├── [ ] Timers pause when hidden
└── [ ] Semantic elements used