An export status card for a video tool. The current step is written in grey text with a wave running through it: each letter lifts slightly toward the viewer, turns a few degrees and darkens, then settles as the next one starts. Under it sit a progress bar, a percentage and a three-step list where the active row carries a smaller, slower wave. The demo loops through a full export and can be paused. On phones the card fills the width and the step details are hidden to keep each row on one line.
// Adapted from Motion Primitives (https://motion-primitives.com/docs/text-shimmer-wave)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { memo, useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
const stages = [
{ label: "Rendering 1,440 frames", detail: "Colour grade and titles", share: 46 },
{ label: "Mixing the soundtrack", detail: "Voice, music and room tone", share: 28 },
{ label: "Writing the final file", detail: "4K master, 2.1 GB", share: 26 },
];
// Seconds for one full export in the demo loop.
const RUN = 9;
/**
* Loading text where a wave travels through the letters: each one lifts toward
* the viewer, turns slightly and darkens for a moment, a beat after the one before.
*/
const TextShimmerWave = memo(function TextShimmerWave({
children,
duration = 1.1,
spread = 1,
baseColor = "#94a3b8",
peakColor = "#0f172a",
className = "",
}: {
children: string;
duration?: number;
spread?: number;
baseColor?: string;
peakColor?: string;
className?: string;
}) {
const reduceMotion = useReducedMotion();
if (reduceMotion) {
return (
<span className={className} style={{ color: peakColor }}>
{children}
</span>
);
}
return (
<span className={`relative inline-block ${className}`} style={{ color: baseColor, perspective: 500 }}>
<span className="sr-only">{children}</span>
{children.split("").map((char, index) => (
<motion.span
key={index}
aria-hidden="true"
className="inline-block whitespace-pre"
style={{ transformStyle: "preserve-3d" }}
initial={{ z: 0, scale: 1, rotateY: 0, color: baseColor }}
animate={{
z: [0, 10, 0],
x: [0, 2, 0],
y: [0, -2, 0],
scale: [1, 1.1, 1],
rotateY: [0, 10, 0],
color: [baseColor, peakColor, baseColor],
}}
transition={{
duration,
repeat: Infinity,
repeatDelay: (children.length * 0.05) / spread,
delay: (index * duration * (1 / spread)) / children.length,
ease: "easeInOut",
}}
>
{char}
</motion.span>
))}
</span>
);
});
export default function WaveShimmerLoadingTextWithExportProgress() {
const reduceMotion = useReducedMotion();
const sectionRef = useRef<HTMLElement>(null);
const [percent, setPercent] = useState(reduceMotion ? 62 : 0);
const [paused, setPaused] = useState(false);
// Fake export progress that loops, only while visible.
useEffect(() => {
if (reduceMotion || paused) return;
let visible = true;
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
});
if (sectionRef.current) observer.observe(sectionRef.current);
const timer = setInterval(() => {
if (!visible || document.hidden) return;
setPercent((value) => (value >= 100 ? 0 : Math.min(100, value + 100 / (RUN * 10))));
}, 100);
return () => {
clearInterval(timer);
observer.disconnect();
};
}, [reduceMotion, paused]);
let passed = 0;
let current = stages.length - 1;
for (let i = 0; i < stages.length; i += 1) {
if (percent < passed + stages[i].share) {
current = i;
break;
}
passed += stages[i].share;
}
const shown = Math.round(percent);
return (
<section ref={sectionRef} className="bg-sky-50 px-5 py-14 text-slate-900 sm:px-8 sm:py-20">
<div className="mx-auto max-w-xl">
<div className="rounded-3xl bg-white p-6 shadow-xl shadow-sky-900/10 ring-1 ring-slate-900/5 sm:p-8">
<div className="flex items-center gap-4">
{/* Film thumbnail */}
<div className="relative h-16 w-24 shrink-0 overflow-hidden rounded-xl bg-slate-900">
<svg viewBox="0 0 96 64" aria-hidden="true" className="absolute inset-0 h-full w-full">
<rect width="96" height="64" fill="#0c4a6e" />
<circle cx="70" cy="22" r="10" fill="#fde68a" />
<path d="M0 50 24 30l16 12 20-22 36 30v14H0Z" fill="#0ea5e9" />
<path d="M0 58c20-8 40-8 60 0s28 4 36-2v8H0Z" fill="#082f49" />
</svg>
<span className="absolute bottom-1 right-1 rounded bg-black/70 px-1 font-mono text-[10px] text-white">00:48</span>
</div>
<div className="min-w-0 flex-1">
<h2 className="truncate text-base font-semibold">Coastline, autumn lookbook</h2>
<p className="mt-0.5 text-sm text-slate-600">Export to 4K, sent to Halden Studio</p>
</div>
<button
type="button"
aria-pressed={paused}
onClick={() => setPaused((value) => !value)}
className="h-10 shrink-0 rounded-full border border-slate-300 px-4 text-sm font-semibold text-slate-800 hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-600"
>
{paused ? "Resume" : "Pause"}
</button>
</div>
<div className="mt-8 flex items-end justify-between gap-4">
<p className="text-xl font-semibold tracking-tight sm:text-2xl" role="status">
{paused ? (
<span className="text-slate-900">Export paused</span>
) : (
<TextShimmerWave key={current}>{`${stages[current].label}...`}</TextShimmerWave>
)}
</p>
<p className="font-mono text-sm tabular-nums text-slate-600">{shown}%</p>
</div>
<div
role="progressbar"
aria-label="Export progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={shown}
className="mt-3 h-2 overflow-hidden rounded-full bg-slate-200"
>
<div className="h-full origin-left rounded-full bg-sky-600 transition-transform duration-150 ease-linear motion-reduce:transition-none" style={{ transform: `scaleX(${percent / 100})` }} />
</div>
<ol className="mt-7 space-y-1">
{stages.map((stage, index) => {
const done = index < current;
const active = index === current;
return (
<li key={stage.label} className={`flex items-center gap-3 rounded-xl px-3 py-2.5 ${active ? "bg-sky-50" : ""}`}>
<span
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold ${
done ? "bg-sky-600 text-white" : active ? "border-2 border-sky-600 text-sky-700" : "border border-slate-300 text-slate-600"
}`}
>
{done ? (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
<path d="m5 10.5 3.2 3.2L15 6.8" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
index + 1
)}
</span>
<span className="flex-1 text-sm">
{active && !paused ? (
<TextShimmerWave duration={1.4} spread={1.6} baseColor="#64748b" peakColor="#0369a1" className="font-semibold">
{stage.label}
</TextShimmerWave>
) : (
<span className={done ? "text-slate-600" : "font-medium text-slate-800"}>{stage.label}</span>
)}
</span>
<span className="hidden text-xs text-slate-600 sm:block">{stage.detail}</span>
</li>
);
})}
</ol>
</div>
<p className="mt-5 text-center text-sm text-slate-600">You can close this tab. We will email the download link when it is ready.</p>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/wave-shimmer-loading-text-with-export-progress.json
A gradient shimmer slides a highlight across flat text using a clipped background. Here each letter is its own element that moves in 3D and changes colour, so the line appears to ripple instead of being lit by a passing light.
Every letter runs the same short animation, but its start is delayed by its position in the string. A repeat delay based on the text length keeps the cycles in step, so one crest crosses the line and then pauses briefly before the next.
Moving a letter on the Z axis has no visible effect without perspective. `perspective: 500` on the wrapper is what makes the lifted letters look closer.
Colour changes repaint the letter, which is cheap for one or two short lines. Avoid running it on long paragraphs or many rows at once. Only the active step animates here.
Remove the interval and set `percent` from your upload or job status. The current step is worked out from `percent` and the `share` values, so the label follows on its own.
Adapted from Text Shimmer Wave in Motion Primitives, used under its open-source licence. Licences and credits
Related: Shimmering Chrome Gradient Headline, Assistant Side Panel with Thinking Shimmer, Text Scramble Decode Headline.
More loaders 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.