INV-1042Kestrel StudioPaid
INV-1043Brightwater DesignSent
INV-1044Oakline JoineryScheduled
Recurring invoices
Set the schedule once. Each one goes out on the day, numbered and filed.
Animated sections
A feature grid whose tiles rise in one after another, each with a small live detail, and lift under the pointer.
4 tiles · staggered reveal · hover lift · invoice rows · typing caret · sync flow · report bars
Set the schedule once. Each one goes out on the day, numbered and filed.
Hi Meera, a gentle nudge on INV-1043 — no rush if it’s already on its way
Write it once, in your tone. We send it when it’s due.
Your totals stay in your home currency.
Payments sync every hour and land matched, so the report is right when you open it.
import { CreditCard, Repeat } from "lucide-react";
import { cn } from "@/lib/utils";
/* Add once to globals.css:
@keyframes tile-in { from { opacity: 0; transform: translateY(18px) scale(.98); } }
@keyframes dash-flow { to { stroke-dashoffset: -10; } }
@keyframes blink { to { visibility: hidden; } }
*/
const invoices = [
{ id: "INV-1042", client: "Kestrel Studio", status: "Paid", tone: "bg-success-soft text-success-fg" },
{ id: "INV-1043", client: "Brightwater Design", status: "Sent", tone: "bg-info-soft text-info-fg" },
{ id: "INV-1044", client: "Oakline Joinery", status: "Scheduled", tone: "bg-surface-2 text-fg-muted" },
];
const currencies = ["₹", "$", "€", "£", "¥", "AED"];
const report = [
{ label: "Paid", value: 82 },
{ label: "Pending", value: 46 },
{ label: "Overdue", value: 14 },
];
function Tile({ index, className, title, text, children }: { index: number; className?: string; title: string; text: string; children: React.ReactNode }) {
return (
<article
// "backwards" fill: after the entrance the hover lift takes over
className={cn(
"grid min-h-[190px] content-between gap-4 overflow-hidden rounded-lg border border-border bg-surface p-[18px] transition duration-300 ease-taste hover:-translate-y-[3px] hover:border-border-strong hover:shadow-[0_18px_40px_-26px_rgb(0_0_0/.4)] animate-[tile-in_.7s_cubic-bezier(.2,.7,.1,1)_backwards] motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
style={{ animationDelay: 150 + index * 100 + "ms" }}
>
{children}
<div>
<h3 className="text-[15px] font-medium tracking-tight text-fg">{title}</h3>
<p className="mt-1 text-[13.5px] leading-relaxed text-fg-muted">{text}</p>
</div>
</article>
);
}
export function BentoReveal() {
return (
<section aria-labelledby="bento-title" className="bg-bg py-20 font-sans">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<p className="text-xs font-medium text-fg-subtle">Features</p>
<h2 id="bento-title" className="mt-2 max-w-lg text-balance text-3xl font-medium tracking-tight text-fg">Everything between “sent” and “paid”</h2>
<div className="mt-8 grid gap-3 md:grid-cols-3">
<Tile index={0} className="md:col-span-2" title="Recurring invoices" text="Set the schedule once. Each one goes out on the day, numbered and filed.">
<div className="grid gap-1.5">
{invoices.map((inv, i) => (
<div key={inv.id} className="flex items-center gap-2.5 rounded border border-border bg-bg px-2.5 py-2 text-[12.5px] animate-rise motion-reduce:animate-none" style={{ animationDelay: 450 + i * 70 + "ms" }}>
<span className="tabular-nums text-fg-subtle">{inv.id}</span>
<span className="min-w-0 flex-1 truncate text-fg">{inv.client}</span>
<span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", inv.tone)}>{inv.status}</span>
</div>
))}
</div>
</Tile>
<Tile index={1} title="Reminders in your words" text="Write it once, in your tone. We send it when it’s due.">
<p className="justify-self-start rounded-lg rounded-bl-sm border border-border bg-bg px-3 py-2 text-[12.5px] leading-relaxed text-fg">
Hi Meera, a gentle nudge on INV-1043 — no rush if it’s already on its way
<span className="ml-0.5 inline-block h-[1em] w-[1.5px] translate-y-0.5 bg-primary animate-[blink_1s_steps(2,start)_infinite] motion-reduce:animate-none" />
</p>
</Tile>
<Tile index={2} title="Paid in 24 currencies" text="Your totals stay in your home currency.">
<div className="flex flex-wrap gap-1.5">
{currencies.map((c, i) => (
<span key={c} className="grid h-[30px] min-w-[38px] place-items-center rounded border border-border bg-bg px-2 text-[12.5px] font-medium text-fg animate-pop motion-reduce:animate-none" style={{ animationDelay: 650 + i * 70 + "ms" }}>
{c}
</span>
))}
</div>
</Tile>
<Tile index={3} className="md:col-span-2" title="Bank feeds and reports" text="Payments sync every hour and land matched, so the report is right when you open it.">
<div className="grid gap-3.5">
<div className="flex items-center gap-2">
<span className="inline-flex h-[30px] items-center gap-1.5 rounded border border-border bg-bg px-2.5 text-xs text-fg"><CreditCard className="size-3.5" />Bank feed</span>
<svg viewBox="0 0 100 12" preserveAspectRatio="none" className="h-3 min-w-10 flex-1" aria-hidden>
<line x1="2" y1="6" x2="98" y2="6" className="stroke-primary [stroke-dasharray:4_6] [stroke-linecap:round] [stroke-width:2] animate-[dash-flow_1.1s_linear_infinite] motion-reduce:animate-none" />
</svg>
<span className="inline-flex h-[30px] items-center gap-1.5 rounded border border-border bg-bg px-2.5 text-xs text-fg"><Repeat className="size-3.5" />Ledger</span>
</div>
<div className="grid gap-2 text-[11.5px] text-fg-muted">
{report.map((r, i) => (
<div key={r.label} className="grid grid-cols-[58px_1fr] items-center gap-2">
<span>{r.label}</span>
<div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
<span className="block h-full origin-left rounded-full bg-primary animate-grow-x motion-reduce:animate-none" style={{ width: r.value + "%", animationDelay: 750 + i * 70 + "ms" }} />
</div>
</div>
))}
</div>
</div>
</Tile>
</div>
</div>
</section>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.