Skip to contentVibraUI
Utilities

Motion stage

A stage for looping illustrations: they run only on screen, in a tab in front and for a reader who has not asked for less motion, and a Pause motion toggle holds them still.

What moves on the stage keys its animations to it: motion-safe:in-data-[playback=running]:animate-[…]. The stage sets data-playback to running only while the reader has not asked for less motion — the OS setting, or a [data-motion="reduced"] above it, read through useReducedMotion — while it is on screen (an IntersectionObserver) and while the tab is in front (visibilitychange); otherwise paused, or still under reduced motion. So off screen and in a background tab the animations are removed rather than paused and cost nothing, and a loop drawn so that it starts and ends on its still frame never jumps when it comes back. In CSS it also stops every animation inside it under either signal, so nothing moves before the first effect has read them, and it renders paused from the server. The toggle is the kit's Toggle: a button named "Pause motion" pressed or not, with aria-pressed saying which, that holds everything still until pressed again — the mechanism WCAG 2.2.2 asks of anything that moves for more than five seconds — and is gone under reduced motion. Pass keyframes and the stylesheet is rendered as a <style> React hoists into the head once per href, however many stages share it, from the server as well as the browser, so a component installs with its motion and nothing is added to your CSS; under a style-src policy without 'unsafe-inline', give React the style nonce as a render option.

Install

npx shadcn@latest add @vibra/motion-stage

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
childrenReact.ReactNode—What moves: anything whose animations key on in-data-[playback=running].
labelstring"Pause motion"The toggle's name, the same whether or not it is pressed; pressed means held still.
keyframes{ href: string; css: string }—A stylesheet the children animate with, hoisted into the document head once per href.

Dependencies

Source

components/ui/motion-stage.tsx
"use client"

import * as React from "react"
import { PauseIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { Toggle } from "@/components/ui/toggle"

/**
 * Where the stage is: `running`; `paused` — allowed to move but off screen, in
 * a background tab or held by the reader; or `still` — the reader asked for
 * less motion, so nothing on it will move.
 */
export type MotionPlayback = "running" | "paused" | "still"

/**
 * A stylesheet of keyframes for what moves on the stage, hoisted into the
 * document head once per `href`. Under a `style-src` policy without
 * `'unsafe-inline'`, give React the style nonce as a render option — it puts
 * it on every stylesheet it hoists; without it the sheet is blocked, and every
 * picture simply shows its final frame.
 */
export type MotionKeyframes = {
  /** Names the stylesheet: two stages that pass the same `href` share one copy of it. */
  href: string
  css: string
}

export type MotionStageProps = Omit<React.ComponentProps<"div">, "children"> & {
  children: React.ReactNode
  /** The toggle's name, the same pressed or not: pressed means held still. */
  label?: string
  keyframes?: MotionKeyframes
}

function subscribeVisibility(onChange: () => void) {
  document.addEventListener("visibilitychange", onChange)
  return () => document.removeEventListener("visibilitychange", onChange)
}
const pageVisible = () => document.visibilityState !== "hidden"
const visibleOnServer = () => true

/**
 * A stage for looping illustrations, and the toggle that holds them still.
 *
 * It runs only while three things hold: the reader has not asked for less
 * motion — by the OS setting, or a `[data-motion="reduced"]` above it — it is
 * on screen, and the tab is in front. `data-playback` says which, and what
 * moves on it keys its animations to a running stage, e.g.
 * `motion-safe:in-data-[playback=running]:animate-[spin_4s_linear_infinite]`,
 * so anywhere else they are removed, not merely paused, and cost nothing. The
 * stage also stops every animation inside it in CSS under either signal, so
 * nothing moves before the first effect has read them.
 *
 * The toggle is the kit's Toggle, a real button named "Pause motion" whether
 * or not it is pressed — `aria-pressed` says which — and pressed, it holds
 * everything still until pressed again (WCAG 2.2.2). Under reduced motion it
 * is gone, since there is nothing to hold.
 */
function MotionStage({ className, children, label = "Pause motion", keyframes, ...props }: MotionStageProps) {
  const stage = React.useRef<HTMLDivElement>(null)
  const reduced = useReducedMotion(stage)
  const visible = React.useSyncExternalStore(subscribeVisibility, pageVisible, visibleOnServer)
  const [inView, setInView] = React.useState(false)
  const [held, setHeld] = React.useState(false)

  React.useEffect(() => {
    const node = stage.current
    if (!node || reduced || typeof IntersectionObserver !== "function") return
    // A report from an observer this effect has let go of is dropped: the
    // stage may have gone still in between.
    let live = true
    const observer = new IntersectionObserver((entries) => {
      if (live) setInView(entries.some((entry) => entry.isIntersecting))
    })
    observer.observe(node)
    return () => {
      live = false
      observer.disconnect()
    }
  }, [reduced])

  const playback: MotionPlayback = reduced ? "still" : held || !inView || !visible ? "paused" : "running"

  return (
    <div
      ref={stage}
      data-slot="motion-stage"
      data-playback={playback}
      className={cn(
        "flex flex-col gap-3 motion-reduce:**:animate-none! in-data-[motion=reduced]:**:animate-none!",
        className
      )}
      {...props}
    >
      {keyframes ? (
        <style href={keyframes.href} precedence="default">
          {keyframes.css}
        </style>
      ) : null}
      {reduced ? null : (
        <div data-slot="motion-stage-controls" className="flex justify-end motion-reduce:hidden in-data-[motion=reduced]:hidden">
          <Toggle size="sm" pressed={held} onPressedChange={setHeld}>
            <PauseIcon aria-hidden="true" data-icon="inline-start" />
            {label}
          </Toggle>
        </div>
      )}
      {children}
    </div>
  )
}

export { MotionStage }