4.1 KiB
Interaction Design
The Eight Interactive States
Every interactive element needs these states designed:
| State | When | Visual Treatment |
|---|---|---|
| Default | At rest | Base styling |
| Hover | Pointer over (not touch) | Subtle lift, color shift |
| Focus | Keyboard/programmatic focus | Visible ring (see below) |
| Active | Being pressed | Pressed in, darker |
| Disabled | Not interactive | Reduced opacity, no pointer |
| Loading | Processing | Spinner, skeleton |
| Error | Invalid state | Red border, icon, message |
| Success | Completed | Green check, confirmation |
The common miss: Designing hover without focus, or vice versa. They're different. Keyboard users never see hover states.
Focus Rings: Do Them Right
Never outline: none without replacement. It's an accessibility violation. Instead, use :focus-visible to show focus only for keyboard users:
/* Hide focus ring for mouse/touch */
button:focus {
outline: none;
}
/* Show focus ring for keyboard */
button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
Focus ring design:
- High contrast (3:1 minimum against adjacent colors)
- 2-3px thick
- Offset from element (not inside it)
- Consistent across all interactive elements
Form Design: The Non-Obvious
Placeholders aren't labels—they disappear on input. Always use visible <label> elements. Validate on blur, not on every keystroke (exception: password strength). Place errors below fields with aria-describedby connecting them.
Loading States
Optimistic updates: Show success immediately, rollback on failure. Use for low-stakes actions (likes, follows), not payments or destructive actions. Skeleton screens > spinners—they preview content shape and feel faster than generic spinners.
Modals: The Inert Approach
Focus trapping in modals used to require complex JavaScript. Now use the inert attribute:
<!-- When modal is open -->
<main inert>
<!-- Content behind modal can't be focused or clicked -->
</main>
<dialog open>
<h2>Modal Title</h2>
<!-- Focus stays inside modal -->
</dialog>
Or use the native <dialog> element:
const dialog = document.querySelector('dialog');
dialog.showModal(); // Opens with focus trap, closes on Escape
The Popover API
For tooltips, dropdowns, and non-modal overlays, use native popovers:
<button popovertarget="menu">Open menu</button>
<div id="menu" popover>
<button>Option 1</button>
<button>Option 2</button>
</div>
Benefits: Light-dismiss (click outside closes), proper stacking, no z-index wars, accessible by default.
Destructive Actions: Undo > Confirm
Undo is better than confirmation dialogs—users click through confirmations mindlessly. Remove from UI immediately, show undo toast, actually delete after toast expires. Use confirmation only for truly irreversible actions (account deletion), high-cost actions, or batch operations.
Keyboard Navigation Patterns
Roving Tabindex
For component groups (tabs, menu items, radio groups), one item is tabbable; arrow keys move within:
<div role="tablist">
<button role="tab" tabindex="0">Tab 1</button>
<button role="tab" tabindex="-1">Tab 2</button>
<button role="tab" tabindex="-1">Tab 3</button>
</div>
Arrow keys move tabindex="0" between items. Tab moves to the next component entirely.
Skip Links
Provide skip links (<a href="#main-content">Skip to main content</a>) for keyboard users to jump past navigation. Hide off-screen, show on focus.
Gesture Discoverability
Swipe-to-delete and similar gestures are invisible. Hint at their existence:
- Partially reveal: Show delete button peeking from edge
- Onboarding: Coach marks on first use
- Alternative: Always provide a visible fallback (menu with "Delete")
Don't rely on gestures as the only way to perform actions.
Avoid: Removing focus indicators without alternatives. Using placeholder text as labels. Touch targets <44x44px. Generic error messages. Custom controls without ARIA/keyboard support.