Stories Viewer

Segmented progress that auto-advances; tap to move, hold to pause.

New: Pro components

Data tables, kanban, AI chat and more.

Edit with AIPro

Describe a change to Stories Viewer and get the updated code. The live preview above stays as the original.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/stories-viewer.tsx

    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 />;
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.