Small UI animations that work for everyone: hover, focus, reduced motion, touch and dragging

Short answer: every effect you add on hover must also happen on keyboard focus, every animation needs a still version for people who ask for reduced motion, every control must be a real control (a button, a checkbox) under the styling, nothing may flash more than three times a second, and anything you drag must also work without dragging. Do those five things and a small animation stays a nice touch instead of a barrier.

We build these pieces for the Sundry Mill library, where each page runs the exact code you copy. This post collects the rules we follow, with the code that applies them, and one place where our own component needs more than the page gives you.

1. Hover is a bonus, focus is the rule

A mouse is one way in. People also reach a button with Tab, with a switch device or with a screen reader, and phones have no hover at all. So two things follow.

First, a button must look like a button before anyone hovers it. A hover effect can add life, but it cannot be the only sign that something is clickable.

Second, whatever happens on hover should also happen on keyboard focus. CSS has a selector made for this. :focus-visible matches a focused element only when the browser decides the focus "should be made evident", in MDN's words. In practice that means a keyboard user sees it and a mouse click on a button usually does not, so you get the effect for the people who need it without a ring flashing up on every click.

All six of our button hover effects are written as pairs:

.hb-fill:hover, .hb-fill:focus-visible { color: #FDF9F1; }
.hb-fill:hover::before, .hb-fill:focus-visible::before { transform: scaleX(1); }

WCAG 2.4.7 asks that keyboard focus is visible, and the understanding notes for 1.4.11 add that a focus indicator needs 3:1 contrast against what is around it. Hover effects themselves are treated as supplemental, so they do not have to meet 3:1. The focus state does.

Pairing the selectors is not always enough on its own. With nothing else set, Chrome draws its default focus ring, a thin dark line, right on the edge of the button. On the Fill slide that edge is already a dark #17202C border, so the ring disappears into it. The Fill slide therefore draws its own ring, set 3 pixels out so it cannot merge with the border:

.hb-fill:focus-visible { outline: 3px solid #E8631A; outline-offset: 3px; }

The orange #E8631A is 3.21:1 against the cream #FDF9F1 page. Our Arrow nudge, Warm glow and magnetic button have dark edges too, and draw their own rings the same way.

The Fill slide button from the library in three states on a cream page. At rest it already has a dark outline, so it looks like a button. On mouse hover the dark fill slides in and the text turns cream. On keyboard focus after Tab the same fill shows, so keyboard users see exactly what mouse users see, and an orange focus ring sits 3 pixels outside the dark border, 3.21:1 against the page. Below, the two CSS lines that pair :hover with :focus-visible and the line that draws the ring

2. Give every animation a still version

Some people get dizzy or sick from motion on screen, and every major system has a switch to ask for less of it. MDN lists where it lives: on Windows 11, Settings, Accessibility, Visual effects, Animation effects; on macOS, the Reduce motion setting under Accessibility; on iOS, Settings, Accessibility, Motion; on Android 9 and later, Settings, Accessibility, Remove animations. Websites can read it with the prefers-reduced-motion media query.

There are two ways to respect it. In CSS, switch the movement off and keep the end state:

@media (prefers-reduced-motion: reduce) {
  .term-line { animation: none; width: auto; }
  .term-caret { animation: none; }
}

That is the still version of our terminal text loop: every line shows in full straight away instead of typing itself out.

In JavaScript, check before you attach anything. Our 3D tilt card and magnetic button both start with this line, so for people who asked for less motion the card stays flat and the button stays put:

if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
Two library components with no motion preference and with prefers-reduced-motion: reduce. The terminal text, caught mid animation with each line typing itself out, and the still version where every line shows in full straight away, with the CSS line .term-line { animation: none; width: auto; }. The 3D tilt card turned toward the pointer, 12 degrees at its edge, and the same card staying flat, with the line of JavaScript that returns early when reduced motion is asked for

WCAG 2.3.3 (level AAA) asks that motion triggered by an interaction can be turned off unless it is essential. A tilting card is never essential, so it is the first thing to go.

Reduced motion does not mean no feedback. A toggle can still change colour, a checkbox can still show its tick, a loader can still say "Loading". It is the travel, spin and zoom that people are asking you to drop.

3. Nothing flashes more than three times a second

WCAG 2.3.1 says pages should not contain anything that "flashes more than three times in any one second period" unless the flash is below the general and red flash thresholds. Fast flashing can trigger seizures in people with photosensitive epilepsy, so this is a level A rule.

Most UI animation is nowhere near it, but blinking carets, pulsing alerts and "attention" effects can get close. The caret in our terminal loop blinks once a second:

.term-caret { animation: term-blink 1s steps(1) infinite; }
@keyframes term-blink { 50% { opacity: 0; } }

That is one flash a second, well under three. If you speed up a blink to make it feel urgent, count the flashes.

4. Style real controls, never replace them

The fastest way to break a form for keyboard and screen reader users is to build a switch or a checkbox out of divs. A real input type="checkbox" already does focus, Space to toggle, a label you can click, and an on or off state a screen reader can announce. Keep it, and style around it.

Our toggle switches are each a real checkbox with role="switch" inside a label. The WAI-ARIA Authoring Practices describe a switch as "an input widget that allows users to choose one of two values: on or off", toggled with Space (and optionally Enter). The role changes what a screen reader says: "on" and "off" instead of "checked" and "not checked". Two more rules come with it:

  • A switch acts at once, like a light switch. If the change waits for a Save button, use a plain checkbox instead, because the switch would be telling people something has happened when it has not.
  • The off state must be visible. The track is a part of the control you need to see, so it needs 3:1 against its background under 1.4.11. The off track of our Classic switch is #89919D on the cream #FDF9F1 page: 3.03:1.

Our checkbox animations hide the real checkbox visually but leave it in the page, so clicking the label, pressing Space and screen readers all still work. The drawing on top is decoration. If the CSS failed to load, you would still have a working checkbox.

A toggle switch built on a real checkbox with role="switch": off, where a screen reader says off and the grey track keeps 3.03:1 against the page, and on, where it says on. Next to them a plain checkbox, which a screen reader announces as checked instead. Below, the Draw checkbox, whose real input is hidden but still in the page: not checked, checked, and with keyboard focus after Tab, where the focus ring is drawn around the box

5. Loaders: say it once, and only when the wait is real

A spinning ring tells a sighted user that something is happening. A screen reader user gets nothing unless you tell them. Each of our CSS loaders carries role="status" and an aria-label, so a screen reader announces "Loading" once instead of staying silent.

Timing matters as much as markup. A loader that appears for 80 milliseconds is a flicker. We show one only after about 300 milliseconds, so fast responses never flash a spinner at all:

const slow = setTimeout(() => button.classList.add('is-loading'), 300);
await save();
clearTimeout(slow);
button.classList.remove('is-loading');

If the wait can be long, the reduced motion rule applies here too: a still ring with the word "Loading" is enough.

6. Anything you drag must work without dragging

Dragging is hard for people with tremors, people using a head pointer and people using voice control. WCAG 2.2 added 2.5.7, Dragging Movements: everything that uses a dragging movement "can be achieved by a single pointer without dragging", unless dragging is essential.

This is the one place where our own component needs more than its page gives you. Our slide to confirm control is built so the knob is a real button: Tab reaches it, and Enter or Space confirms straight away. That covers keyboard users. It does not cover someone with a mouse or a finger who cannot drag, because a single click on the knob does nothing. The click handler only confirms when the click has no pointer behind it:

knob.addEventListener('click', (e) => {
  if (e.detail === 0 && !el.classList.contains('is-done')) done();
});

So when you use it, add a single pointer path next to it, for example a small "Confirm without sliding" link that opens an ordinary Yes or No dialog. The slider stays the main gesture for touch screens, where it prevents accidental taps, and nobody is locked out.

The slide to confirm control used three ways. Drag: ready, the knob dragged part way, and confirmed as Sent after letting go at the end, which works. Keyboard: Tab reaches the knob, Enter or Space is pressed, and it confirms straight away, which works. One click with no drag: clicking the knob leaves it at rest, nothing happens and the person is locked out. The fix: a Confirm without sliding link that opens a plain Yes or No dialog, which works

Two related rules for anything you can press:

  • Targets at least 24 by 24 CSS pixels. That is WCAG 2.5.8. Small icon buttons and close crosses are the usual offenders.
  • Targets that move are harder to hit. Our magnetic button leans toward the mouse, which is fun on one big call to action and annoying on a form. It reacts only to a mouse (if (e.pointerType !== 'mouse') return;), so touch screens never see it, and it springs back as soon as the pointer leaves.

7. Keep it short, and pick the right curve

Most "this feels slow" complaints are timing, not design. The easing curves cheat sheet keeps UI animation between 150 and 400 milliseconds and gives each curve one job:

  • Ease out (fast start, gentle end) for things arriving: menus, toasts, cards. The one to use most.
  • Ease in for things leaving. Speeding up as it goes feels like the interface is getting out of the way.
  • Ease in out for things moving from one place to another on screen.
  • A spring for playful arrivals that overshoot and settle. Ours is a CSS linear() list sampled from a spring with stiffness 170 and damping 14.
  • Linear only for things that never stop, like spinners.

A menu that opens with ease out over 250 ms and closes with ease in over 180 ms feels quicker than the same menu at 400 ms both ways, though the difference is under a quarter of a second.

The five easing curves from the library cheat sheet: ease out for things arriving such as menus, toasts and cards, ease in for things leaving, ease in out for moving from one place to another, a spring for playful arrivals that overshoot and settle, and linear only for things that never stop. Below, a timeline with the 150 to 400 ms range for UI animation marked: a menu that opens with ease out over 250 ms and closes with ease in over 180 ms, against the same menu at 400 ms both ways

Size matters too. Our tilt card turns about 12 degrees at its edge. At 30 degrees it looks like it is falling over, and text on it gets hard to read.

A checklist before you ship a small animation

  1. Does it also happen on :focus-visible?
  2. Does it stop, or show its end state, under prefers-reduced-motion: reduce?
  3. Is there a real button, a or input underneath, with a label?
  4. Does it flash fewer than three times a second?
  5. If it is dragged, is there a way to do the same thing with one tap or click?
  6. Is every target at least 24 by 24 CSS pixels, and does it stay still while people aim?
  7. Is it between 150 and 400 ms, on a curve that matches what the element is doing?
  8. Does its focus state and every state of the control have 3:1 contrast against its surroundings?

The slide to confirm page in the library now says what to add for the fifth. If you find a library page that misses any of the others, tell us at support@sundrymill.com and we will fix it.

Sources

More from the blog

All posts RSS feed