Skip to main content
MEWA STUDIO

Accessibility of Interactions : Vestibular, Colorblindness, Keyboard Navigation

Published on March 6, 2026|10 min read
accessibilityinteractionUX

Learn how to make your interactions accessible without sacrificing experience. Concrete solutions for vestibular disorders, colorblindness, and keyboard navigation. Real business impact.

Colorful intertwined cables on a dark blue-violet background, symbolizing the connections between accessibility and web interactions

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 :

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

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

tsx
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

TypeWhat's affectedWhat the user seesFrequency
ProtanopiaRed perceptionRed becomes dark brown / gray. Green and red look the same.1 % of men, 0.01 % of women
DeuteranopiaGreen perceptionGreen becomes brown / ochre. Red and green are nearly identical.1 % of men, 0.01 % of women
TritanopiaBlue perceptionBlue 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

tsx
// Color is the only state indicator
<button style={{ backgroundColor: success ? 'green' : 'red' }}>
  Submit
</button>

What you see everywhere (insufficient)

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

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

tsx
<div onClick={() => addToCart(product)}>Add to cart</div>

Invisible to keyboard

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

css
/* 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 :

css
/* :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 :

tsx
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

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

ModeBehaviorUse case
aria-live="polite"Waits for the screen reader to finish its current sentence, then announces the changeConfirmation toast, counter update, success message
aria-live="assertive"Immediately interrupts the screen reader to announce the changeForm 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 :

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

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