Accessibility of Interactions : Vestibular, Colorblindness, Keyboard Navigation
Learn how to make your interactions accessible without sacrificing experience. Concrete solutions for vestibular disorders, colorblindness, and keyboard navigation. Real business impact.

Open an award-winning e-commerce site. On the first scroll, a full-screen parallax animation fires : the flagship product spins in 3D, particles explode, text slides across three axes. For a user with a vestibular disorder, nausea rises within two seconds. The tab closes. They won't come back.
Same site. A colorblind user, protanope, is looking for the "Add to cart" button. The button is a red rectangle on a white background. To them, it's a dull gray shape they mistake for a disabled element. They hesitate, doubt, leave.
Same site. A user navigates by keyboard, partial paralysis of the right hand. They press Tab : nothing visible happens. The focus exists somewhere in the DOM, but a developer removed the outline because it looked "ugly." Blind navigation through an unusable interface.
Three profiles. Three exclusions. One site. And that site might be yours.
The real cost of exclusion
Accessibility is often framed as a legal obligation or an ethical gesture. It's both. But it's also, and above all, a business decision.
The numbers :
- 16% of the global population, 1.3 billion people, lives with a significant disability (source : WHO (opens in a new tab))
- 8% of men and 0.5% of women are colorblind (source : National Eye Institute (opens in a new tab))
- 2-3% of the population has diagnosed vestibular disorders (source : NIDCD (opens in a new tab))
- 1 in 4 users experiences discomfort when scrolling with parallax (source : W3C WCAG 2.1 (opens in a new tab))
Beyond the stats, there's a phenomenon companies underestimate : users who need accessibility are among the most loyal. When someone finds a site that respects their needs, they return, recommend it, become advocates. Because these sites are rare.
The reverse is equally true. A Click-Away Pound (opens in a new tab) study estimates that UK businesses lose £17.1 billion per year by abandoning disabled users who "click away." In other markets, the ratio is proportional.
Accessibility isn't a conscience add-on. It's a structural competitive advantage.
Motion : when design makes people sick
What a vestibular user experiences on your site has a name : cybersickness. It's a vestibular disorder triggered by a conflict between what the eyes see (movement) and what the inner ear feels (stillness). Symptoms ? Nausea, dizziness, headaches, and in severe cases, inability to use a screen for hours.
The highest-risk animations :
- Parallax: speed offset between layers ; the brain can't determine which surface is "real"
- Rapid zoom: depth simulation that the inner ear contradicts
- Multi-axis animations: an element moving in X, Y, and Z simultaneously creates sensory overload
- Auto-playing video with movement: unsolicited motion in peripheral vision
The solution : prefers-reduced-motion
All modern operating systems let users request reduced motion. macOS, iOS, Windows, Android : each exposes this preference. And the web has a media query to read it :
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}The nuclear approach (effective but blunt)
It works. But it's the equivalent of turning off all the lights to fix a lighting problem. For a premium site, you need to be more refined : replace animations, don't remove them.
The premium approach : a reusable React hook
Instead of handling prefers-reduced-motion in every component, centralize the logic in a hook :
import { useState, useEffect } from 'react';
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(query.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
query.addEventListener('change', handler);
return () => query.removeEventListener('change', handler);
}, []);
return prefersReduced;
}useReducedMotion.ts
Then in your components, the animation transforms instead of disappearing :
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/useReducedMotion';
export const ProductCard = ({ product }) => {
const reducedMotion = useReducedMotion();
return (
<motion.article
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 30 }}
whileInView={reducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
transition={reducedMotion
? { duration: 0.15 } // instant fade
: { duration: 0.6, ease: 'easeOut' } // slide + fade
}
viewport={{ once: true }}
>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.price}</p>
</motion.article>
);
};ProductCard.tsx, adaptive animation
The standard user sees a smooth slide-up. The vestibular user sees a gentle fade. Both experience a polished interface. The difference ? 15 lines of code.
Color : when design makes things invisible
Imagine a contact form. The user submits their information. The button changes from blue to green with the text "Sent !". To you, it's clear. To a protanope, the button went from blue-gray to a dull brownish-yellow. The word "Sent" is there, but the color, meant to reinforce the message, communicates nothing.
That's the most common case. But it gets worse : interfaces where color is the only information channel. A green "online" badge with no text. A chart with 5 lines differentiated only by hue. A form field that turns red to indicate an error, with no message or icon.
Types of colorblindness
| Type | What's affected | What the user sees | Frequency |
|---|---|---|---|
| Protanopia | Red perception | Red becomes dark brown / gray. Green and red look the same. | 1 % of men, 0.01 % of women |
| Deuteranopia | Green perception | Green becomes brown / ochre. Red and green are nearly identical. | 1 % of men, 0.01 % of women |
| Tritanopia | Blue perception | Blue becomes pink/gray. Yellow and blue look the same. | 0.01 % (very rare) |
85% of colorblind people are protanopes or deuteranopes. The direct consequence : the red/green duo that everyone uses for success/error is the worst possible choice for 1 in 12 male users.
The absolute rule : never color alone
Every piece of information conveyed by color must be backed by at least one other channel :
- Icon: a checkmark for validated, an alert triangle for error, a spinner for loading
- Text: "Sent successfully" not just a color change
- Shape / pattern: solid border vs dashed, filled background vs hatched
- Position: an error message appears below the field, not just a border color change
Code : an accessible form button
// Color is the only state indicator
<button style={{ backgroundColor: success ? 'green' : 'red' }}>
Submit
</button>What you see everywhere (insufficient)
type Status = 'idle' | 'loading' | 'success' | 'error';
const SubmitButton = ({ status, errorMessage }: {
status: Status;
errorMessage?: string;
}) => (
<div>
<button
disabled={status === 'loading'}
aria-describedby={status === 'error' ? 'form-error' : undefined}
className={clsx(
'submit-btn',
status === 'success' && 'submit-btn--success',
status === 'error' && 'submit-btn--error',
)}
>
{status === 'loading' && <Spinner aria-hidden="true" />}
{status === 'success' && <CheckIcon aria-hidden="true" />}
{status === 'error' && <AlertIcon aria-hidden="true" />}
<span>
{status === 'idle' && 'Submit'}
{status === 'loading' && 'Sending...'}
{status === 'success' && 'Sent!'}
{status === 'error' && 'Error, retry'}
</span>
</button>
{status === 'error' && errorMessage && (
<p id="form-error" role="alert" className="error-message">
{errorMessage}
</p>
)}
</div>
);What you should do (multi-channel)
Here, each state is communicated through four simultaneous channels: color (CSS), icon, text, and contextual message. Readable by 100% of users, regardless of how they perceive color.
Contrast : aim for AAA, not AA
WCAG defines two contrast levels :
- AA (legal minimum) : 4.5:1 for body text, 3:1 for large text
- AAA (recommended) : 7:1 for body text, 4.5:1 for large text
For a site that claims to be premium, aiming for AA is aiming for average. Aim for AAA. High contrast doesn't only help colorblind users. It improves readability in sunlight, on cheap screens, or simply for tired eyes at the end of the day.
Verification tools : WebAIM Contrast Checker (opens in a new tab), TPGI Color Contrast Checker (opens in a new tab), and for simulating colorblindness : Coblis (opens in a new tab).
Control : when design silences users
5-10% of your users navigate by keyboard. This includes : people with motor disabilities, screen reader users, power users who prefer the keyboard, and developers testing without a mouse. For all of them, your interface must be fully operable without a pointer.
The three most common mistakes :
Mistake 1 : clickable <div>s instead of <button>s
<div onClick={() => addToCart(product)}>Add to cart</div>Invisible to keyboard
<button onClick={() => addToCart(product)}>Add to cart</button>Natively accessible
A <button> is focusable, keyboard-activatable (Enter and Space), and announced as "button" by screen readers. A <div> is none of those things. If your element triggers an action, it's a <button>. Period.
Mistake 2 : invisible focus
/* Found in 60% of projects. NEVER do this. */
*:focus {
outline: none;
}The most common CSS crime
Removing the focus outline is like removing road signs from a highway. The keyboard user doesn't know where they are. The solution ? Style the focus instead of hiding it :
/* :focus-visible only activates on keyboard, not mouse click */
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
border-radius: 2px;
}A visible and elegant focus
:focus-visible is the ideal solution : it shows the outline when the user navigates by keyboard, but hides it on mouse click. You keep a clean interface for mouse users, and a clear landmark for keyboard users.
Mistake 3 : focus escaping modals
A modal opens. The user presses Tab. Focus passes through the modal... then continues behind it, into the background page. The keyboard user is lost in content they can't even see. Focus must be trapped (focus trap) inside the modal :
import { useEffect, useRef, useCallback } from 'react';
const Modal = ({ isOpen, onClose, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
const trapFocus = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') return onClose();
if (e.key !== 'Tab') return;
const focusable = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable?.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}, [onClose]);
useEffect(() => {
if (!isOpen) return;
const previouslyFocused = document.activeElement as HTMLElement;
modalRef.current?.querySelector<HTMLElement>('button, [href], input')?.focus();
document.addEventListener('keydown', trapFocus);
return () => {
document.removeEventListener('keydown', trapFocus);
previouslyFocused?.focus(); // restore focus on close
};
}, [isOpen, trapFocus]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
};Modal with focus trap
Three important details in this code :
1.Focus is given to the first interactive element on open
2.Tab loops between the first and last focusable elements
3.Focus is restored to the original element on close, the user finds their position
Silence : when design forgets to speak
There's a problem that even accessibility-aware developers forget : dynamic changes. Your cart shows an "Item added" toast. Your form displays "Sent successfully." Your dashboard updates a counter in real time.
Visually, these changes are obvious. But for a screen reader user, content that appears in the DOM will never be announced unless you explicitly ask for it. That's what aria-live is for.
aria-live: giving your interactions a voice
const Toast = ({ message, type }: {
message: string;
type: 'success' | 'error' | 'info';
}) => (
<div
role={type === 'error' ? 'alert' : 'status'}
aria-live={type === 'error' ? 'assertive' : 'polite'}
aria-atomic="true"
className={`toast toast--${type}`}
>
{type === 'success' && <CheckIcon aria-hidden="true" />}
{type === 'error' && <AlertIcon aria-hidden="true" />}
{type === 'info' && <InfoIcon aria-hidden="true" />}
<span>{message}</span>
</div>
);Accessible toast notification
The distinction between modes is crucial :
| Mode | Behavior | Use case |
|---|---|---|
aria-live="polite" | Waits for the screen reader to finish its current sentence, then announces the change | Confirmation toast, counter update, success message |
aria-live="assertive" | Immediately interrupts the screen reader to announce the change | Form error, security alert, session expired |
role="status" is a semantic shortcut for aria-live="polite", and role="alert" for aria-live="assertive". Use roles when possible. They're more readable and explicit.
Common trap : the container must exist BEFORE the content
A frequent pitfall with aria-live: if you inject the container and its content into the DOM at the same time, some screen readers won't announce it. The aria-live container must be present in the DOM before the content changes :
// The aria-live div is ALWAYS in the DOM (even empty)
// Only the message changes when an event occurs
const [notification, setNotification] = useState('');
return (
<>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{notification}
</div>
<button onClick={() => {
addToCart(product);
setNotification(`${product.name} added to cart`);
}}>
Add to cart
</button>
</>
);The container always exists, the content changes
Without aria-live, your most polished micro-interactions (the confirmation toast, the updated badge, the animated counter) don't exist for assistive technology users. They're silent.
Putting it all together : the component that integrates everything
In practice, these four dimensions (motion, color, keyboard, announcements) aren't separate projects. They combine in every component. Here's what a product card looks like when it integrates all of them :
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/useReducedMotion';
import { useState } from 'react';
export const ProductCard = ({ product }) => {
const reducedMotion = useReducedMotion();
const [status, setStatus] = useState<'idle' | 'added'>('idle');
const [announcement, setAnnouncement] = useState('');
const handleAdd = () => {
addToCart(product);
setStatus('added');
setAnnouncement(`${product.name} added to cart`);
setTimeout(() => setStatus('idle'), 2000);
};
return (
<motion.article
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
whileInView={reducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
transition={{ duration: reducedMotion ? 0.1 : 0.5 }}
viewport={{ once: true }}
>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p className="price">{product.price}</p>
<button
onClick={handleAdd}
className={status === 'added' ? 'btn--success' : 'btn--primary'}
>
{status === 'added' && <CheckIcon aria-hidden="true" />}
<span>{status === 'added' ? 'Added!' : 'Add to cart'}</span>
</button>
{/* Announcement for screen readers */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
</motion.article>
);
};Accessible ProductCard: motion, color, keyboard, announcements
This component :
- Motion: animation adapts via useReducedMotion, slide or fade based on preference
- Color: button state change uses icon + text + color (never color alone)
- Keyboard: it's a real <button>, natively focusable and activatable
- Announcement: aria-live informs the screen reader that the item was added
No visual compromise. No excessive complexity. Four dimensions of accessibility in one clean component.
Checklist before going to production
- Motion: enable "Reduce animations" in OS settings. Browse the entire site. Animations should transform (fade instead of slide) or disappear. No multi-axis animation, no parallax, no zoom should remain.
- Colorblindness: simulate with Coblis (opens in a new tab) or Chrome DevTools (Rendering > Emulate vision deficiencies). No information should rely on color alone. Every state must be backed by text or an icon.
- Contrast: check every interactive element with WebAIM (opens in a new tab). Target : 7:1 for body text (WCAG AAA).
- Keyboard: unplug the mouse. Navigate the entire site with Tab. Every interactive element must be reachable and activatable (Enter/Space). Focus must be visible at all times.
- Focus trap: open every modal/drawer/popover. Tab must loop inside. Escape must close. Focus must return to the original element after closing.
- Dynamic content: every notification, toast, confirmation message, or counter update must use
aria-live. Test with VoiceOver (Mac) or NVDA (Windows) : the change must be announced. - Semantics: no clickable
<div>s. Verify that every action uses<button>, every link uses<a>, every field uses<input>or<select>with an associated<label>. - Mobile: heavy animations (parallax, scroll-trigger, WebGL) should be lightened or disabled on mobile and small screens.
The site nobody leaves
The vestibular user revisits the site. This time, "Reduce motion" is enabled on their Mac. The parallax is gone. Products appear with a gentle fade. They browse the entire catalog without nausea.
The colorblind user finds the "Add to cart" button. It's blue with the text "Add," and on click, a checkmark appears with the word "Added." Even without perceiving the color change, they know exactly what happened.
The keyboard user navigates with a blue outline following each Tab. They open the product page, focus traps inside the modal. Escape closes it. They find their position. They place their order.
Three profiles. Three conversions. One site. And this time, it's yours.
Accessibility isn't a compromise on experience. It's experience at its highest level, the level where it works for everyone. And a site that works for everyone is a site nobody leaves.