Skip to content
gilltrish.dev

Small moves, big feelings: microanimations that everyone can enjoy

Getting comfortable12 min read

The 30-second version

Microanimations are small bits of motion that give feedback or delight, like a button press or a counter ticking. They’re optional extras, and some people get dizzy or sick from motion, so respect the “reduce motion” setting. The rules: never put information only in motion, give a way to pause anything that moves for more than 5 seconds, don’t flash, and give screen readers calm text instead.

On this page

Intro

I didn’t set out to love microanimations. It started with a challenge. We were building a new site with a winged logo, and our project manager told the room full of devs: whoever could make a nice, gentle, floating version of it would get extra points that month.

I tried so hard, but I couldn’t get it to look quite right. Our tech lead ended up doing it, and I was in awe. I learned from how he’d built it, and after that I volunteered every time a project needed something to move.

One of those volunteer jobs became my thing: a ticking counter. It was a tough integration, but once it worked, I was the one who added it to every project after that. You’ll build a version of it below.

What I love is the small stuff. A good microanimation is subtle: you might miss it, but when you catch it, it makes you smile. A bad one is jerky, too big or too loud.

In How I built this blog, explained like you’re five I said Astro has “islands”, and that later posts would use them for interactive demos. This is that later post.

What’s a microanimation?

Think of the little “click” a light switch makes when you flip it. It tells you something happened.

Microanimations are the website version: a button that squishes when you press it, a counter that ticks up, a heart that pops when you like something.

Good ones give feedback, point your attention somewhere, or just add a bit of delight. Bad ones get in the way, make you wait, or are the only way to find out something important.

What’s an island? (a quick recap)

Most of this page is like a printed poster: it’s finished and doesn’t change. An island is one small TV screen stuck onto the poster. Only that bit runs JavaScript.

The demos below use client:visible, so their JavaScript only loads when you scroll down to them.

Why accessibility matters here

Some people have vestibular disorders: their inner ear and balance system can be set off by movement on a screen. Big or constant motion can cause dizziness, headaches or nausea. For them, a “fun” animation can mean closing the tab and lying down.

At work we do accessibility audits, and motion is something we call out every time. It can be fun to add lots of moving parts, but they pull attention away from your content. So we fix them: pause buttons on everything, and a lot less video and animation.

The Web Content Accessibility Guidelines (WCAG 2.2) have a few rules about motion. In plain words:

  • 2.2.2 Pause, Stop, Hide (Level A): anything that moves on its own for more than 5 seconds needs a way to pause, stop or hide it.
  • 2.3.1 Three Flashes or Below Threshold (Level A): nothing flashes more than 3 times a second.
  • 2.3.3 Animation from Interactions (Level AAA): motion that happens because you did something (scrolled, clicked) can be turned off, unless it’s essential.

A note on the law

In the US, the ADA (Americans with Disabilities Act) doesn’t spell out technical rules for most websites. Courts and the Department of Justice tend to point to WCAG as the yardstick. Government websites are the exception: a 2024 rule names WCAG 2.1 Level AA for state and local governments.

This isn’t legal advice, just why WCAG is the checklist everyone uses.

Step 1:Respect “reduce motion”

Phones and computers have a “reduce motion” setting, and websites can read it. If someone has switched it on, they’ve told you something: please calm down.

In CSS you check it with a media query:

global.css· 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;
  }
}

This site uses Tailwind, which has shortcuts for the same thing: motion-safe: only applies a class when reduce motion is off, and motion-reduce: only when it’s on.

Button.astro· HTML
<button class="transition motion-safe:hover:-translate-y-1 motion-reduce:transition-none">
  Press me
</button>

Reduce doesn’t have to mean remove. Swapping a big slide or bounce for a gentle fade is often enough.

CSS settings don’t reach your JavaScript

A motion-safe: class only controls CSS. Motion driven by JavaScript (an animation library like Motion, setInterval, requestAnimationFrame) ignores it completely. Check the setting in JavaScript too:

Here’s the small React hook both demos use. It asks the browser, and listens in case the setting changes while the page is open:

useReducedMotion.ts· TypeScript
import {useEffect, useState} from 'react'

const QUERY = '(prefers-reduced-motion: reduce)'

/**
 * True when the visitor has asked their device to reduce motion. CSS `motion-safe:` classes
 * handle this on their own, but JavaScript can't see them, so anything JS decides (showing a
 * pause button, starting a timer, an animation library) checks this instead.
 *
 * Starts as false because the server can't know the setting, then corrects itself on the first
 * render in the browser and follows the setting if it changes while the page is open.
 */
export function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false)

  useEffect(() => {
    const media = window.matchMedia(QUERY)
    setReduced(media.matches)
    const onChange = () => setReduced(media.matches)
    media.addEventListener('change', onChange)
    return () => media.removeEventListener('change', onChange)
  }, [])

  return reduced
}

Or use your library’s own option, e.g. Motion’s useReducedMotion hook or <MotionConfig reducedMotion="user">.

Setting up islands

Before any demo can move, the site needs two things: a way to run React components in the browser, and a way to put a demo inside a post that’s written in Sanity.

Step 2:Add React to Astro

Astro only sends JavaScript to the browser when you ask it to, and it does that through a UI framework. I picked React. The docs say to run one command:

PowerShell

    
        
        
        npx astro add react
      
        
        
        npm error code EALLOWSCRIPTS
npm error --allow-scripts is not allowed in project-scoped installs.
      
  

When the helper command fails

astro add installs the packages, edits astro.config.mjs and edits tsconfig.json. Newer npm (version 11) refuses a flag it passes along, so the install fails and it stops. Nothing is broken: do its three jobs yourself.

Job 1, install the packages it printed:

PowerShell

    
        
        
        npm i @astrojs/react react react-dom @types/react @types/react-dom
      
  

Job 2, tell Astro about React in astro.config.mjs:

astro.config.mjs· JavaScript
import react from '@astrojs/react'

export default defineConfig({
  integrations: [react()],
})

Job 3, tell TypeScript how to read React’s JSX in tsconfig.json:

tsconfig.json· JSON
{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "react"
  }
}

Step 3:Make a “demo” block in Sanity

Posts are written in Sanity, but a demo is code, and Sanity can’t store a React component. So the Studio gets a new block with a dropdown of demo names. Sanity only remembers which name you picked; the code itself lives in the website’s repo.

Explain it like I’m five

It’s like a restaurant menu. The menu (Sanity) only lists the dish names. The kitchen (the website) knows the recipes. You point at “floating bubbles” and the kitchen makes it.

studio/schemaTypes/blocks/demo.ts· TypeScript
const DEMOS = [
  {title: 'Floating bubbles', value: 'floating-bubbles'},
  {title: 'Ticking counter', value: 'ticking-counter'},
]

export const demo = defineType({
  name: 'demo',
  type: 'object',
  title: 'Interactive demo',
  fields: [
    defineField({
      name: 'name',
      type: 'string',
      options: {list: DEMOS},
      validation: (rule) => rule.required(),
    }),
    defineField({name: 'caption', type: 'string'}),
  ],
})

Why not paste code into the CMS?

Anything typed into Sanity would run on the site without being reviewed or type-checked. Keeping the code in the repo means it’s versioned, checked and tested like everything else.

Running npm run typegen turns the dropdown into a TypeScript type, 'floating-bubbles' | 'ticking-counter', so a typo in the website code shows up as an error.

Step 4:Turn the name into an island

From Sanity to your screen

  1. Pick a demoSanity stores its name
  2. BuildAstro renders it to HTML
  3. Scroll to itclient:visible wakes it up
  4. It movesReact takes over that box

On the website, one small component reads the name and renders the matching React component with client:visible:

Demo.astro· Astro
---
import type {Props as PortableTextProps} from 'astro-portabletext/types'
import type {Demo} from '../../sanity/types'
import FloatingBubbles from '../demos/FloatingBubbles'
import TickingCounter from '../demos/TickingCounter'

type Props = PortableTextProps<Demo & {_key: string}>

const {node} = Astro.props

/*
 * The islands. Each demo is rendered to HTML at build time like the rest of the page, then
 * `client:visible` loads its JavaScript only when it scrolls into view. Posts without a demo
 * ship no React at all.
 *
 * Astro has to see `client:visible` on a component it can trace to an import, so each demo gets
 * its own line instead of a lookup like `const Component = demos[node.name]`.
 */
---

<figure class="not-prose my-10">
  {node.name === 'floating-bubbles' && <FloatingBubbles client:visible />}
  {node.name === 'ticking-counter' && <TickingCounter client:visible />}
  {node.caption && <figcaption class="mt-3 text-center text-sm text-muted">{node.caption}</figcaption>}
</figure>

A lookup table won’t work here

My first idea was const Component = demos[node.name] and then <Component client:visible />. Astro needs to see exactly which import a client:* component comes from while it builds, so it can bundle that component’s JavaScript. So each demo gets its own line instead.

What it costs: a page with a demo loads about 70 KB of React (gzipped), plus about 1 KB per demo. A post without a demo loads none of it.

Demo 1: Floating bubbles (decorative)

These bubbles are just for fun. Screen readers skip them, and they never get in the way.

Decorative circles that drift slowly in the background. Pure decoration, so:

  • aria-hidden="true" on the bubbles so screen readers skip them.
  • pointer-events: none so they never block a click.
  • With reduce motion on, the bubbles stay still.
  • They float forever, so there’s a pause button (that’s rule 2.2.2).

Each demo gets its own pause button, so it works on its own wherever it’s placed.

FloatingBubbles.tsx· TSX
import {useState, type CSSProperties} from 'react'
import {PauseButton} from './PauseButton'
import {useReducedMotion} from './useReducedMotion'

/** Position, size and drift for each bubble. Varied durations and delays keep them out of sync. */
const BUBBLES = [
  {
    left: '8%',
    top: '55%',
    size: 5,
    x: '1.5rem',
    y: '-2.5rem',
    duration: 9,
    delay: 0,
    color: 'var(--color-shoe-blue)',
  },
  {
    left: '22%',
    top: '12%',
    size: 3,
    x: '-1rem',
    y: '2rem',
    duration: 11,
    delay: -3,
    color: 'var(--color-cyber)',
  },
  {
    left: '45%',
    top: '60%',
    size: 7,
    x: '2rem',
    y: '-1.5rem',
    duration: 13,
    delay: -6,
    color: 'var(--color-periwinkle)',
  },
  {
    left: '68%',
    top: '18%',
    size: 4,
    x: '-1.5rem',
    y: '2.5rem',
    duration: 10,
    delay: -2,
    color: 'var(--color-shoe-blue)',
  },
  {
    left: '85%',
    top: '58%',
    size: 6,
    x: '-2rem',
    y: '-2rem',
    duration: 12,
    delay: -8,
    color: 'var(--color-cyber)',
  },
]

/**
 * Decorative only, so it follows three rules:
 *  - `aria-hidden` on the bubbles: screen readers skip decoration
 *  - `pointer-events-none`: a bubble can never sit on top of something you want to click
 *  - `motion-safe:` on the animation: with reduce motion on, the bubbles simply stay still
 *
 * The motion is plain CSS. React's only job is the pause button: it flips `animation-play-state`,
 * so a paused bubble freezes where it is instead of jumping back to the start.
 */
export default function FloatingBubbles() {
  const [paused, setPaused] = useState(false)
  const reduced = useReducedMotion()

  return (
    <div className="relative flex min-h-56 flex-col items-center justify-center gap-4 overflow-hidden rounded-2xl border-2 border-fg bg-surface p-6 text-center">
      <div aria-hidden="true" className="pointer-events-none absolute inset-0">
        {BUBBLES.map((b, i) => (
          <span
            key={i}
            className="absolute rounded-full opacity-70 motion-safe:animate-drift"
            style={
              {
                left: b.left,
                top: b.top,
                width: `${b.size}rem`,
                height: `${b.size}rem`,
                background: b.color,
                animationDuration: `${b.duration}s`,
                animationDelay: `${b.delay}s`,
                animationPlayState: paused ? 'paused' : 'running',
                '--drift-x': b.x,
                '--drift-y': b.y,
              } as CSSProperties
            }
          />
        ))}
      </div>

      {/* A solid card, so text contrast never depends on which bubble happens to drift behind it. */}
      <p className="relative max-w-xs rounded-xl border-2 border-fg bg-bg px-4 py-3 font-display text-lg leading-snug font-extrabold">
        These bubbles are just for fun. Screen readers skip them, and they never get in the way.
      </p>
      {/* Nothing moves with reduce motion on, so there's nothing to pause. */}
      {reduced ? (
        <p className="relative rounded-lg bg-bg px-3 py-1 text-sm text-muted">
          Reduce motion is on, so the bubbles are staying still.
        </p>
      ) : (
        <PauseButton paused={paused} onToggle={() => setPaused((p) => !p)} label="bubbles" />
      )}
    </div>
  )
}

Demo 2: A ticking counter (information)

Time since you opened this page

You’ve been on this page for less than a minute.

A “time since…” counter with flipping digits. This one carries actual information, so screen readers need to get it too:

  • The visible flipping digits are aria-hidden="true".
  • A separate, visually hidden line with role="status" and aria-live="polite" holds the same information as plain text.
  • With reduce motion on, the digits change without flipping.

Don’t make the screen reader talk non-stop

Update the screen-reader text once a minute, not every second. Otherwise it announces the new number every single second and nobody can use the page.

Clean up your timers

Always clear the interval when the component goes away (clearInterval in the cleanup function). If you don’t, timers pile up in the background.

TickingCounter.tsx· TSX
import {useEffect, useState} from 'react'
import {PauseButton} from './PauseButton'

const pad = (n: number) => String(n).padStart(2, '0')

function minutesText(minutes: number) {
  if (minutes === 0) return 'You’ve been on this page for less than a minute.'
  return `You’ve been on this page for ${minutes} minute${minutes === 1 ? '' : 's'}.`
}

/**
 * A "time since you arrived" counter. Unlike the bubbles it carries information, so:
 *  - the flipping digits are `aria-hidden` (a screen reader would read "0 0 colon 0 1…")
 *  - a visually hidden live region says the same thing in words, but only once a minute,
 *    otherwise it would interrupt every second
 *  - the flip is `motion-safe:`, so with reduce motion on the digits just change
 *  - it updates on its own forever, so it gets a pause button (WCAG 2.2.2)
 */
export default function TickingCounter() {
  const [start] = useState(() => Date.now())
  const [now, setNow] = useState(start)
  const [paused, setPaused] = useState(false)

  useEffect(() => {
    if (paused) return
    setNow(Date.now()) // catch up straight away after un-pausing
    const id = setInterval(() => setNow(Date.now()), 1000)
    // Cleanup runs on pause and when the island goes away; without it, timers pile up.
    return () => clearInterval(id)
  }, [paused])

  const elapsed = Math.floor((now - start) / 1000)
  const time = `${pad(Math.floor(elapsed / 3600))}:${pad(Math.floor(elapsed / 60) % 60)}:${pad(elapsed % 60)}`
  const minutes = Math.floor(elapsed / 60)

  return (
    <div className="flex flex-col items-center gap-4 rounded-2xl border-2 border-fg bg-surface p-6 text-center">
      <p className="font-display text-lg font-extrabold">Time since you opened this page</p>

      <div aria-hidden="true" className="flex gap-1 font-mono text-4xl font-bold [perspective:20rem]">
        {time.split('').map((char, i) =>
          char === ':' ? (
            <span key={i} className="px-0.5">
              :
            </span>
          ) : (
            <span
              // A new key when the digit changes makes React swap in a fresh element, which
              // replays the CSS flip. Unchanged digits keep their key and stay put.
              key={`${i}-${char}`}
              className="inline-block w-[1.3ch] rounded-lg border-2 border-fg bg-bg py-1 motion-safe:animate-flip"
            >
              {char}
            </span>
          ),
        )}
      </div>

      <p role="status" aria-live="polite" className="sr-only">
        {minutesText(minutes)}
      </p>

      <PauseButton paused={paused} onToggle={() => setPaused((p) => !p)} label="counter" />
    </div>
  )
}

How to test

Step 5:Turn on reduce motion

  • Windows: Settings → Accessibility → Visual effects → Animation effects (turn off).
  • macOS: System Settings → Accessibility → Display → Reduce motion.

Reload the page and check every animation calms down or stops.

Step 6:Fake it in DevTools

No need to change your whole computer. In Chrome DevTools, open the Rendering panel and set “Emulate CSS media feature prefers-reduced-motion” to reduce.

Step 7:Listen with a screen reader

Use NVDA (Windows) or VoiceOver (Mac) on the counter. You should hear the calm text update, not a stream of numbers.

What I’d tell past me

  • CSS is magical. It can do way more than colour backgrounds and text. Both demos in this post are plain CSS animations.
  • Start simple (KISS principle). Slow it down. Make it small.