Skip to content
txbrendan/ui

Motion Timing

beta

Runs a part's motion from script on the theme's motion variables: times, entrances, height glides, swaps, and scroll holds. It shows nothing.

Preview

times

Motion feel

Changes this example only.

  • Fast120 ms
  • Base240 ms
  • Slow320 ms
  • Closing100 ms

Not played yet

on scroll

Each bar grows in the first time it is on screen. Scroll the box to see the rest grow.

  • Mon64%
  • Tue48%
  • Wed72%
  • Thu56%
  • Fri38%
  • Sat22%
  • Sun16%

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/motion-timing

Use it for

  • A motion time in script, like the duration for element.animate(). Read the variable where the part sits and pass it to toMs.
  • Skip the animation when toMs reads 0. Motion off sets every motion time to 0ms.
  • A motion variable read during render. Pass subscribeToMotion to useSyncExternalStore and read the variable in the snapshot, so a change of motion feel applies at once.
  • An entrance that should wait until the part is on screen, like a chart that draws in. useSeen turns true once and stays true.
  • A box whose contents changed. Measure its height first, then call glideHeight in a layout effect.
  • One block swapped for another in place, like a button for its outcome. Use fadeSwap, and glideHeight with swap.
  • A press that closes something above the pressed control. Call holdInView in the press.

Avoid it for

  • Animations a CSS transition can run. A transition reads the motion variables in its classes, as the bar example shows. Motion off sets them to 0ms. Reduced motion keeps them, so a transition that moves or grows something should run only when motion is safe.
  • A media query for reduced motion in script. The variables already change under it, and subscribeToMotion says when to read them again.
  • Knowing whether a part is on screen right now. useSeen never turns back to false.

Props

  • toMs

    Helper. A plain CSS time, like "180ms" or "0.18s", in milliseconds, with its unit in either case. It reads the number at the start and the unit at the end. A missing variable, a bare number, a negative time, or calc() reads as 0.

    (value: string) => number

  • subscribeToMotion

    Helper. A subscribe for useSyncExternalStore. Calls onChange when a data-motion attribute changes anywhere on the page, or when reduced motion turns on or off.

    (onChange: () => void) => () => void

  • glideHeight

    Helper. Glides the element from the height it had, in px, to the height it has now, clipped at its bottom so nothing in it moves. With swap, it waits half the fast time, as fadeSwap's new block does. Runs nothing with motion off or reduced, except that a swap that shrinks holds its height until the old block fades.

    (element: HTMLElement, from: number, speed?: "base" | "slow", swap?: boolean) => Animation | undefined

  • holdInView

    Helper. Keeps the element where it is on screen, by scrolling its scroller each frame, while anything in within moves. A wheel, touch, or key lets go, and a new hold on the same scroller takes over. Returns a stop.

    (element: HTMLElement, within: Element) => () => void

  • fadeSwap

    Helper. Fades the leaving block where it sat on the fast time, and the arriving one in after half that time, so their words never show at once. Returns the leaving fade, or undefined with motion off.

    (leaving: Element, arriving?: Element | null) => Animation | undefined

  • useSeen

    Hook. True once the element has been on screen, and from then on. Pass null to wait. The server and the first render read false. Without IntersectionObserver, it is true at once.

    (element: Element | null) => boolean

Accessibility

  • Motion off sets every motion time to 0ms. Skip the animation when toMs reads 0, and a part follows it with no check of its own.
  • Reduced motion keeps the motion times, so fades still play. It sets --motion-distance to 0px, so read it before you move anything, and fade in place when it is 0.
  • useSeen holds only the entrance. Content is in the page and read by screen readers before it is seen.

Examples

Fade a box in over the base motion time

import { useEffect, useRef, type ReactNode } from "react";

function FadeIn({ children }: { children: ReactNode }) {
  const box = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const element = box.current;
    if (!element || typeof element.animate !== "function") return;
    const style = getComputedStyle(element);
    const duration = toMs(style.getPropertyValue("--motion-duration-base"));
    if (!duration) return;
    const fade = element.animate([{ opacity: 0 }, { opacity: 1 }], { duration, easing: style.getPropertyValue("--motion-ease-out").trim() || "ease-out" });
    return () => fade.cancel();
  }, []);
  return <div ref={box}>{children}</div>;
}

<FadeIn>New items appear here.</FadeIn>

Grow a bar in once it is on screen

import { useState } from "react";

function GrowingBar({ value }: { value: number }) {
  const [track, setTrack] = useState<HTMLDivElement | null>(null);
  const seen = useSeen(track);
  return (
    <div ref={setTrack} className="h-2 rounded-full bg-track">
      <div
        className="h-full rounded-full bg-primary motion-safe:transition-[width] motion-safe:duration-(--motion-duration-slow) motion-safe:ease-(--motion-ease-out)"
        style={{ width: seen ? `${value}%` : "0%" }}
      />
    </div>
  );
}

<GrowingBar value={64} />

Read the base motion time during render, and again when the motion feel changes

import { useState, useSyncExternalStore } from "react";

function MotionReadout() {
  const [element, setElement] = useState<HTMLParagraphElement | null>(null);
  const base = useSyncExternalStore(
    subscribeToMotion,
    () => (element ? toMs(getComputedStyle(element).getPropertyValue("--motion-duration-base")) : 0),
    () => 0,
  );
  return <p ref={setElement}>{base ? `Moves over ${base} ms` : "Motion is off"}</p>;
}

<MotionReadout />

Swap a button for its outcome, and glide the box to the new height

import { useLayoutEffect, useRef, useState } from "react";

function SaveRow() {
  const [saved, setSaved] = useState(false);
  const [leaving, setLeaving] = useState(false);
  const box = useRef<HTMLDivElement>(null);
  const from = useRef(0);
  useLayoutEffect(() => {
    const element = box.current;
    const old = element?.querySelector("[data-leaving]");
    if (!leaving || !element || !old) return;
    glideHeight(element, from.current, "base", true);
    const fade = fadeSwap(old, element.querySelector("p"));
    if (!fade) return setLeaving(false);
    fade.finished.then(() => setLeaving(false), () => {});
    return () => fade.cancel();
  }, [leaving]);
  return (
    <div ref={box} className="relative">
      {(!saved || leaving) && (
        <button
          data-leaving={saved ? "" : undefined}
          inert={saved}
          className={saved ? "absolute top-0" : undefined}
          onClick={() => {
            from.current = box.current?.offsetHeight ?? 0;
            setSaved(true);
            setLeaving(true);
          }}
        >
          Save changes
        </button>
      )}
      {saved && <p>Saved</p>}
    </div>
  );
}

<SaveRow />

Keep Show less under the pointer while the list above it closes

import { useRef, useState } from "react";

function ShowLess({ items }: { items: string[] }) {
  const [open, setOpen] = useState(false);
  const list = useRef<HTMLDivElement>(null);
  return (
    <div ref={list}>
      <ul>
        {(open ? items : items.slice(0, 3)).map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
      <button
        onClick={(event) => {
          if (open && list.current) holdInView(event.currentTarget, list.current);
          setOpen(!open);
        }}
      >
        {open ? "Show less" : "Show more"}
      </button>
    </div>
  );
}

<ShowLess items={["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]} />