# Button (Motion)

Button with motion: it presses in on a spring, a new label crosses over while the button glides to its new width, and loading pops the spinner in.

Docs: https://dashral.com/components/button/motion

## Compared with Default

- Default (https://dashral.com/components/button.md): Presses in to 97% in CSS. A new label snaps the width.
- Motion: Presses on a spring, deeper for an icon button and shallower for a wide one. A new label rises in while the width glides to it; the spinner and loadingText pop in.

## Installation

### shadcn CLI

Adds `components/dashral/motion/button.tsx`, the Button it builds on, `motion` and the motion files every motion component shares:

```bash
npx shadcn@latest add https://dashral.com/r/motion-button.json
```

With the `@dashral` registry in `components.json`: `npx shadcn@latest add @dashral/motion-button`

### Manual

Add Button first (https://dashral.com/components/button.md), then install the dependencies:

```bash
npm install lucide-react motion
```

Then copy `components/dashral/lib/motion-tokens.ts`, `components/dashral/lib/motion-reduced.ts`, `components/dashral/lib/motion-odometer.tsx`, `components/dashral/lib/motion-hover.ts`, `components/dashral/lib/motion-field.tsx`, `components/dashral/lib/motion-text-morph.tsx` and `components/dashral/motion/button.tsx` from the Source section below.

## Usage

```tsx
import { Button } from "@/components/dashral/motion/button"

<Button loading={saving} loadingText="Saving…" onClick={save}>
  Save changes
</Button>
```

## Examples

### Variants

How much a button stands out, from solid down to link.

```tsx
import { Button } from "@/components/dashral/motion/button"

export default function ButtonMotionDemo() {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Button>Solid</Button>
      <Button variant="soft">Soft</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="link">Link</Button>
    </div>
  )
}
```

### Styles, sizes and states

Every button does something: Save goes Saving… then Saved, Delete asks once, Add item pops a count in, ••• turns into ×, Copy pops a tick in, Upload works before it says it's done.

```tsx
"use client"

import { useEffect, useRef, useState } from "react"
import { AnimatePresence } from "motion/react"
import { ArrowRight, Check, Copy, Ellipsis, Plus, Upload, X } from "lucide-react"
import { Badge } from "@/components/dashral/motion/badge"
import { Button } from "@/components/dashral/motion/button"

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

/** A state that falls back to its first value a moment after it changes, like a "Copied" that goes back to "Copy". */
function useFlash<T>(rest: T, after = 1600) {
  const [value, setValue] = useState(rest)
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
  useEffect(() => () => clearTimeout(timer.current), [])
  const flash = (next: T) => {
    setValue(next)
    clearTimeout(timer.current)
    timer.current = setTimeout(() => setValue(rest), after)
  }
  return [value, flash, setValue] as const
}

function Group({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <div className="grid justify-items-center gap-3">
      <p className="text-sm text-muted-foreground">{title}</p>
      <div className="flex flex-wrap items-center justify-center gap-2">{children}</div>
    </div>
  )
}

// Every style, size and state, each doing something: Save goes Saving… then Saved, Delete asks once before it
// deletes, Add item pops a count in, Continue's arrow leans the way it goes, ••• turns into ×, the copy buttons pop a
// tick in, and Upload works before it says it's done. Each new label crosses over while the button glides to its width.
export default function ButtonMotionShowcase() {
  const [save, flashSave, setSave] = useFlash<"idle" | "saving" | "saved">("idle")
  const [remove, flashRemove, setRemove] = useFlash<"idle" | "confirm" | "deleting" | "deleted">("idle", 2400)
  const [items, setItems] = useState(0)
  const [more, setMore] = useState(false)
  const [linkCopied, flashLink] = useFlash(false)
  const [iconCopied, flashIcon] = useFlash(false)
  const [upload, flashUpload, setUpload] = useFlash<"idle" | "uploading" | "done">("idle", 2000)

  async function saveChanges() {
    setSave("saving")
    await wait(1000)
    flashSave("saved")
  }

  async function deleteIt() {
    if (remove !== "confirm") return flashRemove("confirm")
    setRemove("deleting")
    await wait(1000)
    flashRemove("deleted")
  }

  async function uploadFile() {
    setUpload("uploading")
    await wait(1400)
    flashUpload("done")
  }

  return (
    <div className="grid w-full gap-6 [&_[data-slot=button]]:rounded-full">
      <Group title="Styles">
        <Button loading={save === "saving"} loadingText="Saving…" onClick={saveChanges}>
          {save === "saved" ? (
            <>
              <Check /> Saved
            </>
          ) : (
            "Save changes"
          )}
        </Button>
        <Button variant="outline">Secondary</Button>
        <Button variant="ghost">Ghost</Button>
        <Button
          variant={remove === "confirm" ? "solid" : "outline"}
          color="destructive"
          disabled={remove === "deleted"}
          loading={remove === "deleting"}
          loadingText="Deleting…"
          onClick={deleteIt}
        >
          {remove === "confirm" ? "Click again to delete" : remove === "deleted" ? "Deleted" : "Delete"}
        </Button>
      </Group>
      <Group title="Icons and sizes">
        <Button variant="outline" onClick={() => setItems(items + 1)}>
          <Plus /> Add item
          <AnimatePresence initial={false}>
            {items > 0 && (
              <Badge variant="soft" className="min-w-5 rounded-full tabular-nums">
                {items}
              </Badge>
            )}
          </AnimatePresence>
        </Button>
        <Button className="group">
          Continue <ArrowRight className="transition-transform duration-(--duration-standard) ease-standard group-hover:translate-x-0.5 rtl:-scale-x-100 rtl:group-hover:-translate-x-0.5" />
        </Button>
        <Button variant="ghost" size="icon" aria-label={more ? "Close menu" : "More"} aria-expanded={more} onClick={() => setMore(!more)}>
          {more ? <X /> : <Ellipsis />}
        </Button>
        <Button variant="outline" size="lg">
          Large
        </Button>
      </Group>
      <Group title="Feedback and states">
        <Button variant="outline" onClick={() => flashLink(true)}>
          {linkCopied ? (
            <>
              <Check className="text-success-text" /> Copied
            </>
          ) : (
            <>
              <Copy /> Copy link
            </>
          )}
        </Button>
        <Button variant="outline" size="icon" aria-label={iconCopied ? "Copied" : "Copy"} onClick={() => flashIcon(true)}>
          {iconCopied ? <Check className="text-success-text" /> : <Copy />}
        </Button>
        <Button variant="outline" loading={upload === "uploading"} loadingText="Uploading…" onClick={uploadFile}>
          {upload === "done" ? (
            <>
              <Check className="text-success-text" /> Uploaded
            </>
          ) : (
            <>
              <Upload /> Upload file
            </>
          )}
        </Button>
        <Button variant="outline" disabled>
          Disabled
        </Button>
      </Group>
    </div>
  )
}
```

### Hold to confirm

holdToConfirm: a click does nothing; held, a fill sweeps across, and when it's full the button pops, the delete (or payment) runs and a toast says so. Let go early and it drains back.

```tsx
"use client"

import { useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { RotateCcw, Send, Trash2 } from "lucide-react"
import { Button } from "@/components/dashral/motion/button"
import { toast } from "@/components/dashral/motion/toast"
import { useReducedMotion } from "@/components/dashral/lib/motion-reduced"
import { duration, ease } from "@/components/dashral/lib/motion-tokens"

// holdToConfirm, for what can't be undone: a click does nothing, holding fills the button, and when it's full it pops
// and runs, a toast saying it's done. Let go early and the fill drains back. Space or Enter hold it from the keyboard.
export default function ButtonMotionHold() {
  const reduced = useReducedMotion()
  const [deleted, setDeleted] = useState(false)
  const [paid, setPaid] = useState(false)

  const remove = () => {
    setDeleted(true)
    toast.success("Acme workspace deleted", { description: "12 dashboards went with it.", action: { label: "Undo", onClick: () => setDeleted(false) } })
  }
  const pay = () => {
    setPaid(true)
    toast.success("Payment sent", { description: "$1,240.00 to Fernpay, from Operating." })
  }

  const swap = {
    initial: reduced ? { opacity: 0 } : { opacity: 0, y: 6 },
    animate: { opacity: 1, y: 0 },
    exit: { opacity: 0, transition: { duration: duration.fast, ease: ease.exit } },
    transition: { duration: duration.standard, ease: ease.enter },
  }

  return (
    <div className="grid w-full max-w-sm gap-3">
      <div className="flex items-center justify-between gap-3 rounded-xl border bg-card p-4">
        <div className="min-w-0">
          <p className="text-sm font-medium">Acme workspace</p>
          <AnimatePresence mode="wait" initial={false}>
            <motion.p key={String(deleted)} {...swap} className="text-xs text-muted-foreground">
              {deleted ? "Deleted, along with 12 dashboards." : "12 dashboards, 8 members."}
            </motion.p>
          </AnimatePresence>
        </div>
        {deleted ? (
          <Button variant="outline" size="sm" onClick={() => setDeleted(false)}>
            <RotateCcw /> Restore
          </Button>
        ) : (
          <Button color="destructive" size="sm" holdToConfirm onClick={remove}>
            <Trash2 /> Hold to delete
          </Button>
        )}
      </div>
      <div className="flex items-center justify-between gap-3 rounded-xl border bg-card p-4">
        <div className="min-w-0">
          <p className="text-sm font-medium">Pay Fernpay invoice</p>
          <AnimatePresence mode="wait" initial={false}>
            <motion.p key={String(paid)} {...swap} className="text-xs text-muted-foreground tabular-nums">
              {paid ? "Paid $1,240.00 from Operating." : "$1,240.00 from Operating"}
            </motion.p>
          </AnimatePresence>
        </div>
        {paid ? (
          <Button variant="outline" size="sm" onClick={() => setPaid(false)}>
            <RotateCcw /> Undo
          </Button>
        ) : (
          <Button size="sm" holdToConfirm={2000} onClick={pay}>
            <Send /> Hold to pay
          </Button>
        )}
      </div>
    </div>
  )
}
```

### Follow

morph: the text label changes letter by letter: Follow becomes Following (and Unfollow under the pointer), the follower count turning on wheels.

```tsx
"use client"

import { useState } from "react"
import { Button } from "@/components/dashral/motion/button"
import { Odometer } from "@/components/dashral/lib/motion-odometer"

const count = new Intl.NumberFormat("en-US")

// A profile card: with morph, Follow changes into Following letter by letter (the letters both have stay, "ing" grows in), and
// under the pointer it morphs again to Unfollow. The follower count turns on its wheels as it changes.
export default function ButtonMotionFollow() {
  const [following, setFollowing] = useState(false)
  const [hovered, setHovered] = useState(false)
  const label = following ? (hovered ? "Unfollow" : "Following") : "Follow"

  return (
    <div className="flex w-full max-w-sm items-center gap-3 rounded-xl border bg-card p-4">
      <span className="grid size-11 shrink-0 place-items-center rounded-full bg-primary/12 text-sm font-medium text-primary-text">LP</span>
      <div className="min-w-0 flex-1">
        <p className="truncate text-sm font-medium">Leo Park</p>
        <p className="text-xs text-muted-foreground tabular-nums">
          <Odometer value={1284 + (following ? 1 : 0)} format={count} className="inline-flex justify-start" /> followers
        </p>
      </div>
      <Button
        morph
        size="sm"
        variant={following ? "outline" : "solid"}
        color={following && hovered ? "destructive" : undefined}
        onPointerEnter={() => setHovered(true)}
        onPointerLeave={() => setHovered(false)}
        onClick={() => setFollowing(!following)}
      >
        {label}
      </Button>
    </div>
  )
}
```

### Hold styles

holdStyle: fill, radial from the press, a ring round the edge, a bar along the foot, or a countdown on odometer wheels. Hold each until it pops.

```tsx
"use client"

import { Archive, CircleDot, Hourglass, LogOut, Trash2 } from "lucide-react"
import { Button } from "@/components/dashral/motion/button"
import { toast } from "@/components/dashral/motion/toast"

const styles = [
  { style: "fill", label: "Delete file", icon: Trash2, variant: "solid", color: "destructive", note: "Sweeps across" },
  { style: "radial", label: "Archive all", icon: Archive, variant: "solid", color: "primary", note: "Grows from your press" },
  { style: "ring", label: "Sign out everywhere", icon: LogOut, variant: "outline", color: "neutral", note: "Traces the edge" },
  { style: "bar", label: "Reset", icon: CircleDot, variant: "soft", color: "warning", note: "A line along the foot" },
  { style: "countdown", label: "Wipe data", icon: Hourglass, variant: "outline", color: "destructive", note: "Seconds left on wheels" },
] as const

// Five ways to show a hold, one per button: hold each until it pops (a toast says so), or let go early and watch it
// drain back. The countdown holds for three seconds so its wheels have time to turn.
export default function ButtonMotionHoldStyles() {
  return (
    <div className="grid w-full max-w-sm gap-2.5">
      {styles.map(({ style, label, icon: Icon, variant, color, note }) => (
        <div key={style} className="flex items-center justify-between gap-3 rounded-lg border bg-card px-3 py-2.5">
          <div className="min-w-0">
            <p className="font-mono text-xs">holdStyle=&quot;{style}&quot;</p>
            <p className="text-xs text-muted-foreground">{note}</p>
          </div>
          <Button
            size="sm"
            variant={variant}
            color={color}
            holdToConfirm={style === "countdown" ? 3000 : true}
            holdStyle={style}
            onClick={() => toast.success(`${label}: done`, { description: `Held to confirm, with holdStyle="${style}".` })}
          >
            <Icon /> {label}
          </Button>
        </div>
      ))}
    </div>
  )
}
```

### A form that saves

Edit the name and press Save: Saving… pops in with the spinner, then Saved rises in as the button glides to its width.

```tsx
"use client"

import { useState } from "react"
import { Check, Save } from "lucide-react"
import { Input } from "@/components/dashral/motion/input"
import { Button } from "@/components/dashral/motion/button"

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

// A form that saves: edit the name and Save wakes up; press it and the spinner pops in with Saving…, then Saved rises
// in as the button glides to its width, and after a moment it's back to Save changes.
export default function ButtonMotionLabel() {
  const [saved, setSaved] = useState("Acme Inc.")
  const [name, setName] = useState(saved)
  const [state, setState] = useState<"idle" | "saving" | "saved">("idle")
  const dirty = name.trim() !== saved

  async function save(event: React.FormEvent) {
    event.preventDefault()
    setState("saving")
    await wait(1000)
    setSaved(name.trim())
    setState("saved")
    await wait(1600)
    setState("idle")
  }

  return (
    <form onSubmit={save} className="grid w-full max-w-sm gap-4 rounded-xl border bg-card p-4">
      <Input label="Workspace name" value={name} onChange={(e) => setName(e.target.value)} />
      <div className="flex justify-end gap-2">
        <Button type="button" variant="ghost" disabled={!dirty || state === "saving"} onClick={() => setName(saved)}>
          Reset
        </Button>
        <Button type="submit" disabled={state === "idle" && !dirty} loading={state === "saving"} loadingText="Saving…">
          {state === "saved" ? (
            <>
              <Check /> Saved
            </>
          ) : (
            <>
              <Save /> Save changes
            </>
          )}
        </Button>
      </div>
    </form>
  )
}
```

### Sending and copying

Send shows Sending… and keeps its width, then the invite drops into the list; Copy link pops a tick in.

```tsx
"use client"

import { useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Check, Copy, Send, X } from "lucide-react"
import { Input } from "@/components/dashral/motion/input"
import { Button } from "@/components/dashral/motion/button"
import { useReducedMotion } from "@/components/dashral/lib/motion-reduced"
import { duration, ease } from "@/components/dashral/lib/motion-tokens"

const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)

// Invite people: Send pops its spinner in with Sending… and keeps its width, then the invite drops into the list below.
// Copy link swaps its icon for a tick with a pop; × folds an invite away.
export default function ButtonMotionLoading() {
  const reduced = useReducedMotion()
  const [email, setEmail] = useState("")
  const [sending, setSending] = useState(false)
  const [copied, setCopied] = useState(false)
  const [invites, setInvites] = useState(["maya@acme.com"])

  async function send(event: React.FormEvent) {
    event.preventDefault()
    if (!isEmail(email)) return
    setSending(true)
    await new Promise((resolve) => setTimeout(resolve, 1100))
    setSending(false)
    setInvites([email.trim(), ...invites.filter((i) => i !== email.trim())])
    setEmail("")
  }

  return (
    <div className="grid w-full max-w-sm gap-3 rounded-xl border bg-card p-4">
      <form onSubmit={send} className="flex gap-2">
        <Input aria-label="Email" type="email" placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} className="flex-1" />
        <Button type="submit" disabled={!isEmail(email)} loading={sending} loadingText="Sending…">
          <Send /> Send
        </Button>
        <Button
          type="button"
          variant="outline"
          size="icon"
          aria-label={copied ? "Copied" : "Copy invite link"}
          onClick={() => {
            setCopied(true)
            setTimeout(() => setCopied(false), 1600)
          }}
        >
          {copied ? <Check /> : <Copy />}
        </Button>
      </form>
      <ul className="grid">
        <AnimatePresence initial={false}>
          {invites.map((invite) => (
            <motion.li
              key={invite}
              initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0, y: -6 }}
              animate={{ opacity: 1, height: "auto", y: 0 }}
              exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
              transition={{ duration: reduced ? 0 : duration.standard, ease: ease.standard }}
              className="overflow-hidden"
            >
              <div className="flex items-center gap-2 py-1.5 text-sm">
                <span className="grid size-7 place-items-center rounded-full bg-primary/15 text-[11px] font-semibold text-primary-text uppercase">{invite[0]}</span>
                <span className="flex-1 truncate">{invite}</span>
                <span className="text-xs text-muted-foreground">Invited</span>
                <Button variant="ghost" size="icon-xs" aria-label={`Cancel the invite to ${invite}`} onClick={() => setInvites(invites.filter((i) => i !== invite))}>
                  <X />
                </Button>
              </div>
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>
    </div>
  )
}
```

## API reference

Every prop of Button (https://dashral.com/components/button.md), and every attribute of the element it renders. What the Motion edition adds is marked (Motion).

### Button

Also takes every prop of [<button>](https://developer.mozilla.org/docs/Web/HTML/Element/button).

| Prop | Type | Default | Description |
|---|---|---|---|
| `holdToConfirm` | `boolean \| number` | `false` | *(Motion)* onClick runs only after a press is held this long: true for 1.2s, or milliseconds. Space and Enter hold it too. |
| `holdStyle` | `"fill" \| "radial" \| "ring" \| "bar" \| "countdown"` | `"fill"` | *(Motion)* How the hold shows its progress: a sweep, a circle from the press, a ring round the edge, a line along the foot, or the seconds left. |
| `morph` | `boolean` | `false` | *(Motion)* A label that's only text changes letter by letter (Follow to Following) instead of crossing over. |
| `variant` | `"solid" \| "soft" \| "outline" \| "ghost" \| "link"` | `"solid"` | How much it stands out. |
| `color` | `"primary" \| "neutral" \| "info" \| "success" \| "warning" \| "destructive" \| "highlight"` | `"primary" (outline, ghost: "neutral")` | What it means. |
| `glow` | `boolean` | `false` | A soft colored shadow under a solid button. |
| `size` | `"xs" \| "sm" \| "default" \| "lg" \| "icon-xs" \| "icon-sm" \| "icon" \| "icon-lg" \| "inline"` | `"default"` | Height and padding; each icon size is square, as tall as the size it's named after; inline has neither, for a link-style button in a sentence. |
| `loading` | `boolean` | `false` | Shows a spinner in place of the label, which stays (hidden) so the button keeps its width, and ignores clicks, but stays focusable. |
| `loadingText` | `ReactNode` | — | Beside the spinner while loading, e.g. "Saving…"; the button is as wide as the wider of it and the label. Not with asChild. |
| `className` | `string` | — | Extra classes, merged with cn() so yours win. |

No asChild: for a link that looks like a button, use the default Button.

## Motion

- Presses in on a spring while held, as deep as its size calls for: an icon button a little more, a wide one a little less.
- A new label fades in rising 4px while the old one fades out; with morph, a label that's only text changes letter by letter instead: the letters both have glide to their new places, new ones rise out of a soft blur and the rest fade (Follow to Following keeps Follow and grows "ing"). Either way the button glides to the new width (--duration-considered, no overshoot) instead of snapping.
- loading pops the spinner, and loadingText, in over the label with a small overshoot; the button keeps its width.
- With holdToConfirm, held, a fill sweeps from start to end at an even pace; full, the button pops (a quick swell and back) and onClick runs, submitting its form if it's a submit button; let go early and the fill drains back on a glide. Space and Enter hold it too; screen readers hear "(press and hold)" after the label; phones tick at the start and buzz when it runs.
- Disabled, loading, or opening a menu, popover or tooltip, it doesn't press in. With reduced motion (the OS's, or data-motion="reduce" on <html>) everything only fades.

## Source

### components/dashral/motion/button.tsx

```tsx
"use client"

import * as React from "react"
import { Loader2 } from "lucide-react"
import { AnimatePresence, animate, motion, useMotionValue, useMotionValueEvent, useTransform, type Variants } from "motion/react"
import { buttonVariants } from "@/components/dashral/button"
import { useLocale } from "@/components/dashral/lib/locale"
import { cn } from "@/components/dashral/lib/utils"

import { Odometer } from "@/components/dashral/lib/motion-odometer"
import { TextMorph } from "@/components/dashral/lib/motion-text-morph"
import { useReducedMotion } from "@/components/dashral/lib/motion-reduced"
import { duration, ease, spring } from "@/components/dashral/lib/motion-tokens"

type StyleProps = Omit<NonNullable<Parameters<typeof buttonVariants>[0]>, "className">

// motion/react gives these its own meaning (drag and animation events), so a plain button's versions are left out.
type NativeProps = Omit<React.ComponentProps<"button">, "color" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart">

export type ButtonProps = NativeProps &
  StyleProps & {
    /** Shows a spinner in place of the label and ignores clicks, without moving focus. */
    loading?: boolean
    /** Beside the spinner while loading, e.g. "Saving…"; without it the spinner shows alone. */
    loadingText?: React.ReactNode
    /**
     * For an action that can't be undone: a click isn't enough, it has to be pressed and held. A fill sweeps across
     * while it's held and runs onClick (and submits its form) once full; let go early and it drains back. true holds
     * for 1.2s, a number for that many milliseconds. Space and Enter hold it from the keyboard.
     */
    holdToConfirm?: boolean | number
    /**
     * How the hold shows its progress: "fill" sweeps across the button; "radial" grows a circle from where it's
     * pressed; "ring" traces the button's edge; "bar" fills a thin line along its foot; "countdown" turns the label
     * into the seconds left, on odometer wheels, over a bar.
     */
    holdStyle?: "fill" | "radial" | "ring" | "bar" | "countdown"
    /**
     * A label that's only text morphs into the next one letter by letter (Follow to Following keeps "Follow" and grows
     * "ing") instead of crossing over. Off by default.
     */
    morph?: boolean
  }

/** A light buzz on phones that have one. */
const buzz = (pattern: number | number[]) => {
  if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate(pattern)
}

/**
 * What the label is, for telling a new label from a re-render of the same one: its text and the names of the elements
 * in it. A new label (or icon) crosses over; the same one with new props stays put.
 */
function labelKey(node: React.ReactNode): string {
  if (node === null || node === undefined || typeof node === "boolean") return ""
  if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") return String(node)
  if (Array.isArray(node)) return node.map(labelKey).join("")
  if (!React.isValidElement(node)) return ""
  const type = node.type as string | { displayName?: string; name?: string }
  const name = typeof type === "string" ? type : (type.displayName ?? type.name ?? "")
  return `<${name}>${labelKey((node.props as { children?: React.ReactNode }).children)}`
}

/** How far it presses in: an icon button a little deeper, a wide one a little less, so each feels like the same push. */
const press: Variants = {
  pressed: (button: React.RefObject<HTMLButtonElement | null>) => {
    const width = button.current?.offsetWidth ?? 0
    return { scale: width > 200 ? 0.985 : width > 0 && width <= 44 ? 0.95 : 0.97, transition: spring.press }
  },
}

/** A layer of the label's grid cell, laid out like the button itself (its gap included). */
const layer = "col-start-1 row-start-1 inline-flex items-center justify-center gap-[inherit]"

/**
 * The kit's Button with motion: the same variants, colors, sizes and props, and it adds what CSS can't do on its own.
 * It presses in on a spring, as deep as its size calls for; a new label crosses over (rising as it fades in), or with
 * morph a text label changes letter by letter (Follow to Following keeps Follow and grows "ing"), while the button
 * glides to its new width instead of snapping; and loading pops the spinner (and loadingText) in over the label,
 * keeping the width. Disabled, loading, reduced motion, or opening a menu or popover (Radix places the layer from the
 * trigger as it opens), it doesn't press in. With holdToConfirm, it asks to be held: a fill sweeps across, and when
 * it's full the button pops and the action runs (phones buzz); let go early and the fill drains back. No asChild: for
 * a link that looks like a button, use the kit's Button.
 */
export function Button({
  className,
  variant,
  color,
  size,
  glow,
  loading = false,
  loadingText,
  holdToConfirm,
  holdStyle = "fill",
  morph = false,
  children,
  onClick,
  onPointerDown,
  onPointerUp,
  onPointerLeave,
  onPointerCancel,
  onKeyDown,
  onKeyUp,
  ref,
  ...props
}: ButtonProps) {
  const reduced = useReducedMotion()
  const { t, locale } = useLocale()
  const button = React.useRef<HTMLButtonElement | null>(null)
  // Its own ref (the press measures the button) and the one it's given (a Radix trigger's, a form library's), both.
  const setRef = React.useCallback(
    (node: HTMLButtonElement | null) => {
      button.current = node
      if (typeof ref === "function") ref(node)
      else if (ref) ref.current = node
    },
    [ref]
  )
  const content = React.useRef<HTMLSpanElement | null>(null)
  const width = useMotionValue<number | "auto">("auto")
  const key = labelKey(children)
  const iconOnly = !/[^\s]/.test(key.replace(/<[^>]*>/g, ""))

  // The width glides only when the label itself changes; anything else that resizes it (a web font arriving, a parent's
  // layout) is followed at once, so nothing wobbles on first paint.
  const gliding = React.useRef(false)
  const lastKey = React.useRef(key)
  React.useLayoutEffect(() => {
    if (lastKey.current === key) return
    lastKey.current = key
    gliding.current = true
  }, [key])
  React.useEffect(() => {
    const node = content.current
    if (!node) return
    const observer = new ResizeObserver(([entry]) => {
      const next = entry?.borderBoxSize[0]?.inlineSize ?? node.offsetWidth
      if (!next) return
      if (!gliding.current || reduced || width.get() === "auto") {
        width.jump(next)
        gliding.current = false
        return
      }
      animate(width, next, { ...spring.glide, onComplete: () => (gliding.current = false) })
    })
    observer.observe(node)
    return () => observer.disconnect()
  }, [reduced, width])

  const popup = props["aria-haspopup"]
  const anchorsLayer = (popup !== undefined && popup !== false && popup !== "false") || props.role === "combobox" || "data-state" in props
  const still = reduced || props.disabled || loading || anchorsLayer

  // Hold to confirm: how full the fill is, and whether the click now coming is the one the hold earned.
  const holdMs = holdToConfirm === true ? 1200 : typeof holdToConfirm === "number" ? holdToConfirm : 0
  const fill = useMotionValue(0)
  const filling = React.useRef<ReturnType<typeof animate> | null>(null)
  const earned = React.useRef(false)
  const holding = React.useRef(false)
  // A hold that ran ends with the pointer being let go, and the browser sends a click for that too: by then the button
  // may already be something else (Restore, Undo), so that one click is swallowed. The next press clears it.
  const spent = React.useRef(false)
  // The countdown's seconds left, in tenths, while a hold runs.
  const [counting, setCounting] = React.useState(false)
  const [left, setLeft] = React.useState(holdMs / 1000)
  useMotionValueEvent(fill, "change", (v) => {
    if (holdStyle !== "countdown") return
    const next = Math.max(0, Math.ceil((1 - v) * holdMs / 100) / 10)
    setLeft((was) => (was === next ? was : next))
  })
  const seconds = React.useMemo(
    () => new Intl.NumberFormat(locale, { style: "unit", unit: "second", unitDisplay: "narrow", minimumFractionDigits: 1, maximumFractionDigits: 1 }),
    [locale]
  )
  const radial = React.useRef<HTMLSpanElement>(null)
  // An empty ring hides: a path drawn to nothing still shows its round cap as a dot.
  const ringShown = useTransform(fill, (v) => (v > 0.005 ? 1 : 0))
  const ring = React.useRef<SVGRectElement>(null)
  const ringTrack = React.useRef<SVGRectElement>(null)
  /** Sizes what the style draws to the button as it is now: the circle around the press, the edge's path. */
  const prepare = (point: { x: number; y: number } | null) => {
    const node = button.current
    if (!node) return
    // Inside the border: the layers sit in the padding box, which is also where the button clips them.
    const w = node.clientWidth
    const h = node.clientHeight
    if (radial.current) {
      const x = point?.x ?? w / 2
      const y = point?.y ?? h / 2
      // Big enough to cover the farthest corner from where it's pressed.
      const r = Math.hypot(Math.max(x, w - x), Math.max(y, h - y))
      Object.assign(radial.current.style, { left: `${x - r}px`, top: `${y - r}px`, width: `${r * 2}px`, height: `${r * 2}px` })
    }
    if (ring.current) {
      const css = getComputedStyle(node)
      // The corner inside the border, then half the stroke in from there, so the line runs evenly all the way round.
      const inner = Math.max(0, (parseFloat(css.borderTopLeftRadius) || 0) - (parseFloat(css.borderTopWidth) || 0))
      const rx = String(Math.max(0, Math.min(inner - 1.5, h / 2 - 1.5)))
      for (const rect of [ring.current, ringTrack.current]) {
        if (!rect) continue
        rect.setAttribute("width", String(Math.max(0, w - 3)))
        rect.setAttribute("height", String(Math.max(0, h - 3)))
        rect.setAttribute("rx", rx)
      }
    }
  }
  const startHold = (touch: boolean, point: { x: number; y: number } | null = null) => {
    if (!holdMs || props.disabled || loading || holding.current) return
    holding.current = true
    prepare(point)
    if (holdStyle === "countdown") {
      setLeft(holdMs / 1000)
      setCounting(true)
    }
    if (touch) buzz(4)
    filling.current?.stop()
    // A fill left full (the button became something else before it drained) starts empty again.
    if (fill.get() >= 1) fill.jump(0)
    filling.current = animate(fill, 1, {
      duration: (holdMs / 1000) * (1 - fill.get()),
      ease: "linear",
      onComplete: () => {
        holding.current = false
        setCounting(false)
        if (touch) buzz(14)
        // Full, it pops: a quick swell and back.
        if (!reduced && button.current) animate(button.current, { scale: [0.97, 1.06, 1] }, { duration: 0.32, ease: ease.standard })
        earned.current = true
        button.current?.click()
        spent.current = true
        filling.current = animate(fill, 0, { duration: duration.considered, ease: ease.standard, delay: 0.15 })
      },
    })
  }
  const stopHold = () => {
    if (!holding.current) return
    holding.current = false
    setCounting(false)
    filling.current?.stop()
    filling.current = animate(fill, 0, spring.glide)
  }
  const enter = reduced ? { opacity: 0 } : iconOnly ? { opacity: 0, scale: 0.8 } : { opacity: 0, y: 4 }

  return (
    <motion.button
      ref={setRef}
      data-slot="button"
      // Tells the kit's styles to leave the press to this component.
      data-press="motion"
      {...props}
      className={buttonVariants({
        variant,
        color,
        size,
        glow,
        // Colors still change in CSS; transform is this component's, so CSS mustn't ease it too.
        className: cn("transition-[color,background-color,border-color,box-shadow,opacity]", holdMs > 0 && "relative overflow-hidden", className),
      })}
      aria-busy={loading || undefined}
      aria-disabled={loading || props["aria-disabled"]}
      // preventDefault also stops a submit button from submitting its form. Held to confirm, only the click the hold
      // earned goes through.
      onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
        if (spent.current) {
          spent.current = false
          return event.preventDefault()
        }
        if (loading || (holdMs && !earned.current)) return event.preventDefault()
        earned.current = false
        onClick?.(event)
      }}
      onPointerDown={(event: React.PointerEvent<HTMLButtonElement>) => {
        spent.current = false
        onPointerDown?.(event)
        if (event.button === 0) {
          const box = event.currentTarget.getBoundingClientRect()
          startHold(event.pointerType === "touch", { x: event.clientX - box.left, y: event.clientY - box.top })
        }
      }}
      onPointerUp={(event: React.PointerEvent<HTMLButtonElement>) => {
        onPointerUp?.(event)
        stopHold()
      }}
      onPointerLeave={(event: React.PointerEvent<HTMLButtonElement>) => {
        onPointerLeave?.(event)
        stopHold()
      }}
      onPointerCancel={(event: React.PointerEvent<HTMLButtonElement>) => {
        onPointerCancel?.(event)
        stopHold()
      }}
      onKeyDown={(event: React.KeyboardEvent<HTMLButtonElement>) => {
        if (!event.repeat) spent.current = false
        onKeyDown?.(event)
        if (holdMs && (event.key === " " || event.key === "Enter")) {
          // The browser's own click on Enter would come before the hold is done.
          event.preventDefault()
          if (!event.repeat) startHold(false)
        }
      }}
      onKeyUp={(event: React.KeyboardEvent<HTMLButtonElement>) => {
        onKeyUp?.(event)
        if (holdMs && (event.key === " " || event.key === "Enter")) {
          event.preventDefault()
          stopHold()
        }
      }}
      custom={button}
      variants={press}
      whileTap={still ? undefined : "pressed"}
      transition={spring.press}
    >
      {/*
        The fill, behind the label, sweeping from the start to the end. Always there, so its drain keeps playing when the
        button turns into something else after a hold (a Restore in its place): taken away, it would stop full.
      */}
      <motion.span
        aria-hidden
        className={cn("pointer-events-none absolute inset-0 origin-left bg-current opacity-15 rtl:origin-right", (!holdMs || holdStyle !== "fill") && "hidden")}
        style={{ scaleX: fill }}
      />
      {/* radial: a circle from where it was pressed, growing to cover the button. */}
      <motion.span
        ref={radial}
        aria-hidden
        className={cn("pointer-events-none absolute rounded-full bg-current opacity-15", (!holdMs || holdStyle !== "radial") && "hidden")}
        style={{ scale: fill }}
      />
      {/* ring: the button's edge, traced from its top-left corner round. */}
      <svg aria-hidden className={cn("pointer-events-none absolute inset-0 size-full", (!holdMs || holdStyle !== "ring") && "hidden")}>
        {/* The path it will take, faint, while it's held; then the line drawn along it. */}
        <motion.rect ref={ringTrack} x={1.5} y={1.5} fill="none" stroke="currentColor" strokeWidth={2} strokeOpacity={0.15} style={{ opacity: ringShown }} />
        <motion.rect ref={ring} x={1.5} y={1.5} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" style={{ pathLength: fill, opacity: ringShown }} />
      </svg>
      {/* A rounded line floating inside the foot, clear of the corners, over a faint track that shows while it's held. */}
      <motion.span
        aria-hidden
        className={cn(
          "pointer-events-none absolute inset-x-3 bottom-1 h-0.5 overflow-hidden rounded-full",
          (!holdMs || (holdStyle !== "bar" && holdStyle !== "countdown")) && "hidden"
        )}
        style={{ opacity: ringShown }}
      >
        <span className="absolute inset-0 bg-current opacity-20" />
        <motion.span className="absolute inset-0 origin-left rounded-full bg-current rtl:origin-right" style={{ scaleX: fill }} />
      </motion.span>
      {holdMs > 0 && <span className="sr-only">{t("pressAndHold")}</span>}
      {/* The visible width, gliding; what's inside is measured at its own width and kept centred while it catches up. */}
      <motion.span className="flex justify-center gap-[inherit]" style={{ width }}>
        <span ref={content} className="relative grid w-max shrink-0 gap-[inherit]">
          <motion.span
            className={layer}
            animate={{ opacity: loading || counting ? 0 : 1 }}
            transition={{ duration: duration.fast, ease: ease.standard }}
            aria-hidden={loading || undefined}
          >
            {morph && (typeof children === "string" || typeof children === "number") ? (
              // With morph, a label that's only text changes letter by letter: the letters both labels have glide to their places.
              <span className={layer}>
                <TextMorph>{String(children)}</TextMorph>
              </span>
            ) : (
              // popLayout lifts the outgoing label out of the flow at once, so the new one sets the width straight away.
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.span
                  key={key}
                  className={layer}
                  initial={enter}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={{ opacity: 0, transition: { duration: 0.1, ease: ease.exit } }}
                  transition={
                    reduced
                      ? { duration: duration.fast }
                      : iconOnly
                        ? { ...spring.pop, opacity: { duration: duration.fast, ease: ease.enter } }
                        : { duration: duration.standard, ease: ease.enter }
                  }
                >
                  {children}
                </motion.span>
              </AnimatePresence>
            )}
          </motion.span>
          {/* countdown: the seconds left take the label's place while it's held, keeping the button's width. */}
          <AnimatePresence initial={false}>
            {counting && (
              <motion.span
                key="countdown"
                aria-hidden
                className={cn(layer, "tabular-nums")}
                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, transition: { duration: 0.1, ease: ease.exit } }}
                transition={{ duration: duration.fast, ease: ease.enter }}
              >
                <Odometer value={left} format={seconds} />
              </motion.span>
            )}
          </AnimatePresence>
          <AnimatePresence initial={false}>
            {loading && (
              <motion.span
                key="loading"
                className={layer}
                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, transition: { duration: 0.1, ease: ease.exit } }}
                transition={reduced ? { duration: duration.fast } : { ...spring.pop, opacity: { duration: duration.fast, ease: ease.enter } }}
              >
                <Loader2 data-spinner="" className="animate-spin motion-reduce:animate-[spin_1.5s_linear_infinite]" aria-hidden />
                {loadingText}
              </motion.span>
            )}
          </AnimatePresence>
        </span>
      </motion.span>
    </motion.button>
  )
}
```

### components/dashral/lib/motion-tokens.ts

```ts
// The kit's motion tokens for motion/react, which takes numbers, not CSS variables. They mirror globals.css in
// @dashral/ui (--duration-*, --ease-*): change one, change the other. Durations are in seconds, as motion/react has
// them; eases are the same cubic-bezier points.

export const duration = {
  /** --duration-fast: hovers, presses, color changes. */
  fast: 0.16,
  /** --duration-standard: menus, popovers, toggles. */
  standard: 0.24,
  /** --duration-considered: sliding highlights, panels, things that travel. */
  considered: 0.48,
} as const

export const ease = {
  /** --ease-standard: things already on screen moving or changing. */
  standard: [0.2, 0, 0, 1],
  /** --ease-enter: arriving, fast then settling. */
  enter: [0, 0, 0, 1],
  /** --ease-exit: leaving, gathering speed. */
  exit: [0.3, 0, 1, 1],
} as const satisfies Record<string, readonly [number, number, number, number]>

export const spring = {
  /** A press and its release: quick, and it never overshoots, so a button doesn't wobble under the finger. */
  press: { type: "spring", visualDuration: 0.16, bounce: 0 },
  /** Sizes and positions following new content (a label changing): the considered glide, without overshoot. */
  glide: { type: "spring", visualDuration: 0.48, bounce: 0 },
  /** Something moving to a new place on screen (a tab's highlight, a switch's thumb): a touch of overshoot to settle. */
  move: { type: "spring", visualDuration: 0.4, bounce: 0.12 },
  /** A menu, popover, tooltip or dialog arriving: --duration-standard with a hint of overshoot, quick to settle. */
  appear: { type: "spring", visualDuration: 0.24, bounce: 0.1 },
  /** --ease-spring: a small overshoot, for something arriving that should feel alive (an icon popping in). */
  pop: { type: "spring", visualDuration: 0.48, bounce: 0.15 },
} as const
```

### components/dashral/lib/motion-reduced.ts

```ts
import { useSyncExternalStore } from "react"
import { reducedMotion } from "@/components/dashral/lib/motion"

// Whether to keep still, as the kit decides it: the OS asks for less motion, or the app turned motion down with
// data-motion="reduce" on <html> (the dashboards' setting). motion/react's own hook hears only the first.

function subscribe(onChange: () => void) {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)")
  query.addEventListener("change", onChange)
  const observer = new MutationObserver(onChange)
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-motion"] })
  return () => {
    query.removeEventListener("change", onChange)
    observer.disconnect()
  }
}

/** True while motion should stay still; false on the server, so the first paint matches. */
export function useReducedMotion() {
  return useSyncExternalStore(subscribe, reducedMotion, () => false)
}
```

### components/dashral/lib/motion-odometer.tsx

```ts
"use client"

// A number on odometer wheels, as the motion NumberInput and Slider show their values: each digit turns as far as it
// really travelled, softly blurred while it turns, and the number's width glides as digits come and go.

import * as React from "react"
import { AnimatePresence, animate, motion, useMotionValue, useTransform, type MotionValue } from "motion/react"

import { cn } from "@/components/dashral/lib/utils"

import { useReducedMotion } from "@/components/dashral/lib/motion-reduced"
import { duration, ease, spring } from "@/components/dashral/lib/motion-tokens"

type Column = { key: string; count: number } | { key: string; text: string }

/**
 * The number as columns keyed by place (ones, tens, tenths…), so the ones stay the ones as it grows. A digit's count
 * is the whole number of its place units (1,284 has 128 tens), so a column knows how far it really turned.
 */
function columnsOf(value: number, format: Intl.NumberFormat): Column[] {
  const parts = format.formatToParts(value)
  const fractionDigits = parts.filter((p) => p.type === "fraction").reduce((n, p) => n + p.value.length, 0)
  // From the digits as shown, so a percent (0.45 shown as 45%) or a rounded value counts what you see.
  const scaled = Number(parts.filter((p) => p.type === "integer" || p.type === "fraction").map((p) => p.value).join("")) || 0
  let place = parts.filter((p) => p.type === "integer").reduce((n, p) => n + p.value.length, 0)
  let fraction = 0
  return parts.flatMap((part, index): Column[] => {
    if (part.type === "integer")
      return [...part.value].map(() => {
        place--
        return { key: `i${place}`, count: Math.floor(scaled / 10 ** (place + fractionDigits)) }
      })
    if (part.type === "fraction")
      return [...part.value].map(() => {
        const at = fractionDigits - 1 - fraction++
        return { key: `f${fraction}`, count: Math.floor(scaled / 10 ** at) }
      })
    return [{ key: part.type === "group" ? `g${place}` : part.type === "decimal" ? "d" : `${part.type}${index}`, text: part.value }]
  })
}

/**
 * The columns, with the number's width gliding to fit as columns come and go (9 to 10 opens a tens column): a new
 * column rolls in the way the number moved, and one that's no longer needed rolls out.
 */
export function Odometer({ value, format, className }: { value: number; format: Intl.NumberFormat; className?: string }) {
  const reduced = useReducedMotion()
  const [shown, setShown] = React.useState(value)
  const [rise, setRise] = React.useState(1)
  if (shown !== value) {
    setShown(value)
    setRise(value < shown ? -1 : 1)
  }

  // The visible width follows the columns' on a glide; the first measure is at once.
  const inner = React.useRef<HTMLSpanElement>(null)
  const width = useMotionValue<number | "auto">("auto")
  React.useEffect(() => {
    const node = inner.current
    if (!node) return
    const observer = new ResizeObserver(([entry]) => {
      const next = entry?.borderBoxSize[0]?.inlineSize ?? node.offsetWidth
      if (!next) return
      if (reduced || width.get() === "auto") width.jump(next)
      else animate(width, next, spring.glide)
    })
    observer.observe(node)
    return () => observer.disconnect()
  }, [reduced, width])

  const slot = {
    enter: (dir: number) => (reduced ? { opacity: 0 } : { opacity: 0, y: `${dir * 100}%`, filter: "blur(2px)" }),
    shown: { opacity: 1, y: 0, filter: "blur(0px)" },
    leave: (dir: number) => (reduced ? { opacity: 0 } : { opacity: 0, y: `${dir * -100}%`, filter: "blur(2px)" }),
  }
  return (
    <motion.span className={cn("flex justify-center overflow-hidden", className)} style={{ width }}>
      <span ref={inner} className="relative flex w-max shrink-0">
        {/* popLayout lifts a leaving column out of the row at once, so the new width is measured straight away. */}
        <AnimatePresence mode="popLayout" initial={false} custom={rise}>
          {columnsOf(value, format).map((column) => (
            <motion.span
              key={column.key}
              className="inline-flex"
              custom={rise}
              variants={slot}
              initial="enter"
              animate="shown"
              exit="leave"
              transition={reduced ? { duration: 0 } : { duration: duration.standard, ease: ease.enter }}
            >
              {"count" in column ? <Wheel count={column.count} reduced={reduced} /> : column.text}
            </motion.span>
          ))}
        </AnimatePresence>
      </span>
    </motion.span>
  )
}

/**
 * One column: the ten digits on a wheel. It turns as many places as its count moved (capped near two turns, so a
 * huge jump still lands quickly), takes longer the farther it goes, and blurs with its speed, sharp again at rest.
 */
function Wheel({ count, reduced }: { count: number; reduced: boolean }) {
  const position = useMotionValue(count)
  // Blur from the wheel's speed as it turns, and none once it rests.
  const filter = useMotionValue("none")
  const turned = React.useRef({ count, target: count })
  React.useLayoutEffect(() => {
    const wheel = turned.current
    const delta = count - wheel.count
    if (!delta) return
    wheel.count = count
    // Past two turns it keeps only the last few places (same digit to land on), so it never spins for long.
    const travel = Math.abs(delta) > 20 ? Math.sign(delta) * (20 + (Math.abs(delta) % 10)) : delta
    wheel.target += travel
    if (reduced) {
      position.jump(wheel.target)
      return
    }
    const controls = animate(position, wheel.target, {
      type: "spring",
      visualDuration: Math.min(0.32 + Math.abs(travel) * 0.025, 0.85),
      bounce: 0.12,
      onUpdate: () => {
        // A soft blur on any turn (a single step too), growing with speed for a long spin.
        const speed = Math.abs(position.getVelocity())
        filter.set(speed < 0.4 ? "none" : `blur(${Math.min(0.6 + speed * 0.04, 2).toFixed(2)}px)`)
      },
      onComplete: () => filter.set("none"),
    })
    return () => {
      controls.stop()
      filter.set("none")
    }
  }, [count, filter, position, reduced])
  return (
    <motion.span className="relative inline-grid overflow-hidden" style={{ filter }}>
      <span className="invisible">0</span>
      {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((digit) => (
        <Glyph key={digit} digit={digit} position={position} />
      ))}
    </motion.span>
  )
}

/** Where a digit sits on its wheel: on the line when the wheel is at it, a line below for the next one up. */
function Glyph({ digit, position }: { digit: number; position: MotionValue<number> }) {
  const away = (at: number) => {
    let d = (digit - at) % 10
    if (d > 5) d -= 10
    if (d < -5) d += 10
    return d
  }
  const y = useTransform(position, (at) => `${away(at) * 100}%`)
  const opacity = useTransform(position, (at) => Math.max(0, 1 - Math.abs(away(at)) ** 1.4))
  const visibility = useTransform(position, (at) => (Math.abs(away(at)) >= 1 ? "hidden" : "visible"))
  return (
    <motion.span className="absolute inset-0 text-center" style={{ y, opacity, visibility }}>
      {digit}
    </motion.span>
  )
}
```

### components/dashral/lib/motion-hover.ts

```ts
"use client"

// The hover highlight motion menus, selects, tabs and segmented controls share: one pill, gliding without a blink.
// It glides from item to item without going out between them. Its opacity is kept here, outside the pill, so moving to
// the next item (a new pill there, by layoutId) doesn't start it from nothing; and letting go of an item waits a
// moment, so passing from one to the next (or a menu moving focus to itself in between) never blinks it out.

import { useCallback, useEffect, useRef, useState } from "react"
import { animate, useMotionValue } from "motion/react"

import { duration } from "@/components/dashral/lib/motion-tokens"

export function useHoverPill<K>(reduced: boolean) {
  const [hot, setHot] = useState<K | null>(null)
  const opacity = useMotionValue(0)
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
  useEffect(() => () => clearTimeout(timer.current), [])

  /** The pointer or the keys are on this item: the pill goes there (gliding, if it was showing). */
  const enter = useCallback(
    (key: K) => {
      clearTimeout(timer.current)
      setHot(key)
      animate(opacity, 1, { duration: reduced ? 0 : duration.fast })
    },
    [opacity, reduced]
  )

  /** Off an item: unless another takes the pill straight away, it fades where it is, then goes. */
  const leave = useCallback(() => {
    clearTimeout(timer.current)
    timer.current = setTimeout(() => {
      animate(opacity, 0, { duration: reduced ? 0 : duration.fast, onComplete: () => setHot(null) })
    }, 60)
  }, [opacity, reduced])

  /** At once, as when the list closes. */
  const clear = useCallback(() => {
    clearTimeout(timer.current)
    opacity.jump(0)
    setHot(null)
  }, [opacity])

  return { hot, enter, leave, clear, opacity }
}
```

### components/dashral/lib/motion-field.tsx

```ts
"use client"

// The description and error every motion form control shows: an error grows in under the control and folds away.
// The words below glide down as it grows instead of jumping, and a new message crosses over the old one in the same
// space. Each line brings its own space above it, so a message's space grows in with it rather than popping.

import * as React from "react"
import { AnimatePresence, motion } from "motion/react"
import type { FieldMessages } from "@/components/dashral/field"
import { cn } from "@/components/dashral/lib/utils"

import { useReducedMotion } from "@/components/dashral/lib/motion-reduced"
import { duration, ease } from "@/components/dashral/lib/motion-tokens"

/** What a message says, for telling a new one from a re-render of the same; null when there's none. */
function messageKeyOf(message: React.ReactNode) {
  if (!message) return null
  return typeof message === "string" || typeof message === "number" ? String(message) : "node"
}

/** An error under (or beside) a control: it grows in and folds away, and a new one crosses over the old. */
export function FieldMessage({
  id,
  message,
  space = "pt-2",
  className,
}: {
  id?: string
  message: React.ReactNode
  /** The space above it, grown in with it: pt-2 under a control, pt-1 in a checkbox's or switch's label column. */
  space?: string
  className?: string
}) {
  const reduced = useReducedMotion()
  const fold = { duration: reduced ? 0 : duration.standard, ease: ease.standard }
  return (
    <AnimatePresence initial={false}>
      {message && (
        <motion.div
          key="error"
          className="overflow-hidden"
          initial={{ height: 0, opacity: 0 }}
          animate={{ height: "auto", opacity: 1 }}
          exit={{ height: 0, opacity: 0, transition: { ...fold, duration: reduced ? 0 : duration.fast } }}
          transition={fold}
        >
          <div className={cn("grid", space)}>
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.p
                key={messageKeyOf(message)}
                id={id}
                data-slot="field-error"
                className={cn("col-start-1 row-start-1 text-xs text-destructive-text", className)}
                initial={reduced ? { opacity: 0 } : { opacity: 0, y: -4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, transition: { duration: 0.1, ease: ease.exit } }}
                transition={{ duration: duration.standard, ease: ease.enter }}
              >
                {message}
              </motion.p>
            </AnimatePresence>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  )
}

/**
 * For a kit Field, FieldWrapper or ChoiceField's renderMessages: the description, and the error growing in under it.
 * The field's own gap is taken back above them (each line brings its space), so nothing pops as an error arrives.
 */
export const motionMessages: FieldMessages = ({ description, descriptionId, error, errorId, layout, classNames }) => {
  const space = layout === "field" ? "pt-2" : "pt-1"
  return (
    <div className={cn("grid min-w-0", layout === "field" ? "-mt-2" : "-mt-1")}>
      {description && (
        <p id={descriptionId} data-slot="field-description" className={cn(space, "text-xs text-muted-foreground", classNames?.description)}>
          {description}
        </p>
      )}
      <FieldMessage id={errorId} message={error} space={space} className={classNames?.error} />
    </div>
  )
}
```

### components/dashral/lib/motion-text-morph.tsx

```ts
"use client"

// Text that morphs into its next state letter by letter: the letters both have glide to their new places, the new ones
// rise in out of a soft blur, and the ones it no longer has fade away ("Follow" to "Following" keeps "Follow" where it
// is and grows "ing"). The motion Button uses it for a label that's only text; use it for any short text that changes.

import * as React from "react"
import { AnimatePresence, LayoutGroup, motion } from "motion/react"
import { cn } from "@/components/dashral/lib/utils"

import { useReducedMotion } from "@/components/dashral/lib/motion-reduced"
import { duration, ease, spring } from "@/components/dashral/lib/motion-tokens"

/** The letters, each keyed by itself and how many of it came before ("l-0", "l-1"), so a shared letter is the same one. */
function lettersOf(text: string) {
  const seen = new Map<string, number>()
  return [...text].map((char) => {
    const n = seen.get(char) ?? 0
    seen.set(char, n + 1)
    return { char, key: `${char}-${n}` }
  })
}

/**
 * A short text that morphs into the next one letter by letter. Screen readers hear the text as a whole; the letters
 * are only for the eye. With reduced motion the new text fades in.
 */
export function TextMorph({ children, className }: { children: string; className?: string }) {
  const reduced = useReducedMotion()
  const group = React.useId()
  return (
    <span className={cn("relative inline-flex whitespace-pre", className)}>
      <span className="sr-only">{children}</span>
      <LayoutGroup id={group}>
        {/* popLayout lifts a leaving letter out of the line at once, so the others glide to their places straight away. */}
        <AnimatePresence mode="popLayout" initial={false}>
          {lettersOf(children).map(({ char, key }) => (
            <motion.span
              key={key}
              aria-hidden
              className="inline-block"
              layout={reduced ? false : "position"}
              initial={reduced ? { opacity: 0 } : { opacity: 0, y: "0.3em", filter: "blur(3px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.3em", filter: "blur(3px)", transition: { duration: duration.fast, ease: ease.exit } }}
              transition={{ layout: spring.glide, default: { duration: duration.standard, ease: ease.enter } }}
            >
              {char === " " ? " " : char}
            </motion.span>
          ))}
        </AnimatePresence>
      </LayoutGroup>
    </span>
  )
}
```