Focus & State Motion Accessibility

Part of Accessible Motion Architecture.

Interactive-state motion is the animation that communicates what a control is doing: the ring that grows around a focused button, the lift on hover, the depression on :active, the settle of a toggle as it flips. Unlike decorative reveal animations, this motion carries information — it tells the user where the keyboard is and what will happen if they act. That makes it a special case for accessibility. You cannot simply delete it under reduced-motion the way you would a parallax hero, because the underlying state feedback is essential. Instead you separate the message from the movement: keep the feedback, drop the travel. This guide covers how to build that separation, how to keep the motion on the compositor, and how to make sure state is never conveyed by motion alone.


What “state motion” means and why it is different

Most motion accessibility advice is about suppression: detect prefers-reduced-motion and turn the animation off. That advice is correct for ornamental motion, but interactive-state motion is functional — it is part of how the interface reports its own condition. WCAG’s reduced-motion guidance (SC 2.3.3, Animation from Interactions) explicitly concerns motion that is not essential. A focus indicator is essential; the fade that introduces it is not. The engineering task, then, is to decompose each state animation into two layers:

  • The state signal — the persistent, perceivable difference between “focused” and “not focused”, “pressed” and “resting”. This must survive reduced-motion, low frame rates, and a user glancing away mid-transition.
  • The transition — the animated interpolation between those two states. This is a comfort and polish layer that can and should be reduced to zero duration when the user asks for less motion.

Get this decomposition wrong in either direction and you fail a user. Remove the whole thing under reduced-motion and a keyboard user loses their focus indicator — a WCAG 2.4.7 failure. Keep the full travel and a vestibular-sensitive user gets an unwanted lurch on every hover. The rest of this page is about drawing that line cleanly.


Execution model: which state motion touches the main thread

State transitions are triggered by pseudo-class changes — :hover, :focus-visible, :active, :checked. When the pseudo-class flips, the browser recalculates style for the matched element and starts any transition declared on the properties that changed. Whether the resulting animation stays on the compositor depends entirely on which properties you transition, exactly as it does for keyframe animation.

The trap specific to focus and state work is that the most obvious properties are the expensive ones. outline, border-width, and box-shadow are the native vocabulary of focus rings, and all three invalidate paint — box-shadow in particular repaints a region larger than the element on every frame of a spread transition. background-color and color are paint-only too. The compositor-safe path is to render the ring as a pseudo-element and animate its transform (scale) and opacity, changing any discrete colour in a single instantaneous step rather than transitioning it.

Invalidations fire on the frame the pseudo-class changes. If that frame also runs layout — because you transitioned width or padding on :focus — the input feedback is delayed behind a main-thread reflow, which is exactly when a keyboard user is waiting to see where focus landed. Keeping the transition on transform/opacity means the ring appears on the compositor thread even if the main thread is briefly busy. This is the same discipline documented in animation state management, applied to the narrow but high-traffic case of interactive states.

State feedback across multiple cue channels Four interactive states — resting, hover, focus-visible, active — shown across the top. Below each, three cue channels are marked: motion (transform/opacity, compositor), colour, and text or shape. The diagram stresses that motion is only one channel and that each state still differs in a non-motion channel. Every state differs in at least one non-motion channel Resting Hover Focus-visible Active / pressed Motion Colour Text / shape none lift translateY ring scale + fade press scale down base darker fill high-contrast ring inset shade label cursor pointer 2px solid outline aria-pressed=true Under reduced-motion the Motion row collapses to instant; the Colour and Text/shape rows are untouched. Motion (orange) runs on the compositor; discrete colour and text changes apply in one step.

Property and API reference

The table below sorts the tools of interactive-state feedback by their rendering cost. Prefer the compositor tier for anything that animates; use the paint tier only for discrete, instant changes.

Property / selector Typical values Compositing tier Notes
transform (on ring pseudo-element) scale(), translateY() Composite The compositor-safe way to grow a ring or lift a control
opacity (on ring pseudo-element) 01 Composite Fades the ring in without touching paint
:focus-visible selector Composite (drives the above) Shows the ring for keyboard/AT focus, not mouse clicks
:focus selector Composite (fallback) Broad fallback for browsers without :focus-visible
:active selector Composite (drives the above) Press feedback; keep to a small scale()
outline 2px solid Paint Keep as a static, instant indicator — do not transition its geometry
outline-offset 2px4px Paint Transitioning it repaints each frame; set it once instead
box-shadow ring/spread Paint Expensive to animate; avoid transitioning spread
border-width 1px3px Layout Changes box size; never transition on interaction
background-color / color discrete Paint Change instantly as a non-motion state cue, do not animate for feedback
accent-color discrete Paint Useful persistent colour cue for native controls

Annotated code examples

1. A compositor-safe focus ring that reduces to instant

Intent: draw the focus ring on a pseudo-element and animate only its transform and opacity, so the motion is off the main thread — and under reduced-motion keep the ring but remove the travel.

/* The control paints its ring on ::after so the ring can scale/fade
   independently without touching the button's own box. */
.btn {
  position: relative;
  transition: transform 0.12s ease;
}

.btn::after {
  content: "";
  position: absolute;
  inset: -4px;
  border-radius: inherit;
  outline: 2px solid #7c3aed;      /* the persistent, essential indicator */
  opacity: 0;                       /* hidden until focus */
  transform: scale(0.92);
  transition: opacity 0.15s ease, transform 0.15s ease;
  pointer-events: none;
}

/* Keyboard / AT focus grows and fades the ring in — compositor only. */
.btn:focus-visible::after {
  opacity: 1;
  transform: scale(1);
}

/* Reduced motion: keep the ring, drop the animation.
   The indicator still appears — it just appears instantly. */
@media (prefers-reduced-motion: reduce) {
  .btn,
  .btn::after {
    transition: none;
  }
  .btn:focus-visible::after {
    opacity: 1;
    transform: none;   /* no scale travel, ring is simply present */
  }
}

Rendering Impact: transform + opacity on a pseudo-element — composite only. The static outline paints once and never re-paints during the transition.

2. Hover lift that never becomes the only state cue

Intent: lift the card on hover for polish, but back the interactive state with a persistent colour and border change so the state survives when motion is suppressed or a frame is dropped.

.card {
  border: 1px solid var(--edge);
  background: var(--surface);
  transition: transform 0.2s ease;
}

/* Motion channel — decorative lift, compositor-safe. */
.card:hover,
.card:focus-within {
  transform: translateY(-4px);
}

/* Non-motion channels — these persist and carry the actual signal.
   They change instantly and remain even with motion switched off. */
.card:hover,
.card:focus-within {
  --edge: #7c3aed;
  --surface: color-mix(in srgb, #7c3aed 8%, Canvas);
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
  .card:hover,
  .card:focus-within {
    transform: none;   /* drop the lift; colour + border still signal state */
  }
}

Rendering Impact: transform — composite only. The border and background changes are discrete paint updates applied in a single step, not animated.

3. An accessible toggle: motion plus a text/ARIA state that does not depend on it

Intent: animate the toggle thumb sliding, but express the on/off state through aria-pressed and a visible label so a screen-reader user and a reduced-motion user both perceive the state without the slide.

.toggle__thumb {
  transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
  transform: translateX(0);
}

/* Motion channel — the thumb slides across on activation. */
.toggle[aria-pressed="true"] .toggle__thumb {
  transform: translateX(20px);
}

/* Non-motion channel — track colour flips instantly and persists. */
.toggle[aria-pressed="true"] {
  background: #047857;
}

@media (prefers-reduced-motion: reduce) {
  .toggle__thumb {
    transition: none;   /* thumb jumps to position; no slide */
  }
}
<!-- The authoritative state lives in aria-pressed and the label text,
     never in the slide animation alone. -->
<button class="toggle" aria-pressed="false">
  <span class="toggle__thumb"></span>
  <span class="toggle__label">Notifications: <b>off</b></span>
</button>

Rendering Impact: transform — composite only. aria-pressed and the label text are the state of record; the track colour is a discrete paint change.


DevTools workflow: verifying accessible state motion

  1. Open Chrome DevTools and switch to the Rendering tab (⋮ → More tools → Rendering). Enable Emulate CSS media feature prefers-reduced-motion and set it to reduce.
  2. Tab through every interactive control with the keyboard. In reduce mode, confirm each focused element still shows a clearly visible ring or indicator — it should appear instantly, not fade. A control that loses its indicator here is a WCAG 2.4.7 failure.
  3. Switch emulation back to no-preference and re-tab. The ring should now animate in, and the animation should be brief (under ~200ms).
  4. Open the Performance panel, record while hovering and focusing controls rapidly. In the flame chart, confirm the Compositor thread handles the ring frames and that no Paint or Layout bars appear on the Main thread per hover. Long paint bars mean you are transitioning box-shadow or outline geometry — move the ring to a transform-animated pseudo-element.
  5. In the Elements panel, use the :hov flyout to force :focus-visible and :active, and inspect the computed style to confirm the persistent colour/text cue is present, not only the transform.
  6. Run the Accessibility inspector on a toggled control and confirm aria-pressed (or the relevant state) updates — proving state is exposed to assistive technology independently of the visual slide.

Failure modes and fixes

Problem: A keyboard user reports they cannot tell which control is focused when their OS reduced-motion setting is on. Root cause: The global reduced-motion reset (* { transition: none } plus an outline: none) removed the focus animation and the focus indicator together. Fix: Never pair outline: none with reduced-motion suppression. In the reduce block, set the transition to none but explicitly restore the visible ring (opacity: 1; transform: none) so the indicator is present instantly.


Problem: Hover feedback disappears entirely for reduced-motion users, leaving cards looking inert. Root cause: The only hover cue was a transform: translateY() lift, which the reduce block correctly removes — but nothing replaced it. Fix: Add a persistent non-motion cue (border colour, background tint, or an underline) that changes on :hover/:focus-within and is not inside the reduced-motion suppression. Motion becomes an enhancement, not the signal.


Problem: Focusing a button causes a visible layout jump and delayed ring on slow pages. Root cause: The focus style transitions border-width or padding, resizing the box and forcing layout on the focus frame. Fix: Move the ring to a pseudo-element with inset and animate transform/opacity. Size never changes, so no layout runs and the ring appears on the compositor immediately.


Problem: Mouse users complain that clicking any button flashes a large animated ring. Root cause: The ring is driven by :focus, which matches on pointer clicks as well as keyboard focus. Fix: Drive the animated ring from :focus-visible and keep a minimal static :focus fallback for legacy browsers. The heuristic shows the ring for keyboard and assistive-technology focus without firing on every click.


Problem: A vestibular-sensitive user finds the :active press animation on a rapidly-tapped control nauseating. Root cause: The press uses a large scale() or translate that compounds when the control is activated repeatedly. Fix: Keep press feedback to a small scale (no more than scale(0.97)), and suppress it under reduced-motion while retaining an instant colour change so the press still registers visually.


Accessibility and reduced-motion notes

Interactive-state motion sits at the intersection of two WCAG obligations. SC 2.4.7 Focus Visible requires that the keyboard focus indicator is always perceivable — this is non-negotiable and applies regardless of the user’s motion preference. SC 2.3.3 Animation from Interactions asks that non-essential motion triggered by interaction can be disabled; the transition into the focused state is such motion. Reconciling them is the core skill: the indicator stays, the transition goes.

There is also SC 1.4.1 Use of Colour and, more broadly, the principle that state must not depend on a single sensory characteristic. Motion is a sensory characteristic. A state signalled only by movement fails users who cannot or do not perceive the movement — reduced-motion users, users on frame-dropping hardware, and anyone whose attention was elsewhere during the 150ms transition. Every state your interface expresses through motion must also be expressed through at least one persistent channel: colour, contrast, text, an icon, a border, or an ARIA state property.

For deeper coverage of when functional motion is exempt and how to structure the global reduced-motion gate, see prefers-reduced-motion architecture. For the two narrow implementations this topic depends on most, work through accessible focus-ring animation patterns and animating ARIA live-region updates.


Frequently asked questions

Is focus-ring motion exempt from prefers-reduced-motion because it is functional?

The visible focus indicator itself is mandatory and must never be removed, but its animation is not exempt. Under prefers-reduced-motion: reduce you keep the ring but present it instantly, dropping the scale or fade transition. Functional feedback is allowed to persist; the decorative motion around it should not.

Why can I not rely on motion alone to show that a control is active?

Users with reduced-motion enabled, users on low-end devices dropping frames, and users who simply glance away during the transition will all miss a state signalled only by movement. WCAG requires state to be perceivable without relying on a single sensory characteristic, so pair motion with a colour, text, shape, or border change that persists after the animation ends.

Do hover and focus transitions run on the compositor?

Only if you animate transform and opacity. Transitioning outline, border-width, box-shadow, or background-color triggers paint or layout on the main thread every frame. To keep interactive-state motion off the main thread, animate a pseudo-element ring with transform and opacity and change discrete colours instantly.

Should :focus-visible or :focus drive the animated ring?

Prefer :focus-visible so the animated ring appears for keyboard and assistive-technology users but not on every mouse click, which many users find noisy. Keep a plain :focus fallback for older browsers, and never suppress the indicator entirely when the heuristic omits it.