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.
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
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.
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.
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.
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.
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.
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.