caliper uidocs

Text motion

Free

Text reveals, morphs and shimmer.

Installs components/ui/text-motion.tsxDepends on none beyond ReactMotion honours reduced motion
pnpm dlx shadcn@latest add https://caliperui.site/r/text-motion.json

Release 2.4

Tables, trees and threads, ready to ship

Sort, filter and select records, walk nested files, and keep a conversation next to the work. Every part keeps focus where you left it.

Scroll to read

Interfaces earn trust in the small moments. A label that changes without a jump, a list that remembers where you were, a loading state that says what it is doing.

$24 per seat, billed monthly

Cancel any time. Move to yearly and save 20%.

Summarise the report

Nothing generated yet.

When to use #

Use it for

  • A heading entering view, or a label that updates.

Skip it when

  • Long reading text.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/text-motion.json

Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above. It is built from TextReveal, InViewTitle, TextMorph, TextShimmer and useInView.

example.tsx
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { InViewTitle, TextMorph, TextReveal, TextShimmer } from "@/components/ui/text-motion"
import { SegmentedControl } from "@/components/ui/segmented-control"

const cadence = [
  { value: "monthly", label: "Monthly" },
  { value: "yearly", label: "Yearly" },
];

const copy = {
  monthly: "$24 per seat, billed monthly",
  yearly: "$19 per seat, billed yearly",
};

const note = {
  monthly: "Cancel any time. Move to yearly and save 20%.",
  yearly: "Two months free compared with paying monthly.",
};

export function TextMotionDemo() {
  const [play, setPlay] = useState(true);
  const [plan, setPlan] = useState<"monthly" | "yearly">("monthly");
  const [phase, setPhase] = useState<"idle" | "working" | "done">("idle");
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  useEffect(() => () => timers.current.forEach(clearTimeout), []);
  const replay = () => {
    setPlay(false);
    timers.current.push(setTimeout(() => setPlay(true), 80));
  };
  const summarise = () => {
    setPhase("working");
    timers.current.push(setTimeout(() => setPhase("done"), 2600));
  };
  return (
    <div className="grid min-w-0 gap-8" data-testid="text-motion-examples">
      <div className="grid min-w-0 gap-4 rounded-overlay border border-border p-5 sm:p-8">
        <div className="flex items-start justify-between gap-3">
          <InViewTitle eyebrow="Release 2.4">Tables, trees and threads, ready to ship</InViewTitle>
          <Button variant="ghost" size="sm" onClick={replay}>
            Replay
          </Button>
        </div>
        <TextReveal play={play} className="max-w-xl text-base leading-7 text-muted-foreground">
          Sort, filter and select records, walk nested files, and keep a conversation next to the work. Every part keeps focus where you left it.
        </TextReveal>
      </div>
      <div className="grid min-w-0 gap-3 rounded-overlay border border-border p-5 sm:p-8">
        <p className="text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">Scroll to read</p>
        <TextReveal mode="scroll" className="max-w-2xl text-2xl font-medium leading-snug tracking-tight sm:text-3xl">
          Interfaces earn trust in the small moments. A label that changes without a jump, a list that remembers where you were, a loading state that says what it is doing.
        </TextReveal>
      </div>
      <div className="grid min-w-0 gap-4 rounded-overlay border border-border p-5 sm:p-8">
        <SegmentedControl label="Billing period" value={plan} onValueChange={(v) => setPlan(v as "monthly" | "yearly")} options={cadence} />
        <p className="min-h-9 text-xl font-semibold tracking-tight sm:text-2xl">
          <TextMorph>{copy[plan]}</TextMorph>
        </p>
        <p className="text-sm text-muted-foreground">
          <TextMorph>{note[plan]}</TextMorph>
        </p>
      </div>
      <div className="grid min-w-0 gap-3 rounded-overlay border border-border p-5 sm:p-8">
        <div className="flex flex-wrap items-center justify-between gap-3">
          <h3 className="text-sm font-medium">Summarise the report</h3>
          <Button variant="secondary" size="sm" onClick={summarise} disabled={phase === "working"}>
            {phase === "done" ? "Run again" : "Summarise"}
          </Button>
        </div>
        <div role="status" className="min-h-12 text-sm" aria-live="polite">
          {phase === "idle" && <span className="text-muted-foreground">Nothing generated yet.</span>}
          {phase === "working" && <TextShimmer className="text-base font-medium">Reading 12 pages…</TextShimmer>}
          {phase === "done" && <span>Revenue grew 8% on stronger renewals. Support volume fell after the new help page shipped.</span>}
        </div>
      </div>
    </div>
  );
}

API reference #

TextReveal

component
PropTypeDefaultDescription
children *string—
asElementType"p"
by"word" | "char""word"
mode"stagger" | "scroll""stagger""stagger" plays once on entering view; "scroll" brightens words as the block scrolls past.
staggernumber36Delay between units in milliseconds.
playboolean—Controls playback instead of viewport entry. Toggle to replay.
classNamestring—

InViewTitle

component
PropTypeDefault
children *string—
eyebrowstring—
asElementType"h2"
align"center" | "start""start"
classNamestring—

TextMorph

component
PropTypeDefault
children *string—
asElementType"span"
classNamestring—

TextShimmer

component
PropTypeDefaultDescription
children *ReactNode—
asElementType"span"
activebooleantrueStops the sweep and shows the text normally when false.
durationnumber2200One sweep in milliseconds.
classNamestring—

useInView

component

No props of its own; it accepts the native props of its root element.

Accessibility #

It is built on native elements and keeps aria-hidden in step with state.

No custom key handling; Tab and the native activation keys do the work.

Motion #

It checks prefers-reduced-motion itself and settles immediately instead of travelling. Every state is still reachable.