Text motion
FreeText reveals, morphs and shimmer.
pnpm dlx shadcn@latest add https://caliperui.site/r/text-motion.jsonWhen 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.