Stories Viewer
Segmented progress that auto-advances; tap to move, hold to pause.
New: Pro components
Data tables, kanban, AI chat and more.
components/ui/stories-viewer.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
type Story = { title: string; text: string; from: string; to: string };
const defaults: Story[] = [
{ title: "New: Pro components", text: "Data tables, kanban, AI chat and more.", from: "#2563eb", to: "#7c3aed" },
{ title: "Copy. Paste. Ship.", text: "Every component is one typed file.", from: "#db2777", to: "#f59e0b" },
{ title: "Runs live", text: "Try it on the page before you copy it.", from: "#059669", to: "#06b6d4" },
];
/** Stories viewer: segmented progress that auto-advances, tap left/right to move, press and hold to pause. */
export function StoriesViewer({ stories = defaults, seconds = 4 }: { stories?: Story[]; seconds?: number }) {
const [index, setIndex] = useState(0);
const [progress, setProgress] = useState(0); // 0–1 of the current story
const [paused, setPaused] = useState(false);
const held = useRef(false);
useEffect(() => {
if (paused) return;
const t = setInterval(() => setProgress((p) => p + 0.05 / seconds), 50);
return () => clearInterval(t);
}, [paused, seconds]);
// Move on when the current segment fills.
useEffect(() => {
if (progress < 1) return;
setIndex((i) => (i + 1) % stories.length);
setProgress(0);
}, [progress, stories.length]);
function go(d: number) {
setIndex((i) => Math.max(0, Math.min(stories.length - 1, i + d)));
setProgress(0);
}
const s = stories[index];
return (
<div
tabIndex={0}
aria-roledescription="stories"
aria-label={`Story ${index + 1} of ${stories.length}: ${s.title}`}
onKeyDown={(e) => {
if (e.key === "ArrowRight") go(1);
if (e.key === "ArrowLeft") go(-1);
if (e.key === " ") {
e.preventDefault();
setPaused((p) => !p);
}
}}
onPointerDown={() => {
held.current = false;
setPaused(true);
setTimeout(() => (held.current = true), 250);
}}
onPointerUp={(e) => {
setPaused(false);
if (held.current) return; // it was a hold, not a tap
const r = e.currentTarget.getBoundingClientRect();
go(e.clientX - r.left < r.width / 3 ? -1 : 1);
}}
onPointerLeave={() => setPaused(false)}
className="relative h-96 w-56 cursor-pointer select-none overflow-hidden rounded-[2rem] bg-zinc-900 shadow-2xl outline-none ring-offset-2 focus-visible:ring-2 focus-visible:ring-zinc-900"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div key={index} initial={{ opacity: 0, scale: 1.06 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} className="absolute inset-0 flex flex-col justify-end p-5 text-white" style={{ background: `linear-gradient(160deg, ${s.from}, ${s.to})` }}>
<p className="text-xl font-semibold leading-tight">{s.title}</p>
<p className="mt-1.5 text-sm text-white/80">{s.text}</p>
</motion.div>
</AnimatePresence>
<div className="absolute inset-x-3 top-3 flex gap-1" aria-hidden>
{stories.map((_, i) => (
<span key={i} className="h-1 flex-1 overflow-hidden rounded-full bg-white/30">
<span className="block h-full rounded-full bg-white" style={{ width: `${i < index ? 100 : i === index ? Math.min(100, progress * 100) : 0}%` }} />
</span>
))}
</div>
{paused && <span className="absolute right-4 top-7 rounded-full bg-black/40 px-2 py-0.5 text-[10px] font-medium text-white">Paused</span>}
</div>
);
}
export default function Demo() {
return <StoriesViewer />;
}
Edit with AIPro
Describe a change to Stories Viewer and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/stories-viewer.tsxcomponents/ui/stories-viewer.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useRef, useState } from "react"; type Story = { title: string; text: string; from: string; to: string }; const defaults: Story[] = [ { title: "New: Pro components", text: "Data tables, kanban, AI chat and more.", from: "#2563eb", to: "#7c3aed" }, { title: "Copy. Paste. Ship.", text: "Every component is one typed file.", from: "#db2777", to: "#f59e0b" }, { title: "Runs live", text: "Try it on the page before you copy it.", from: "#059669", to: "#06b6d4" }, ]; /** Stories viewer: segmented progress that auto-advances, tap left/right to move, press and hold to pause. */ export function StoriesViewer({ stories = defaults, seconds = 4 }: { stories?: Story[]; seconds?: number }) { const [index, setIndex] = useState(0); const [progress, setProgress] = useState(0); // 0–1 of the current story const [paused, setPaused] = useState(false); const held = useRef(false); useEffect(() => { if (paused) return; const t = setInterval(() => setProgress((p) => p + 0.05 / seconds), 50); return () => clearInterval(t); }, [paused, seconds]); // Move on when the current segment fills. useEffect(() => { if (progress < 1) return; setIndex((i) => (i + 1) % stories.length); setProgress(0); }, [progress, stories.length]); function go(d: number) { setIndex((i) => Math.max(0, Math.min(stories.length - 1, i + d))); setProgress(0); } const s = stories[index]; return ( <div tabIndex={0} aria-roledescription="stories" aria-label={`Story ${index + 1} of ${stories.length}: ${s.title}`} onKeyDown={(e) => { if (e.key === "ArrowRight") go(1); if (e.key === "ArrowLeft") go(-1); if (e.key === " ") { e.preventDefault(); setPaused((p) => !p); } }} onPointerDown={() => { held.current = false; setPaused(true); setTimeout(() => (held.current = true), 250); }} onPointerUp={(e) => { setPaused(false); if (held.current) return; // it was a hold, not a tap const r = e.currentTarget.getBoundingClientRect(); go(e.clientX - r.left < r.width / 3 ? -1 : 1); }} onPointerLeave={() => setPaused(false)} className="relative h-96 w-56 cursor-pointer select-none overflow-hidden rounded-[2rem] bg-zinc-900 shadow-2xl outline-none ring-offset-2 focus-visible:ring-2 focus-visible:ring-zinc-900" > <AnimatePresence mode="popLayout" initial={false}> <motion.div key={index} initial={{ opacity: 0, scale: 1.06 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} className="absolute inset-0 flex flex-col justify-end p-5 text-white" style={{ background: `linear-gradient(160deg, ${s.from}, ${s.to})` }}> <p className="text-xl font-semibold leading-tight">{s.title}</p> <p className="mt-1.5 text-sm text-white/80">{s.text}</p> </motion.div> </AnimatePresence> <div className="absolute inset-x-3 top-3 flex gap-1" aria-hidden> {stories.map((_, i) => ( <span key={i} className="h-1 flex-1 overflow-hidden rounded-full bg-white/30"> <span className="block h-full rounded-full bg-white" style={{ width: `${i < index ? 100 : i === index ? Math.min(100, progress * 100) : 0}%` }} /> </span> ))} </div> {paused && <span className="absolute right-4 top-7 rounded-full bg-black/40 px-2 py-0.5 text-[10px] font-medium text-white">Paused</span>} </div> ); } export default function Demo() { return <StoriesViewer />; }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.