Motion Timing
betaRuns 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
- Fast120 ms
- Base240 ms
- Slow320 ms
- Closing100 ms
on scroll
- Mon64%
- Tue48%
- Wed72%
- Thu56%
- Fri38%
- Sat22%
- Sun16%
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. 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
| Prop | Type | Default | What it does |
|---|---|---|---|
| toMs | (value: string) => number | - | 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. |
| subscribeToMotion | (onChange: () => void) => () => void | - | 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. |
| glideHeight | (element: HTMLElement, from: number, speed?: "base" | "slow", swap?: boolean) => Animation | undefined | - | 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. |
| holdInView | (element: HTMLElement, within: Element) => () => void | - | 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. |
| fadeSwap | (leaving: Element, arriving?: Element | null) => Animation | undefined | - | 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. |
| useSeen | (element: Element | null) => boolean | - | 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. |
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"]} />