A vertical story card with a caption that hops into place. Every word starts thirty pixels low, tilted and invisible, then springs upright with a soft overshoot, each one a beat after the last. A two-way switch changes the split from words to single letters for a faster ripple. The card cycles through three captions with progress bars along the top, and tapping the left or right side steps between them. The scene behind is drawn in SVG, so nothing loads from elsewhere. On phones the copy sits above the card.
// Adapted from Animata (https://animata.design/docs/text/jumping-text-instagram)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
const slides = [
{ caption: "Golden hour at the harbour wall", place: "Porthaven, 6:42 pm", sky: "from-amber-300 via-orange-400 to-rose-500" },
{ caption: "First swim of the year, absolutely freezing", place: "Tidal pool, 7:10 am", sky: "from-cyan-300 via-sky-500 to-indigo-600" },
{ caption: "Chips on the sea wall, gulls circling", place: "The Front, 1:25 pm", sky: "from-lime-200 via-emerald-400 to-teal-600" },
];
const SLIDE_SECONDS = 3.6;
type Mode = "word" | "letter";
/** Words keep their trailing space so the line can wrap between them. */
function pieces(text: string, mode: Mode) {
return mode === "word" ? text.split(/(?<=\s)/u) : Array.from(text);
}
function JumpingText({ text, mode, still }: { text: string; mode: Mode; still: boolean }) {
const nodes = pieces(text, mode);
return (
<span key={text + mode}>
<span aria-hidden="true">
{nodes.map((node, index) => (
<motion.span
key={index}
initial={still ? false : { y: 30, rotate: -30, opacity: 0 }}
animate={{ y: 0, rotate: 0, opacity: 1 }}
transition={{ type: "spring", damping: 10, mass: 2, stiffness: 180, delay: (mode === "word" ? 0.07 : 0.018) * index }}
className="inline-block origin-center whitespace-pre"
>
{node}
</motion.span>
))}
</span>
<span className="sr-only">{text}</span>
</span>
);
}
export default function StoryCaptionTextThatSpringsInWordByWord() {
const reduce = useReducedMotion();
const [index, setIndex] = useState(0);
const [mode, setMode] = useState<Mode>("word");
const slide = slides[index];
useEffect(() => {
if (reduce) return;
const timer = window.setTimeout(() => setIndex((current) => (current + 1) % slides.length), SLIDE_SECONDS * 1000);
return () => window.clearTimeout(timer);
}, [index, mode, reduce]);
return (
<section className="bg-stone-100 px-4 py-14 sm:px-6 sm:py-20">
<style>{`@keyframes story-caption-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@media (prefers-reduced-motion: reduce) { .story-caption-bar { animation: none !important; transform: none !important; } }`}</style>
<div className="mx-auto grid max-w-5xl items-center gap-10 md:grid-cols-2 md:gap-16">
<div>
<p className="text-sm font-semibold uppercase tracking-widest text-orange-700">Caption motion</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-stone-900 sm:text-4xl">Captions that hop onto the picture.</h2>
<p className="mt-4 max-w-md text-base leading-relaxed text-stone-700">
Each word starts tilted and below the line, then springs upright with a soft overshoot. Switch to letters for a
quicker, rippling version of the same move.
</p>
<div className="mt-7 inline-flex rounded-full bg-stone-200 p-1" role="group" aria-label="Animate by">
{(["word", "letter"] as const).map((option) => (
<button
key={option}
type="button"
aria-pressed={mode === option}
onClick={() => setMode(option)}
className={`h-10 rounded-full px-5 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-100 ${
mode === option ? "bg-stone-900 text-white" : "text-stone-700 hover:text-stone-900"
}`}
>
{option === "word" ? "Word by word" : "Letter by letter"}
</button>
))}
</div>
</div>
<div className={`relative mx-auto h-[500px] w-full max-w-[300px] overflow-hidden rounded-[2rem] bg-gradient-to-b ${slide.sky} shadow-2xl shadow-stone-400/60 transition-colors`}>
{/* A drawn scene: sun, two layers of water and a harbour wall. */}
<svg viewBox="0 0 300 500" className="absolute inset-0 h-full w-full" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<circle cx="210" cy="170" r="46" fill="white" opacity="0.55" />
<circle cx="210" cy="170" r="80" fill="white" opacity="0.14" />
<path d="M0 300 Q75 286 150 300 T300 300 V500 H0Z" fill="black" opacity="0.14" />
<path d="M0 345 Q75 330 150 345 T300 345 V500 H0Z" fill="black" opacity="0.18" />
<path d="M0 500 V392 H120 L150 372 H300 V500Z" fill="black" opacity="0.42" />
<rect x="196" y="318" width="6" height="56" fill="black" opacity="0.5" />
<path d="M202 320 L238 366 H202Z" fill="white" opacity="0.7" />
</svg>
<div className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/70 to-transparent" />
<div className="absolute inset-x-4 top-4 flex gap-1.5" aria-hidden="true">
{slides.map((item, i) => (
<span key={item.caption} className="h-1 flex-1 overflow-hidden rounded-full bg-white/35">
<span
key={i === index ? `${index}-${mode}` : "idle"}
className="story-caption-bar block h-full origin-left rounded-full bg-white"
style={
i === index
? { animation: `story-caption-fill ${SLIDE_SECONDS}s linear forwards` }
: { transform: i < index ? "scaleX(1)" : "scaleX(0)" }
}
/>
</span>
))}
</div>
<div className="absolute inset-x-4 top-9 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-xs font-bold text-stone-900">JT</span>
<span className="text-sm font-semibold text-white">juno.travels</span>
</div>
<div className="absolute inset-x-5 bottom-6">
<p className="text-[1.7rem] font-extrabold leading-tight text-white">
<JumpingText text={slide.caption} mode={mode} still={Boolean(reduce)} />
</p>
<p className="mt-3 text-sm font-medium text-white/85">{slide.place}</p>
</div>
<button
type="button"
aria-label="Previous story"
onClick={() => setIndex((current) => (current - 1 + slides.length) % slides.length)}
className="absolute bottom-0 left-0 top-20 w-1/3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
/>
<button
type="button"
aria-label="Next story"
onClick={() => setIndex((current) => (current + 1) % slides.length)}
className="absolute bottom-0 right-0 top-20 w-2/3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
/>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/story-caption-text-that-springs-in-word-by-word.json
The caption is split after each space, so every piece is a word plus its trailing space. Each piece is an `inline-block` with `whitespace-pre`, which keeps the space visible and lets the browser break the line between pieces.
The wrapper has a `key` made from the text and the mode. When either changes React mounts fresh spans, and each one plays its `initial` to `animate` spring again.
Yes. Copy the `pieces` helper and the `JumpingText` component and render it inside any heading. It takes the text, the mode and a flag that turns the entrance off.
In letter mode every character is its own inline block, so a line can break inside a word. For long captions, split by word first and then render the letters of each word inside a `whitespace-nowrap` span.
Replace `animate` with `whileInView` on the `motion.span` and add `viewport={{ once: true }}`. The stagger delay still applies.
Adapted from Jumping Text in Animata, used under its open-source licence. Licences and credits
Related: Hero with Word Reveal Headline and Floating Cards, Word by Word Blur In Paragraph Reveal, Rotating Word Headline with Spring Flip.
More text effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.