A five-tile bento grid on a warm paper background where each tile shows the feature working instead of describing it. A wide dark tile draws a revenue line with a dot riding the tip and a total that counts up. The others flip rule switches one by one, pop avatars into a stack, type a question and grow answer bars, and press two keys to open a command result. Loops pause when the grid is off screen. On phones the tiles stack in one column and every demo keeps running.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
const CHART_PATH = "M0 150 C40 146 60 120 96 122 S150 150 190 112 S250 60 292 84 S350 118 392 66 S450 24 480 18";
const rules = [
{ when: "Invoice is 7 days late", then: "Send a polite reminder" },
{ when: "Refund over $200", then: "Ask a manager first" },
{ when: "New customer pays", then: "Post it in #wins" },
];
const people = [
{ initials: "AO", tone: "bg-emerald-600" },
{ initials: "JT", tone: "bg-stone-800" },
{ initials: "MK", tone: "bg-orange-500" },
{ initials: "RS", tone: "bg-sky-600" },
{ initials: "LV", tone: "bg-rose-500" },
];
const question = "Which plan grew fastest in May?";
const answers = [
{ label: "Studio yearly", value: "+38%", width: "88%" },
{ label: "Solo monthly", value: "+21%", width: "54%" },
{ label: "Agency yearly", value: "+9%", width: "26%" },
];
/** A counter that ticks while the grid is on screen. Every tile derives its state from it. */
function useTick(active: boolean, ms: number) {
const [tick, setTick] = useState(0);
useEffect(() => {
if (!active) return;
const id = window.setInterval(() => setTick((t) => t + 1), ms);
return () => window.clearInterval(id);
}, [active, ms]);
return tick;
}
function ChartTile({ active, still }: { active: boolean; still: boolean }) {
const pathRef = useRef<SVGPathElement>(null);
const progress = useMotionValue(still ? 1 : 0);
const boxRef = useRef<HTMLDivElement>(null);
const size = useRef({ w: 0, h: 0 });
const length = useRef(0);
useEffect(() => {
if (pathRef.current) length.current = pathRef.current.getTotalLength();
const box = boxRef.current;
if (!box) return;
const measure = () => {
size.current = { w: box.clientWidth, h: box.clientHeight };
progress.set(progress.get());
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(box);
return () => ro.disconnect();
}, [progress]);
useEffect(() => {
if (still || !active) return;
const controls = animate(progress, [0, 1, 1], { duration: 4.2, times: [0, 0.62, 1], ease: [0.45, 0, 0.2, 1], repeat: Infinity, repeatDelay: 0.3 });
return () => controls.stop();
}, [active, still, progress]);
const point = (v: number) => (pathRef.current && length.current ? pathRef.current.getPointAtLength(v * length.current) : { x: 480, y: 18 });
const dotX = useTransform(progress, (v) => (point(v).x / 480) * size.current.w);
const dotY = useTransform(progress, (v) => (point(v).y / 170) * size.current.h);
const amount = useTransform(progress, (v) => `$${(18.4 + v * 30.2).toFixed(1)}k`);
const reveal = useTransform(progress, (v) => point(v).x);
return (
<div className="relative flex h-full flex-col overflow-hidden rounded-3xl bg-stone-900 p-6 text-white sm:p-7">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold">Revenue, drawn as it lands</h3>
<p className="mt-1 max-w-xs text-sm text-stone-400">Every payment moves the line the second it clears. No nightly export.</p>
</div>
<div className="text-right">
<motion.p className="text-3xl font-semibold tabular-nums tracking-tight text-emerald-300">{amount}</motion.p>
<p className="text-xs text-stone-400">this month</p>
</div>
</div>
<div ref={boxRef} className="relative mt-6 min-h-[150px] flex-1">
<svg viewBox="0 0 480 170" preserveAspectRatio="none" className="absolute inset-0 h-full w-full overflow-visible" aria-hidden="true">
<defs>
<linearGradient id="bento-demo-area" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#34d399" stopOpacity="0.35" />
<stop offset="1" stopColor="#34d399" stopOpacity="0" />
</linearGradient>
<clipPath id="bento-demo-reveal">
<motion.rect x="0" y="0" height="170" style={{ width: reveal }} />
</clipPath>
</defs>
{[30, 76, 122, 168].map((y) => (
<line key={y} x1="0" x2="480" y1={y} y2={y} stroke="#fff" strokeOpacity="0.07" strokeDasharray="3 6" vectorEffect="non-scaling-stroke" />
))}
<path d={`${CHART_PATH} L480 170 L0 170 Z`} fill="url(#bento-demo-area)" clipPath="url(#bento-demo-reveal)" />
<path ref={pathRef} d={CHART_PATH} fill="none" stroke="#34d399" strokeWidth="3" strokeLinecap="round" vectorEffect="non-scaling-stroke" clipPath="url(#bento-demo-reveal)" />
</svg>
{/* The dot is an HTML element so it stays round while the chart stretches. */}
<motion.span aria-hidden="true" className="absolute left-0 top-0 -ml-2 -mt-2 h-4 w-4 rounded-full border-[3px] border-stone-900 bg-emerald-300 shadow-[0_0_0_6px_rgba(52,211,153,0.2)]" style={{ x: dotX, y: dotY }} />
</div>
<div className="mt-4 flex justify-between text-xs text-stone-500" aria-hidden="true">
<span>May 1</span>
<span>May 10</span>
<span>May 20</span>
<span>Today</span>
</div>
</div>
);
}
function RulesTile({ active, still }: { active: boolean; still: boolean }) {
const tick = useTick(active, 900);
// Steps 0-2 switch the rules on one by one, step 3 holds, step 4 clears.
const step = still ? 3 : tick % 5;
return (
<div className="flex h-full flex-col rounded-3xl border border-stone-200 bg-white p-6 sm:p-7">
<h3 className="text-lg font-semibold text-stone-900">Rules that run themselves</h3>
<p className="mt-1 text-sm text-stone-600">Switch a rule on once. It handles the follow-up from then on.</p>
<ul className="mt-6 space-y-2.5">
{rules.map((r, i) => {
const on = step !== 4 && step > i;
return (
<li key={r.when} className={`flex items-center justify-between gap-3 rounded-2xl border px-3.5 py-3 transition-colors duration-500 ${on ? "border-emerald-200 bg-emerald-50" : "border-stone-200 bg-stone-50"}`}>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-stone-900">{r.when}</p>
<p className="truncate text-xs text-stone-600">{r.then}</p>
</div>
<span aria-hidden="true" className={`flex h-6 w-11 flex-none items-center rounded-full p-0.5 transition-colors duration-300 ${on ? "bg-emerald-600" : "bg-stone-300"}`}>
<motion.span className="h-5 w-5 rounded-full bg-white shadow" animate={{ x: on ? 20 : 0 }} transition={still ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 28 }} />
</span>
</li>
);
})}
</ul>
</div>
);
}
function PeopleTile({ active, still }: { active: boolean; still: boolean }) {
const tick = useTick(active, 900);
const count = still ? people.length : Math.min(people.length, (tick % (people.length + 2)) + 1);
const visible = people.slice(0, count);
return (
<div className="flex h-full flex-col rounded-3xl border border-stone-200 bg-white p-6 sm:p-7">
<h3 className="text-lg font-semibold text-stone-900">Everyone in one room</h3>
<p className="mt-1 text-sm text-stone-600">Finance, support and the founder look at the same numbers, live.</p>
<div className="mt-auto flex items-center justify-between gap-3 pt-8">
<div className="flex h-12 items-center">
<AnimatePresence initial={false}>
{visible.map((p, i) => (
<motion.span
key={p.initials}
initial={still ? false : { opacity: 0, scale: 0.2, x: 26 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.4, transition: { duration: 0.18 } }}
transition={still ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 18 }}
style={{ zIndex: i }}
className={`relative -ml-2 flex h-12 w-12 items-center justify-center rounded-full border-[3px] border-white text-sm font-semibold text-white first:ml-0 ${p.tone}`}
>
{p.initials}
</motion.span>
))}
</AnimatePresence>
</div>
<p className="flex flex-none items-center gap-2 rounded-full bg-stone-100 px-3 py-1.5 text-sm font-medium text-stone-800">
<span className="relative flex h-2 w-2" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-70 motion-reduce:animate-none" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-600" />
</span>
<span className="tabular-nums">{count}</span> online
</p>
</div>
</div>
);
}
function AskTile({ active, still }: { active: boolean; still: boolean }) {
const tick = useTick(active, 60);
// 60ms ticks: type the question, show the answer, hold, start again.
const total = question.length + 70;
const t = still ? total : tick % total;
const typed = question.slice(0, Math.min(question.length, t));
const answered = t > question.length + 6;
return (
<div className="flex h-full flex-col rounded-3xl border border-stone-200 bg-white p-6 sm:p-7">
<h3 className="text-lg font-semibold text-stone-900">Ask in plain words</h3>
<p className="mt-1 text-sm text-stone-600">Type a question, get a chart. No query language to learn.</p>
<div className="mt-6 flex min-h-[44px] items-center gap-2.5 rounded-xl border border-stone-300 bg-stone-50 px-3.5 text-sm text-stone-900">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" className="h-4 w-4 flex-none text-stone-500">
<circle cx="9" cy="9" r="5.5" />
<path d="m13.5 13.5 3.5 3.5" />
</svg>
<span className="truncate">{typed}</span>
{!answered && <span aria-hidden="true" className="-ml-2 h-4 w-0.5 flex-none animate-pulse bg-emerald-600 motion-reduce:animate-none" />}
</div>
<ul className="mt-3 space-y-2">
{answers.map((a, i) => (
<li key={a.label} className="relative overflow-hidden rounded-lg bg-stone-100">
<motion.span aria-hidden="true" className="absolute inset-y-0 left-0 origin-left rounded-lg bg-emerald-200" style={{ width: a.width }} initial={false} animate={{ scaleX: answered ? 1 : 0 }} transition={still ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 20, delay: answered ? i * 0.09 : 0 }} />
<span className="relative flex items-center justify-between px-3 py-1.5 text-xs">
<span className="font-medium text-stone-800">{a.label}</span>
<motion.span className="font-semibold tabular-nums text-emerald-800" initial={false} animate={{ opacity: answered ? 1 : 0 }} transition={{ delay: answered ? 0.15 + i * 0.09 : 0, duration: 0.2 }}>
{a.value}
</motion.span>
</span>
</li>
))}
</ul>
</div>
);
}
function KeysTile({ active, still }: { active: boolean; still: boolean }) {
const tick = useTick(active, 900);
// Steps: rest, Ctrl down, K down, palette opens, hold, hold.
const step = still ? 4 : tick % 6;
const ctrl = step >= 1 && step <= 2;
const k = step === 2;
const open = step >= 3;
const key = (down: boolean) => ({ y: down ? 4 : 0, boxShadow: down ? "0 1px 0 0 #a8a29e" : "0 5px 0 0 #a8a29e" });
const spring = still ? { duration: 0 } : { type: "spring" as const, stiffness: 700, damping: 26 };
return (
<div className="flex h-full flex-col overflow-hidden rounded-3xl border border-stone-200 bg-white p-6 sm:p-7">
<h3 className="text-lg font-semibold text-stone-900">Two keys to anything</h3>
<p className="mt-1 text-sm text-stone-600">Jump to a customer, an invoice or a report without touching the mouse.</p>
<div className="relative mt-auto flex h-24 items-end justify-center pt-6">
<div className="flex items-center gap-2.5 pb-1.5" aria-hidden="true">
<motion.span animate={key(ctrl)} transition={spring} className="flex h-12 w-16 items-center justify-center rounded-xl border border-stone-300 bg-stone-50 text-sm font-semibold text-stone-800">Ctrl</motion.span>
<motion.span animate={key(k)} transition={spring} className="flex h-12 w-12 items-center justify-center rounded-xl border border-stone-300 bg-stone-50 text-sm font-semibold text-stone-800">K</motion.span>
</div>
<AnimatePresence>
{open && (
<motion.div
initial={still ? false : { opacity: 0, y: 14, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.97, transition: { duration: 0.15 } }}
transition={still ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 26 }}
className="absolute inset-x-0 bottom-0 flex h-16 items-center gap-3 rounded-2xl bg-stone-900 px-4 text-sm text-white shadow-xl"
>
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-emerald-500 text-xs font-bold text-stone-950" aria-hidden="true">#</span>
<span className="min-w-0">
<span className="block truncate font-medium">Invoice 2041, Marlow & Finch</span>
<span className="block truncate text-xs text-stone-400">Paid yesterday, $1,280.00</span>
</span>
<span className="ml-auto flex-none rounded-md border border-white/20 px-1.5 py-0.5 text-[11px] text-stone-300" aria-hidden="true">Enter</span>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
export default function AnimatedBentoGridWithLiveMiniDemos() {
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { amount: 0.15 });
const still = !!useReducedMotion();
const active = inView && !still;
const tiles = [
{ span: "md:col-span-4", node: <ChartTile active={active} still={still} /> },
{ span: "md:col-span-2", node: <RulesTile active={active} still={still} /> },
{ span: "md:col-span-2", node: <PeopleTile active={active} still={still} /> },
{ span: "md:col-span-2", node: <AskTile active={active} still={still} /> },
{ span: "md:col-span-2", node: <KeysTile active={active} still={still} /> },
];
return (
<section ref={ref} className="px-5 py-16 text-stone-900 sm:px-6 sm:py-24" style={{ backgroundColor: "#f4f1ea" }}>
<div className="mx-auto max-w-6xl">
<div className="max-w-2xl">
<p className="text-sm font-semibold text-emerald-700">Ledgerline for small teams</p>
<h2 className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">The money side of your business, awake all day.</h2>
<p className="mt-4 text-lg text-stone-600">Five small things that used to take a spreadsheet, a reminder and a meeting.</p>
</div>
<div className="mt-12 grid gap-4 md:grid-cols-6">
{tiles.map((t, i) => (
<motion.div
key={i}
className={t.span}
initial={still ? false : { opacity: 0, y: 24, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={still ? { duration: 0 } : { type: "spring", stiffness: 130, damping: 20, delay: 0.07 * i }}
>
{t.node}
</motion.div>
))}
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/animated-bento-grid-with-live-mini-demos.json
Each demo is ordinary markup driven by a counter. A small hook, `useTick`, increases a number on an interval, and the tile works out what to show from that number: which switch is on, how many avatars are visible, how many characters are typed. Springs from framer-motion handle the movement between states.
One motion value runs from 0 to 1. It is passed through `getPointAtLength` to find the point that far along the path. A clip rectangle is widened to that point, which reveals the line and the area under it, the dot is moved to it, and a formatter turns the same value into the dollar total, so all of them always agree.
No. The demos animate transform and opacity, the chart updates one path, and every loop stops when the grid leaves the screen. The fastest timer fires about sixteen times a second and re-renders only its own tile.
Yes. Add an entry to the `tiles` array with a column span and your component. Spans in a row should add up to six. For a sixth tile, make the second row `md:col-span-2` three times and give the new one `md:col-span-6` or split a third row.
Yes. It uses core utilities only. The few custom values, such as the page colour, are inline styles so there is no config to copy.
Related: Bento Grid with Six Feature Tiles, Three Column Feature Grid with Icons, Auto-Advancing Feature List with Progress and Preview.
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.