Auto-Advancing Feature List with Progress and Preview

A dark two-column feature section. On the left, four features sit on a thin rail; the active one fills its rail from top to bottom over five seconds and then hands over to the next. On the right, a product window blurs out and in with a different preview for each feature: an inbox filling up, a message being routed, reply-time bars growing and a saved reply filling in its fields. Hovering or focusing the list holds the timer. On phones the preview moves above the list and stays the same height, so nothing jumps.

Code

import { useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";

const SECONDS_PER_FEATURE = 5;

const features = [
  { key: "inbox", title: "One shared inbox", text: "Email, chat and contact forms land in a single queue, each with an owner." },
  { key: "routing", title: "Routing that reads the message", text: "Billing questions go to finance, bugs go to support. Nobody forwards anything." },
  { key: "reports", title: "Reply times you can show the boss", text: "See the median first reply by day, and which hours leave customers waiting." },
  { key: "replies", title: "Saved replies with real names in them", text: "Drop in a snippet and it fills in the customer, the order and your sign-off." },
];

const conversations = [
  { initials: "OM", tone: "bg-sky-500", name: "Odette Marsh", line: "Can I move my booking to Friday?", tag: "Bookings", owner: "TK" },
  { initials: "HB", tone: "bg-amber-500", name: "Hal Brandt", line: "Charged twice for the March invoice", tag: "Billing", owner: "RA" },
  { initials: "SN", tone: "bg-emerald-500", name: "Sunniva Nord", line: "The export button does nothing on Safari", tag: "Bug", owner: "JL" },
  { initials: "PD", tone: "bg-rose-500", name: "Piet Dekker", line: "Do you have a plan for charities?", tag: "Sales", owner: "TK" },
];
const bars = [
  { day: "Mon", minutes: 42 },
  { day: "Tue", minutes: 31 },
  { day: "Wed", minutes: 36 },
  { day: "Thu", minutes: 22 },
  { day: "Fri", minutes: 14 },
  { day: "Sat", minutes: 9 },
];

type PreviewProps = { still: boolean };
const rise = (still: boolean, delay: number) => ({
  initial: still ? false : ({ opacity: 0, y: 14 } as const),
  animate: { opacity: 1, y: 0 },
  transition: still ? { duration: 0 } : ({ type: "spring", stiffness: 260, damping: 24, delay } as const),
});

function InboxPreview({ still }: PreviewProps) {
  return (
    <ul className="space-y-2.5">
      {conversations.map((c, i) => (
        <motion.li key={c.name} {...rise(still, 0.15 + i * 0.5)} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/5 p-3">
          <span className={`flex h-10 w-10 flex-none items-center justify-center rounded-full text-xs font-semibold text-white ${c.tone}`} aria-hidden="true">{c.initials}</span>
          <span className="min-w-0 flex-1">
            <span className="block truncate text-sm font-medium text-white">{c.name}</span>
            <span className="block truncate text-sm text-slate-400">{c.line}</span>
          </span>
          <span className="hidden flex-none rounded-full bg-sky-400/10 px-2.5 py-1 text-xs font-medium text-sky-300 sm:block">{c.tag}</span>
          <span className="flex h-7 w-7 flex-none items-center justify-center rounded-full border border-white/15 text-[10px] font-semibold text-slate-200" aria-hidden="true">{c.owner}</span>
        </motion.li>
      ))}
    </ul>
  );
}

function RoutingPreview({ still }: PreviewProps) {
  const lanes = [
    { label: "Finance", note: "invoice, refund, charged", tone: "text-amber-300", hit: false },
    { label: "Support", note: "error, broken, cannot", tone: "text-sky-300", hit: true },
    { label: "Sales", note: "plan, quote, discount", tone: "text-emerald-300", hit: false },
  ];
  return (
    <div className="flex h-full flex-col justify-center gap-5">
      <motion.div {...rise(still, 0.15)} className="rounded-2xl border border-white/10 bg-white/5 p-4">
        <p className="text-xs font-medium uppercase tracking-wider text-slate-400">New message</p>
        <p className="mt-1.5 text-sm text-white">
          The export button is <span className="rounded bg-sky-400/20 px-1 text-sky-200">broken</span> and I get an <span className="rounded bg-sky-400/20 px-1 text-sky-200">error</span> on Safari.
        </p>
      </motion.div>
      <svg viewBox="0 0 300 44" className="h-11 w-full" fill="none" aria-hidden="true">
        {["M150 0 C150 26 50 16 50 44", "M150 0 L150 44", "M150 0 C150 26 250 16 250 44"].map((d, i) => (
          <motion.path key={d} d={d} stroke={i === 1 ? "#38bdf8" : "#334155"} strokeWidth={i === 1 ? 2 : 1.5} strokeLinecap="round" strokeDasharray={i === 1 ? undefined : "3 5"} initial={still ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={still ? { duration: 0 } : { duration: 0.7, delay: 0.8 + (i === 1 ? 0.3 : 0), ease: "easeOut" }} />
        ))}
      </svg>
      <div className="grid grid-cols-3 gap-2.5">
        {lanes.map((l, i) => (
          <motion.div key={l.label} {...rise(still, 1.3 + i * 0.12)} className={`relative rounded-2xl border p-3 text-center ${l.hit ? "border-sky-400/60 bg-sky-400/10" : "border-white/10 bg-white/5"}`}>
            <p className={`text-sm font-semibold ${l.tone}`}>{l.label}</p>
            <p className="mt-1 hidden text-xs text-slate-400 sm:block">{l.note}</p>
            {l.hit && (
              <motion.span initial={still ? false : { scale: 0 }} animate={{ scale: 1 }} transition={still ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 18, delay: 2.1 }} className="absolute -right-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-sky-400 text-slate-950" aria-hidden="true">
                <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m5 10.5 3.2 3.2L15 6.5" /></svg>
              </motion.span>
            )}
          </motion.div>
        ))}
      </div>
    </div>
  );
}

function ReportsPreview({ still }: PreviewProps) {
  return (
    <div className="flex h-full flex-col">
      <motion.div {...rise(still, 0.15)} className="flex items-end justify-between">
        <div>
          <p className="text-xs font-medium uppercase tracking-wider text-slate-400">Median first reply</p>
          <p className="mt-1 text-4xl font-semibold tracking-tight text-white">14 min</p>
        </div>
        <p className="rounded-full bg-emerald-400/10 px-2.5 py-1 text-xs font-semibold text-emerald-300">28 min faster than Monday</p>
      </motion.div>
      <div className="mt-6 flex flex-1 items-end gap-2.5 sm:gap-4">
        {bars.map((b, i) => (
          <div key={b.day} className="flex h-full flex-1 flex-col items-center justify-end gap-2">
            <motion.span initial={still ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: still ? 0 : 0.9 + i * 0.3 }} className="text-xs tabular-nums text-slate-300">{b.minutes}m</motion.span>
            <motion.span
              aria-hidden="true"
              className={`w-full origin-bottom rounded-t-lg ${i >= 4 ? "bg-sky-400" : "bg-slate-600"}`}
              style={{ height: `${(b.minutes / 42) * 78}%` }}
              initial={still ? false : { scaleY: 0 }}
              animate={{ scaleY: 1 }}
              transition={still ? { duration: 0 } : { type: "spring", stiffness: 120, damping: 16, delay: 0.4 + i * 0.3 }}
            />
            <span className="text-xs text-slate-400">{b.day}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

function RepliesPreview({ still }: PreviewProps) {
  const chip = "rounded bg-sky-400/20 px-1 text-sky-200";
  return (
    <div className="flex h-full flex-col justify-between gap-4">
      <motion.div {...rise(still, 0.15)} className="flex flex-wrap gap-2">
        {["Refund sent", "Booking moved", "Bug logged"].map((s, i) => (
          <span key={s} className={`rounded-full border px-3 py-1.5 text-xs font-medium ${i === 1 ? "border-sky-400 bg-sky-400 text-slate-950" : "border-white/15 text-slate-300"}`}>{s}</span>
        ))}
      </motion.div>
      <motion.div {...rise(still, 0.7)} className="rounded-2xl border border-white/10 bg-white/5 p-4 text-sm leading-7 text-slate-200">
        Hi <motion.span {...pop(still, 1.2)} className={chip}>Odette</motion.span>, your booking is now on{" "}
        <motion.span {...pop(still, 1.7)} className={chip}>Friday 14 June, 10:30</motion.span>. The confirmation is on its way to{" "}
        <motion.span {...pop(still, 2.2)} className={chip}>[email protected]</motion.span>.
        <br />
        <motion.span {...pop(still, 2.7)} className={chip}>Tariq, Customer team</motion.span>
      </motion.div>
      <motion.div {...rise(still, 1)} className="flex items-center justify-between">
        <p className="text-xs text-slate-400">4 fields filled from the booking</p>
        <span className="rounded-lg bg-white px-3.5 py-2 text-xs font-semibold text-slate-950">Send reply</span>
      </motion.div>
    </div>
  );
}
function pop(still: boolean, delay: number) {
  return {
    style: { display: "inline-block" },
    initial: still ? false : ({ opacity: 0.15, scale: 0.9 } as const),
    animate: { opacity: 1, scale: 1 },
    transition: still ? { duration: 0 } : ({ type: "spring", stiffness: 500, damping: 20, delay } as const),
  };
}

const previews = [InboxPreview, RoutingPreview, ReportsPreview, RepliesPreview];

export default function AutoAdvancingFeatureListWithProgressAndPreview() {
  const [active, setActive] = useState(0);
  const [hold, setHold] = useState(false);
  const ref = useRef<HTMLElement>(null);
  const inView = useInView(ref, { amount: 0.25 });
  const still = !!useReducedMotion();
  const Preview = previews[active];
  const paused = hold || !inView;

  return (
    <section ref={ref} className="relative overflow-hidden bg-slate-950 px-5 py-16 text-slate-200 sm:px-6 sm:py-24">
      <style>{`
        @keyframes auto-advancing-feature-fill { from { transform: scaleY(0); } to { transform: scaleY(1); } }
        @keyframes auto-advancing-feature-fill-x { from { transform: scaleX(0); } to { transform: scaleX(1); } }
        .auto-advancing-feature-rail { transform-origin: top; animation: auto-advancing-feature-fill ${SECONDS_PER_FEATURE}s linear forwards; }
        .auto-advancing-feature-bar { transform-origin: left; animation: auto-advancing-feature-fill-x ${SECONDS_PER_FEATURE}s linear forwards; }
        @media (prefers-reduced-motion: reduce) { .auto-advancing-feature-rail, .auto-advancing-feature-bar { animation: none; } }
      `}</style>
      <div aria-hidden="true" className="pointer-events-none absolute -right-40 top-1/2 h-[640px] w-[640px] -translate-y-1/2 rounded-full blur-3xl" style={{ background: "radial-gradient(closest-side, rgba(56,189,248,0.16), transparent)" }} />

      <div className="relative mx-auto grid max-w-6xl gap-x-16 gap-y-10 lg:grid-cols-2">
        <div className="min-w-0 lg:col-start-1 lg:row-start-1">
          <p className="text-sm font-semibold text-sky-400">Relay desk</p>
          <h2 className="mt-3 text-4xl font-semibold tracking-tight text-white sm:text-5xl">Answer every customer from one calm place.</h2>
        </div>
        <div className="min-w-0 lg:col-start-1 lg:row-start-2" onMouseEnter={() => setHold(true)} onMouseLeave={() => setHold(false)} onFocus={() => setHold(true)} onBlur={() => setHold(false)}>
          <div role="tablist" aria-label="Product features" aria-orientation="vertical">
            {features.map((f, i) => {
              const on = i === active;
              return (
                <button
                  key={f.key}
                  type="button"
                  role="tab"
                  id={`auto-advancing-tab-${f.key}`}
                  aria-selected={on}
                  aria-controls="auto-advancing-panel"
                  tabIndex={on ? 0 : -1}
                  onClick={() => setActive(i)}
                  onKeyDown={(e) => {
                    const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
                    if (!step) return;
                    e.preventDefault();
                    const next = (i + step + features.length) % features.length;
                    setActive(next);
                    document.getElementById(`auto-advancing-tab-${features[next].key}`)?.focus();
                  }}
                  className="group relative flex w-full gap-5 py-3.5 pl-6 text-left outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-4 focus-visible:ring-offset-slate-950"
                >
                  <span aria-hidden="true" className="absolute inset-y-0 left-0 w-0.5 overflow-hidden rounded-full bg-white/10">
                    {on && <span key={active} onAnimationEnd={() => setActive((a) => (a + 1) % features.length)} className="auto-advancing-feature-rail block h-full w-full rounded-full bg-sky-400" style={{ animationPlayState: paused ? "paused" : "running" }} />}
                  </span>
                  <span className={`mt-0.5 text-sm tabular-nums transition-colors duration-300 ${on ? "text-sky-400" : "text-slate-600"}`} aria-hidden="true">0{i + 1}</span>
                  <span>
                    <span className={`block text-lg font-semibold transition-colors duration-300 ${on ? "text-white" : "text-slate-400 group-hover:text-slate-200"}`}>{f.title}</span>
                    <span className={`mt-1 block text-sm leading-6 transition-colors duration-300 ${on ? "text-slate-300" : "text-slate-500"}`}>{f.text}</span>
                  </span>
                </button>
              );
            })}
          </div>
        </div>

        <div className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:self-center">
          <div className="relative rounded-3xl border border-white/10 bg-slate-900 shadow-2xl shadow-sky-950">
            <div className="flex items-center gap-2 border-b border-white/10 px-5 py-3.5">
              <span className="h-2.5 w-2.5 rounded-full bg-white/15" aria-hidden="true" />
              <span className="h-2.5 w-2.5 rounded-full bg-white/15" aria-hidden="true" />
              <span className="h-2.5 w-2.5 rounded-full bg-white/15" aria-hidden="true" />
              <p className="ml-3 text-xs font-medium text-slate-400">{features[active].title}</p>
              <span className="ml-auto h-1 w-16 overflow-hidden rounded-full bg-white/10" aria-hidden="true">
                <span key={active} className="auto-advancing-feature-bar block h-full w-full rounded-full bg-sky-400" style={{ animationPlayState: paused ? "paused" : "running" }} />
              </span>
            </div>
            <div id="auto-advancing-panel" role="tabpanel" aria-labelledby={`auto-advancing-tab-${features[active].key}`} className="relative h-[340px] overflow-hidden p-5 sm:h-[380px] sm:p-7">
              <AnimatePresence mode="wait">
                <motion.div
                  key={active}
                  className="h-full"
                  initial={still ? false : { opacity: 0, scale: 0.97, filter: "blur(8px)" }}
                  animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                  exit={still ? { opacity: 0 } : { opacity: 0, scale: 1.02, filter: "blur(8px)" }}
                  transition={{ duration: still ? 0 : 0.28, ease: [0.22, 1, 0.36, 1] }}
                >
                  <Preview still={still} />
                </motion.div>
              </AnimatePresence>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/auto-advancing-feature-list-with-progress-and-preview.json

When to use it

How to customize it

Accessibility

Questions

How does the auto-advance stay in sync with the progress bar?

The bar is the timer. It is a CSS keyframe animation that scales the rail from 0 to 1, and its `onAnimationEnd` event selects the next feature. Because the animation drives the change, the bar can never drift from the content.

How does pausing work?

The rail's `animationPlayState` is set to `paused` when the list is hovered, focused or scrolled off screen. The browser freezes the animation where it is and resumes from the same point, so there is no timer arithmetic to get wrong.

Why do the descriptions stay visible instead of collapsing?

Collapsing them changes the height of the list every five seconds, which moves everything under it. Dimming the inactive items gives the same focus without any layout shift, and visitors can read ahead.

Can I use screenshots instead of the drawn previews?

Yes. Replace a preview function with one that returns an image filling the panel, for example an `img` with `h-full w-full object-cover rounded-2xl`. The swap transition applies to whatever you render.

How do I make it advance only once and then stop?

In the `onAnimationEnd` handler, stop when the active index is the last one: only call `setActive` if `a + 1` is less than `features.length`.

Related: Underline Tabs with Icons and Three Panels, Alternating Feature Rows with Illustrations, Animated Bento Grid with Live Mini Demos.

More feature sections or all free UI components. Start for free to have a full site built around it.

More in Feature Sections

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.

Product

Who It's For

Features

Resources

Company

View Sitemap