Siteplane UI

Motion

Animate items that appear, leave, move or replace one another, and numbers that change, with no animation code.

Markdown
  • Review the launch checklist
  • Send the invoice
  • Write the release notes
"use client";

import { PlusIcon, XIcon } from "lucide-react";
import { useRef, useState } from "react";
import { Button } from "@/siteplane/components/button";
import { Card } from "@/siteplane/components/card";
import { MotionItem, MotionList } from "@/siteplane/components/motion";

type Task = { id: number; title: string };

const titles = [
  "Review the launch checklist",
  "Send the invoice",
  "Write the release notes",
  "Book the team offsite",
  "Update the pricing copy",
];

export default function Example() {
  const [tasks, setTasks] = useState<Task[]>(() =>
    titles.slice(0, 3).map((title, index) => ({ id: index + 1, title })),
  );
  const nextId = useRef(4);

  const add = () => {
    const id = nextId.current++;
    setTasks((current) => [
      { id, title: titles[(id - 1) % titles.length]! },
      ...current,
    ]);
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Button className="justify-self-start" onClick={add} size="sm">
        <PlusIcon /> Add task
      </Button>
      <MotionList aria-label="Tasks" gap={2}>
        {tasks.map((task) => (
          <MotionItem key={task.id}>
            <Card
              className="flex-row items-center justify-between gap-3 p-3"
              variant="subtle"
            >
              <span className="truncate text-sm">{task.title}</span>
              <Button
                aria-label={`Remove ${task.title}`}
                onClick={() =>
                  setTasks((current) =>
                    current.filter((item) => item.id !== task.id),
                  )
                }
                shape="square"
                size="sm"
                variant="ghost"
              >
                <XIcon />
              </Button>
            </Card>
          </MotionItem>
        ))}
      </MotionList>
    </div>
  );
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/motion

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

pnpm dlx shadcn@4.18.0 add @siteplane/button @siteplane/card @siteplane/alert @siteplane/input @siteplane/separator @siteplane/toggle-group

Usage

import { MotionItem, MotionList } from "@/siteplane/components/motion";

const tasks = [
  { id: 1, title: "Review the launch checklist" },
  { id: 2, title: "Send the invoice" },
];

<MotionList aria-label="Tasks" gap={2}>
  {tasks.map((task) => (
    <MotionItem key={task.id}>{task.title}</MotionItem>
  ))}
</MotionList>;

Change the array and the list animates. A new item expands and fades in, and a removed item fades out and collapses while the items around them glide to their new place.

import { MotionNumber } from "@/siteplane/components/motion";

<MotionNumber
  format={{ currency: "USD", maximumFractionDigits: 0, style: "currency" }}
  value={24960}
/>;

Change value and the digits roll to the new figure.

Composition

MotionList
└── MotionItem            one per entry, with a stable key
MotionReveal              one block, shown with open
MotionSwap                content keyed by swapKey
MotionNumber              a figure that rolls to its new value
useTweenedValues          numbers that move toward a target, for chart geometry
useTweenedRows            keyed rows whose numbers come, move and go
useFollowValue            a number that glides after a moving target

Add, remove and reorder

MotionList renders a ul and MotionItem an li; pass render to use another element, or a component that passes its ref on to its element. Give every item a stable key such as an id, never the index: the key tells the list which item has left. Sorting or shuffling the array makes each item glide to its new place.

Set the space between items with gap, in steps of the spacing scale. It is between items only: none above the first item or below the last. It opens with an item that is added after another and closes when an item becomes the last, together with that item’s own expand or collapse. A CSS gap on the list would leave a hole that closes only after the item has gone.

  • Pricing copy
  • Launch checklist
  • Team offsite
  • Invoice
  • Release notes
"use client";

import { ArrowDownAZIcon, ShuffleIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/siteplane/components/button";
import { Card } from "@/siteplane/components/card";
import { MotionItem, MotionList } from "@/siteplane/components/motion";

const initial = [
  { id: 1, title: "Pricing copy" },
  { id: 2, title: "Launch checklist" },
  { id: 3, title: "Team offsite" },
  { id: 4, title: "Invoice" },
  { id: 5, title: "Release notes" },
];

export default function Example() {
  const [tasks, setTasks] = useState(initial);

  return (
    <div className="grid w-full max-w-md gap-4">
      <div className="flex gap-2">
        <Button
          onClick={() =>
            setTasks((current) =>
              [...current].sort((a, b) => a.title.localeCompare(b.title)),
            )
          }
          size="sm"
          variant="outline"
        >
          <ArrowDownAZIcon /> Sort A–Z
        </Button>
        <Button
          onClick={() =>
            setTasks((current) => [...current].sort(() => Math.random() - 0.5))
          }
          size="sm"
          variant="outline"
        >
          <ShuffleIcon /> Shuffle
        </Button>
      </div>
      <MotionList aria-label="Tasks" gap={2}>
        {tasks.map((task) => (
          <MotionItem key={task.id}>
            <Card className="p-3" variant="subtle">
              <span className="text-sm">{task.title}</span>
            </Card>
          </MotionItem>
        ))}
      </MotionList>
    </div>
  );
}

Dividers between items

separator draws a divider, or a line that connects the items, in the space between two items. It appears and disappears with that space. Position it against the item (the item needs to be relative) or lay it out inside the space.

  • Review the launch checklist
  • Send the invoice
  • Write the release notes
"use client";

import { PlusIcon, XIcon } from "lucide-react";
import { useRef, useState } from "react";
import { Button } from "@/siteplane/components/button";
import { MotionItem, MotionList } from "@/siteplane/components/motion";
import { Separator } from "@/siteplane/components/separator";

type Task = { id: number; title: string };

const titles = [
  "Review the launch checklist",
  "Send the invoice",
  "Write the release notes",
  "Book the team offsite",
  "Update the pricing copy",
];

export default function Example() {
  const [tasks, setTasks] = useState<Task[]>(() =>
    titles.slice(0, 3).map((title, index) => ({ id: index + 1, title })),
  );
  const nextId = useRef(4);

  const add = () => {
    const id = nextId.current++;
    setTasks((current) => [
      ...current,
      { id, title: titles[(id - 1) % titles.length]! },
    ]);
  };

  return (
    <div className="grid w-full max-w-md gap-4">
      <Button className="justify-self-start" onClick={add} size="sm">
        <PlusIcon /> Add task
      </Button>
      <MotionList
        aria-label="Tasks"
        gap={4}
        separator={
          <Separator
            className="absolute inset-x-0 bottom-2"
            role="presentation"
          />
        }
      >
        {tasks.map((task) => (
          <MotionItem className="relative" key={task.id}>
            <div className="flex items-center justify-between gap-3">
              <span className="truncate text-sm">{task.title}</span>
              <Button
                aria-label={`Remove ${task.title}`}
                onClick={() =>
                  setTasks((current) =>
                    current.filter((item) => item.id !== task.id),
                  )
                }
                shape="square"
                size="sm"
                variant="ghost"
              >
                <XIcon />
              </Button>
            </div>
          </MotionItem>
        ))}
      </MotionList>
    </div>
  );
}

Wrapping sets

Use layout="flow" for chips, tags and tiles that wrap. A set item fades and scales slightly, and its siblings glide to their new place by position, and the height of the list follows when a row appears or goes, so what is below it glides instead of jumping. Lay the list out and space it with your own classes, such as flex flex-wrap gap-2. A list that is display: contents has no height of its own: pass the element that wraps its items as container.

"use client";

import { PlusIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/siteplane/components/button";
import { MotionItem, MotionList } from "@/siteplane/components/motion";

const topics = ["Design", "Research", "Launch", "Pricing", "Docs", "Support"];

export default function Example() {
  const [chips, setChips] = useState(topics.slice(0, 4));
  const unused = topics.filter((topic) => !chips.includes(topic));

  return (
    <div className="grid w-full max-w-md gap-4">
      <Button
        className="justify-self-start"
        disabled={unused.length === 0}
        onClick={() => setChips((current) => [...current, unused[0]!])}
        size="sm"
      >
        <PlusIcon /> Add topic
      </Button>
      <MotionList
        aria-label="Topics"
        className="flex flex-wrap gap-2"
        layout="flow"
      >
        {chips.map((chip) => (
          <MotionItem key={chip}>
            <Button
              aria-label={`Remove ${chip}`}
              onClick={() =>
                setChips((current) => current.filter((item) => item !== chip))
              }
              size="sm"
              variant="outline"
            >
              {chip}
              <XIcon />
            </Button>
          </MotionItem>
        ))}
      </MotionList>
    </div>
  );
}

Show and hide a block

MotionReveal shows or hides one block with open. It expands with a fade and collapses with a fade. While it is closed it renders nothing, so it adds no space to the layout around it. Put the space above the content as a margin inside the block, so it opens and closes with it.

The content below glides with the message instead of jumping.

"use client";

import { InfoIcon } from "lucide-react";
import { useState } from "react";
import {
  Alert,
  AlertDescription,
  AlertTitle,
} from "@/siteplane/components/alert";
import { Button } from "@/siteplane/components/button";
import { MotionReveal } from "@/siteplane/components/motion";

export default function Example() {
  const [open, setOpen] = useState(false);

  return (
    <div className="w-full max-w-md">
      <Button
        aria-expanded={open}
        onClick={() => setOpen((current) => !current)}
        size="sm"
        variant="outline"
      >
        {open ? "Hide message" : "Show message"}
      </Button>
      <MotionReveal open={open}>
        <Alert className="mt-3" variant="info">
          <InfoIcon aria-hidden="true" />
          <AlertTitle>Draft saved</AlertTitle>
          <AlertDescription>
            Your changes are stored. Publish when you are ready.
          </AlertDescription>
        </Alert>
      </MotionReveal>
      <p className="mt-3 text-muted-foreground text-sm">
        The content below glides with the message instead of jumping.
      </p>
    </div>
  );
}

A field error is the most common use. FieldError reveals its message this way by itself, so the space under the field opens with the message and the form below moves smoothly. Leave the field empty, or type an incomplete address and move on, to see it.

"use client";

import { Field, FieldError, FieldLabel } from "@/siteplane/components/field";
import { Input } from "@/siteplane/components/input";

export default function Example() {
  return (
    <Field className="w-full max-w-sm" name="email" validationMode="onBlur">
      <FieldLabel>Email</FieldLabel>
      <Input placeholder="name@company.com" required type="email" />
      <FieldError>Enter an email address such as name@company.com.</FieldError>
    </Field>
  );
}

Replace content

MotionSwap fades content that replaces other content through, such as the steps of a flow or the states of a panel. Change swapKey with the content: the old content fades out quickly and the new content fades in after it, so the two are never legible on top of each other. The container’s height follows the new content over the whole swap, so nothing below it jumps, and it keeps following content that builds a frame later, such as a collection or an editor.

Step 1 of 3

Name your workspace

Choose a name for the workspace. You can change it later.

"use client";

import { useState } from "react";
import { Button } from "@/siteplane/components/button";
import { Card } from "@/siteplane/components/card";
import { MotionSwap } from "@/siteplane/components/motion";

const steps = [
  {
    body: "Choose a name for the workspace. You can change it later.",
    title: "Name your workspace",
  },
  {
    body: "Invite people by email. Everyone you invite gets editor access, and you can change roles from the members page at any time. People who have not joined yet show as pending until they accept.",
    title: "Invite your team",
  },
  { body: "All set.", title: "Done" },
];

export default function Example() {
  const [step, setStep] = useState(0);
  const current = steps[step]!;

  return (
    <div className="grid w-full max-w-md gap-4">
      <MotionSwap swapKey={step}>
        <Card className="gap-1 p-4" variant="subtle">
          <p className="text-muted-foreground text-xs">
            Step {step + 1} of {steps.length}
          </p>
          <p className="font-medium text-sm">{current.title}</p>
          <p className="text-muted-foreground text-sm">{current.body}</p>
        </Card>
      </MotionSwap>
      <div className="flex gap-2">
        <Button
          disabled={step === 0}
          onClick={() => setStep((value) => value - 1)}
          size="sm"
          variant="outline"
        >
          Back
        </Button>
        <Button
          disabled={step === steps.length - 1}
          onClick={() => setStep((value) => value + 1)}
          size="sm"
        >
          Next
        </Button>
      </div>
    </div>
  );
}

Rolling numbers

MotionNumber shows a figure that changes in place, such as a total or a rate. When value changes, each digit rolls like an odometer wheel to its new digit, upward when the number rises and downward when it falls. Characters that come and go, such as a sign, a separator or a new leading digit, open and close their width, so everything beside them glides. A number is formatted with Intl.NumberFormat: pass format for currency, percent, compact notation, a sign or a unit, and locales for another language. A string is shown as it is written and its digits roll, so a preformatted value works too.

Booking revenue

$24,960

+9.1% on the period before, from 12,480 visitors

"use client";

import { useState } from "react";
import { Card } from "@/siteplane/components/card";
import { MotionNumber } from "@/siteplane/components/motion";
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/siteplane/components/toggle-group";

const periods = [
  { change: 0.024, days: "7", label: "7 days", revenue: 5920, visitors: 3008 },
  {
    change: 0.091,
    days: "30",
    label: "30 days",
    revenue: 24960,
    visitors: 12480,
  },
  {
    change: -0.032,
    days: "90",
    label: "90 days",
    revenue: 76640,
    visitors: 38336,
  },
];

export default function Example() {
  const [days, setDays] = useState("30");
  const period = periods.find((item) => item.days === days)!;

  return (
    <div className="grid w-full max-w-md gap-4">
      <ToggleGroup
        aria-label="Period"
        onValueChange={(next) => {
          if (next[0]) setDays(next[0]);
        }}
        size="sm"
        value={[days]}
        variant="outline"
      >
        {periods.map((item) => (
          <ToggleGroupItem key={item.days} value={item.days}>
            {item.label}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <Card className="gap-1 p-4" variant="subtle">
        <p className="text-muted-foreground text-xs">Booking revenue</p>
        <p className="font-semibold text-3xl">
          <MotionNumber
            format={{
              currency: "USD",
              maximumFractionDigits: 0,
              style: "currency",
            }}
            value={period.revenue}
          />
        </p>
        <p className="text-muted-foreground text-sm">
          <MotionNumber
            format={{
              maximumFractionDigits: 1,
              signDisplay: "always",
              style: "percent",
            }}
            value={period.change}
          />{" "}
          on the period before, from <MotionNumber value={period.visitors} />{" "}
          visitors
        </p>
      </Card>
    </div>
  );
}

The first render and the server show the final text, and nothing animates on mount. A change that arrives while the digits still roll carries on from where they are. MotionNumber sets tabular figures (tabular-nums) itself, so the digits keep their places; a font without tabular figures works too, because the width of each place follows the roll. In right-to-left text a number still reads from left to right: MotionNumber sets dir="ltr" on its element, and the number as a whole sits where the surrounding text puts it.

Chart values

A chart that animates writes no animation loop of its own. useTweenedValues takes the numbers the data says now, such as the heights of bars or the points of a line, and returns the numbers to draw this frame, moving toward each new target with the shared tokens. At rest it returns your array itself, so you can tell that nothing moves, and a fresh array with the same numbers starts nothing. useTweenedValue does the same for one number. useTweenedRows follows keyed rows: a row that is new grows from 0, a row that is gone shrinks to 0 and then leaves the list, which is how the Pie Chart glides.

"use client";

import { ShuffleIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/siteplane/components/button";
import { useTweenedValues } from "@/siteplane/components/motion";

export default function Example() {
  const [values, setValues] = useState([34, 52, 41, 68, 57, 80]);
  // The heights glide to the new values; the chart writes no animation loop.
  const heights = useTweenedValues(values);

  return (
    <div className="grid w-full max-w-xl gap-4">
      <Button
        className="justify-self-start"
        onClick={() =>
          setValues((current) =>
            current.map(() => 15 + Math.round(Math.random() * 75)),
          )
        }
        size="sm"
      >
        <ShuffleIcon /> New data
      </Button>
      <svg
        aria-label="Bookings by week"
        className="h-40 w-full"
        role="img"
        viewBox="0 0 240 100"
      >
        {heights.map((height, index) => (
          <rect
            fill="var(--chart-brand)"
            height={height}
            key={index}
            rx="3"
            width="28"
            x={index * 40 + 6}
            y={100 - height}
          />
        ))}
      </svg>
    </div>
  );
}

Not every number should grow from 0. Pass absent to useTweenedRows for the number a row has while it is not there: a new row starts from it and a leaving row ends at it. Return the row’s own number, such as its place on an axis, and a row appears and disappears where it stands while the rows that stay glide to their new place; the Bar Chart places its categories this way. With keys, useTweenedValues takes starts instead: one number per value, in the order of the target, where a value with a new key starts.

Follow motion

useFollowValue makes a number glide after a target that moves, such as the crosshair of a chart that follows the pointer from one item to the next. It returns a motion value: bind it to a followed element and the element moves frame by frame while your component does not render again. The glide is a critically damped spring, so it never overshoots, and when the target changes on the way the value keeps its speed instead of stopping at each item. At rest, on the server and in the first render, the value is the target itself.

import { followed, useFollowValue } from "@/siteplane/components/motion";

function Crosshair({ height, x }: { height: number; x: number }) {
  const at = useFollowValue(x);
  return (
    <followed.line stroke="currentColor" x1={at} x2={at} y1={0} y2={height} />
  );
}

A glide takes --motion-duration-follow (280 ms) by default. A card that trails the mark, as the tooltip of a chart does, passes duration: FOLLOW_TRAIL_DURATION, the --motion-duration-follow-trail token (520 ms), so it visibly lags behind. Set active to false while the mark is hidden: it then sits at its target, appears in its place when it is shown again and glides on from there.

useFollowPoint follows an x and a y. useFollowDerived makes a value that moves with another, such as the height of a dot that rides a curve while its x glides. A part that has to pass the numbers on as props, such as the anchor of a floating panel, uses useFollowFrames to render again on each frame in which they move.

Reduced motion

Every part is instant when the system asks for reduced motion, when html carries data-motion-reduce="true", or inside SiteplaneMotionProvider preset="reduced". Items then appear and disappear in one step, in the same frame; the final state is the same. A number shows its new value at once, the value tweens return their targets, and a follower jumps to its target. The parts render the same markup on the server and while hydrating, and follow the preference right after, so reduced motion never causes a hydration mismatch.

API reference

Component properties
PropTypeDefaultDescription
MotionList.layout"stack" | "flow""stack"Stack is for vertical lists: items expand and collapse. Flow is for wrapping sets: items fade and scale, siblings glide by position.
MotionList.gapnumberunsetSpace between the items of a stack, in steps of the spacing scale (3 is 12 px). None above the first or below the last item; it opens and closes with the items.
MotionList.separatorReactNodeunsetDrawn in the space between two items, such as a divider or a connecting line, and shown with it. Needs gap.
MotionList.containerRefObject<HTMLElement>the listFlow only: the element whose height follows when a row of items appears or goes. Pass the element that wraps the items when the list is display: contents.
MotionList.renderReactElement | render functionulReplaces the list element.
MotionItem.keyReact.KeyrequiredA stable key, such as an id. The key tells the list which item has left.
MotionItem.renderReactElementliReplaces the item element with an element or a component element that passes its ref on. It is the clipping box while the item changes: give it no padding or border.
MotionReveal.openbooleanrequiredShows the block. Closing collapses it; nothing is rendered while it is closed.
MotionReveal.renderReactElementdivReplaces the block element. It is the clipping box while the block changes: give it no padding or border.
MotionSwap.swapKeyReact.KeyrequiredIdentifies the content. A new key fades the old content out and the new content in after it.
MotionSwap.renderReactElement | render functiondivReplaces the container element.
MotionNumber.valuenumber | stringrequiredThe value to show. A number is formatted with locales and format; a string is shown as it is and its digits roll.
MotionNumber.localesIntl.LocalesArgument"en-US"The locale a number is formatted in, as Intl.NumberFormat takes it. Fixed by default, so the server and the client always agree.
MotionNumber.formatIntl.NumberFormatOptionsunsetOptions for a number: currency, percent, compact notation, sign display, units.
MotionNumber.renderReactElement | render functionspanReplaces the element.
useTweenedValues(target: readonly number[], options?: TweenOptions) => readonly number[]noneMoves a list of numbers toward its target and returns what to draw this frame; the target itself at rest. A list that changes its length shows at once unless options.keys name the values.
useTweenedValue(target: number, options?: { container }) => numbernoneThe same for one number.
useTweenedRows(rows, read: (row) => number, options?: { container, absent }) => TweenedRow[]noneMoves the number of each keyed row (rows have an id) toward the one read gives. A new row starts from its absent number; a row that is gone moves to it and then leaves. Returns the rows in order with their value so far and whether they are leaving.
useTweenedRows.absent(row) => number0The number a row has while it is not there: where a new row starts and where a leaving row ends. Return the row’s own number to let it appear and disappear where it stands.
TweenOptions.containerRefObject<Element>document rootThe element whose motion tokens set the timing: the chart.
TweenOptions.keysreadonly string[]unsetNames the values when their number changes, so each follows its own key; a value with a new key starts at 0 or at its number in starts.
TweenOptions.startsreadonly number[]0 for eachWhere each value with a new key starts, in the order of the target. A value that should appear where it is starts at its own target.
useFollowValue(target: number, options?: FollowValueOptions) => MotionValue<number>noneGlides a number after its target with the follow spring and returns it as a motion value to bind to a followed element. At rest it is the target itself.
useFollowPoint(target: { x: number; y: number }, options?: FollowValueOptions) => { x, y }noneThe same for a point: two motion values.
useFollowDerived(source: MotionValue<number>, derive: (value: number) => number) => MotionValue<number>noneA motion value that is derive of the source and moves with it. A derive that changes while the source stands still moves it too.
useFollowFrames(values: readonly MotionValue<number>[]) => voidnoneRenders the component again on every frame in which one of the values moves, for a part that has to pass the numbers on as props. Bind the values to an element instead wherever you can.
followed{ circle, div, line, path, rect }noneMotion elements that take motion values as attributes or style. A path has no position attribute: style={{ x, y }} translates it.
FollowValueOptions.duration`--motion-duration-${string}`FOLLOW_DURATIONThe token that sets how long a glide takes. Pass FOLLOW_TRAIL_DURATION for a card that trails a mark.
FollowValueOptions.activebooleantrueWhether the follower is in use. One that is not sits at its target; when it becomes active it appears in its place and glides on from there.
FollowValueOptions.containerRefObject<Element>document rootThe element whose motion tokens set the timing: the chart.
FOLLOW_DURATION"--motion-duration-follow"280msThe token of a mark that follows the pointer, such as a crosshair, a dot or an axis label.
FOLLOW_TRAIL_DURATION"--motion-duration-follow-trail"520msThe token of a card that trails the marks, so it visibly lags behind them.
followSpring(seconds: number) => FollowSpringnoneThe spring of a glide that takes about seconds, as options for Motion’s animate: critically damped, so it never overshoots, and it keeps the speed of a value that is sent to a new target on the way.
followMode({ active, reduced, seconds, wasActive }) => FollowModenoneglide while the follower is active and was active before, motion is not reduced and the token gives it time; jump otherwise.

All parts pass the remaining props to their element. Durations and easings come from the shared motion tokens: presence uses --motion-duration-expand and --motion-ease-standard. A swap takes --motion-duration-base in all: the old content fades out within --motion-duration-micro (--motion-ease-soft-in-out), the new content fades in during the last --motion-duration-micro (--motion-ease-standard), and the height follows over the whole swap. Flow items scale from --motion-presence-scale. Rolling numbers and chart values take --motion-duration-value (380 ms by default, the same as --motion-duration-linger) with --motion-ease-standard. Followers take --motion-duration-follow (280 ms) or --motion-duration-follow-trail (520 ms) and no easing token: the spring sets its own curve. The tokens are read when an animation starts, so a token set on a container changes the motion inside it. The browser plays the motion: items and reveals as Web Animations, the glide of a list as a translate on the compositor, and a height that follows as a CSS transition. Each starts in the frame after the one that shows the change, so a slow first frame does not cut off the start of the motion. Each part exports its props type, such as MotionListProps or MotionNumberProps, and the hooks export TweenOptions, TweenedRow, FollowValueOptions, FollowMode and FollowSpring.

Read the installed source.

Usage notes

A leaving item is inert and hidden from assistive technology while it fades, so focus inside it is lost when it leaves. Move focus to a neighboring item before removing one.

An item, a reveal or a swap container clips its content only while it changes height. At rest nothing is clipped, so focus rings and shadows at the edge stay whole.

A MotionNumber is read once, as its final text, and the rolling glyphs are hidden from assistive technology. It sets no live region, so a change is not announced by itself: put aria-live="polite" on the element around it when it should be. Only the Latin digits of a string roll; any other character fades in and out. A number without locales is formatted in en-US, so pass the locale of your page for another language.

A list measures positions only when its keys change, so it can re-render often, for example while progress updates. A leaving item stays right after the item it followed until it has collapsed, so adding an item before it never makes it glide across a neighbor. Items must be direct children of the list: a fragment around several items counts as one. For several hundred rows, virtualize the list instead.