Bento
A grid of unequal tiles, each a small moving illustration of one thing the product does.
Workspace bento
bento-01Four unequal tiles that move: the revenue line drawing itself, the count of active accounts rolling up, alerts arriving one on another and a pointer walking down the accounts table.
import * as React from "react"
import { ArrowUpRightIcon, MousePointer2Icon, TrendingDownIcon, UserRoundXIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { LogoMark } from "@/components/ui/logo-mark"
import { StatusBadge } from "@/components/ui/status-badge"
import { BentoStage } from "./bento-01.client"
import { ACCOUNTS, ACCOUNTS_KPI, ALERTS, EYEBROW, HEADLINE, LEDE, MOTION_LABEL, REVENUE, type AlertKind } from "./bento-01.data"
/** One tile: its heading and line on the frame, its picture on the sheet. */
function Tile({ title, body, className, children }: { title: string; body: string; className?: string; children: React.ReactNode }) {
return (
<li className={cn("flex min-w-0", className)}>
<Card className="w-full">
<CardHeader>
<CardTitle>
<h3>{title}</h3>
</CardTitle>
<CardDescription className="text-pretty">{body}</CardDescription>
</CardHeader>
<CardContent className="relative flex min-h-60 flex-col justify-center">{children}</CardContent>
</Card>
</li>
)
}
/** A smooth line through the points: each segment a cubic whose handles follow its neighbours. */
function smoothPath(points: readonly (readonly [number, number])[]): string {
const r = (n: number) => Math.round(n * 10) / 10
return points.reduce((path, [x, y], i) => {
if (i === 0) return `M${r(x)} ${r(y)}`
const [x0, y0] = points[i - 1]
const [xa, ya] = points[i - 2] ?? points[i - 1]
const [xb, yb] = points[i + 1] ?? points[i]
return `${path} C${r(x0 + (x - xa) / 6)} ${r(y0 + (y - ya) / 6)} ${r(x - (xb - x0) / 6)} ${r(y - (yb - y0) / 6)} ${r(x)} ${r(y)}`
}, "")
}
/** The revenue line drawing itself: the line, then its wash, then the dot at today. One image, named by its caption. */
function RevenuePicture() {
const { values } = REVENUE
const x = (i: number) => 8 + (i * 464) / (values.length - 1)
const y = (v: number) => 136 - ((v - 28) / 22) * 124
const points = values.map((v, i) => [x(i), y(v)] as const)
const line = smoothPath(points)
const [endX, endY] = points[points.length - 1]
return (
<div role="img" aria-label={REVENUE.caption} className="flex flex-col gap-4">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-muted-foreground">{REVENUE.label}</span>
<span className="flex items-center gap-2">
<span className="type-numeral text-xl">{REVENUE.figure}</span>
<StatusBadge status="up" variant="success" label={REVENUE.delta} />
</span>
</div>
<svg viewBox="0 0 480 150" className="h-auto w-full overflow-visible">
{[30, 40, 50].map((tick) => (
<line key={tick} x1={0} x2={480} y1={y(tick)} y2={y(tick)} stroke="var(--chart-grid)" strokeDasharray="3 4" />
))}
<path
d={`${line} L${endX} 150 L8 150 Z`}
fill="var(--chart-1)"
fillOpacity={0.1}
className="motion-safe:in-data-[playback=running]:animate-[bento-01-fill_12s_linear_infinite]"
/>
<path
d={line}
fill="none"
stroke="var(--chart-1)"
strokeWidth={2}
strokeLinecap="round"
pathLength={1}
strokeDasharray="1 1"
className="motion-safe:in-data-[playback=running]:animate-[bento-01-draw_12s_linear_infinite]"
/>
<circle
cx={endX}
cy={endY}
r={4.5}
fill="var(--card)"
stroke="var(--chart-1)"
strokeWidth={2}
className="origin-center [transform-box:fill-box] motion-safe:in-data-[playback=running]:animate-[bento-01-pop_12s_var(--ease-standard)_infinite]"
/>
</svg>
<div className="flex justify-between text-xs text-muted-foreground max-sm:[&>*:nth-child(even)]:invisible">
{REVENUE.months.map((month) => (
<span key={month}>{month}</span>
))}
</div>
</div>
)
}
const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
/** One digit of a counter: a strip of the ten figures in a one-figure window, set on its own figure and rolled from the older one. */
function Digit({ to, from }: { to: number; from: number }) {
const vars = { transform: `translateY(-${to}em)`, "--from": `-${from}em`, "--to": `-${to}em` } as React.CSSProperties
return (
<span className="relative inline-block overflow-hidden">
{/* The window is as wide as the figure it lands on, so the still frame spaces like plain text. */}
<span className="invisible">{to}</span>
<span
style={vars}
className="absolute inset-x-0 top-0 flex flex-col items-center motion-safe:in-data-[playback=running]:animate-[bento-01-roll_12s_linear_infinite]"
>
{DIGITS.map((digit) => (
<span key={digit}>{digit}</span>
))}
</span>
</span>
)
}
/** The count of active accounts, rolling up from last month's to this month's; the sentence is what a screen reader hears. */
function CounterPicture() {
const now = formatNumber(ACCOUNTS_KPI.value)
const then = formatNumber(ACCOUNTS_KPI.from).padStart(now.length, "0")
return (
<div role="img" aria-label={ACCOUNTS_KPI.caption} className="flex flex-col items-start gap-3">
<span className="text-xs text-muted-foreground">{ACCOUNTS_KPI.label}</span>
<span className="type-numeral flex text-5xl leading-none">
{[...now].map((char, i) =>
/\d/.test(char) ? <Digit key={i} to={Number(char)} from={/\d/.test(then[i]) ? Number(then[i]) : 0} /> : <span key={i}>{char}</span>
)}
</span>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<StatusBadge status="up" variant="success" label={ACCOUNTS_KPI.delta} />
{ACCOUNTS_KPI.note}
</span>
<span className="mt-4 flex h-16 w-full items-end gap-1">
{ACCOUNTS_KPI.weeks.map((week, index) => (
<span
key={index}
style={{ height: `${(week / Math.max(...ACCOUNTS_KPI.weeks)) * 100}%` }}
className={cn("flex-1 rounded-t-[3px]", index === ACCOUNTS_KPI.weeks.length - 1 ? "bg-(--chart-1)" : "bg-(--chart-1)/40")}
/>
))}
</span>
<span className="text-xs text-muted-foreground">{ACCOUNTS_KPI.weeksLabel}</span>
</div>
)
}
const ALERT_LOOK: Record<AlertKind, { icon: LucideIcon; tile: string }> = {
churn: { icon: UserRoundXIcon, tile: "bg-danger-muted text-danger" },
upgrade: { icon: ArrowUpRightIcon, tile: "bg-success-muted text-success" },
trial: { icon: TrendingDownIcon, tile: "bg-warning-muted text-warning" },
}
/** Each card's own arrival, newest first: the oldest lands on top and is pushed down as the others come. */
const ARRIVE = [
"motion-safe:in-data-[playback=running]:animate-[bento-01-arrive-0_12s_var(--ease-standard)_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-01-arrive-1_12s_var(--ease-standard)_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-01-arrive-2_12s_var(--ease-standard)_infinite]",
]
/** Three alerts arriving one at a time, each pushing the last one down. Decoration: the tile's words say what alerts do. */
function AlertsPicture() {
return (
<ul aria-hidden="true" inert className="flex flex-col gap-2">
{ALERTS.items.map((alert, index) => {
const { icon: Icon, tile } = ALERT_LOOK[alert.kind]
return (
<li key={alert.title} className={cn("flex h-14 items-center gap-3 elev-1 px-3", ARRIVE[index])}>
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md", tile)}>
<Icon className="size-4" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{alert.title}</span>
<span className="truncate text-xs text-muted-foreground">{alert.detail}</span>
</span>
<span className="self-start pt-2 text-xs text-muted-foreground tabular-nums">{alert.time}</span>
</li>
)
})}
</ul>
)
}
/** Where each row is lit while the pointer rests on it: the second row is home, the rest take their turn. */
const ROW_LIGHT = [
"motion-safe:in-data-[playback=running]:animate-[bento-01-visit_12s_linear_7.2s_infinite]",
"bg-muted/50 motion-safe:in-data-[playback=running]:animate-[bento-01-home_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-01-visit_12s_linear_2.4s_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-01-visit_12s_linear_4.8s_infinite]",
]
/** The accounts table with a pointer moving down it and each row lighting as it passes. Decoration beside the tile's words. */
function AccountsPicture() {
const { columns, rows } = ACCOUNTS
const grid = "grid grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.8fr)] items-center gap-3 px-3"
return (
<div aria-hidden="true" inert className="relative">
<div className={cn(grid, "h-9 border-b border-rule text-xs text-muted-foreground")}>
<span>{columns.account}</span>
<span>{columns.plan}</span>
<span className="text-right">{columns.mrr}</span>
<span className="text-right">{columns.tickets}</span>
</div>
{rows.map((row, index) => (
<div key={row.account} className={cn(grid, "h-10 rounded-md border-b border-border text-sm last:border-b-0", ROW_LIGHT[index])}>
<LogoMark name={row.account} size="sm" className="truncate" />
<span className="truncate text-muted-foreground">{row.plan}</span>
<span className="text-right tabular-nums">{row.mrr}</span>
<span className="text-right text-muted-foreground tabular-nums">{row.tickets}</span>
</div>
))}
<MousePointer2Icon className="absolute top-[5.5rem] left-[86%] size-5 fill-foreground text-background motion-safe:in-data-[playback=running]:animate-[bento-01-pointer_12s_var(--ease-emphasized)_infinite]" />
</div>
)
}
/**
* The overview bento: four unequal tiles under one claim, each a small moving
* picture of a Northwind workspace built from the kit's own parts — the
* revenue line drawing itself, the count of active accounts rolling up, alerts
* arriving one on another, and a pointer walking down the accounts table
* lighting each row. A wide tile and a narrow one, then a narrow and a wide:
* six columns from `lg`, two on a tablet, one on a phone.
*
* The pictures move only on the kit's MotionStage, which the island hands
* this section's keyframes: it runs while the grid is on screen, the tab is
* in front and the reader has not asked for less motion, and its Pause motion
* toggle holds them still. Held still, each shows its final frame, so the
* information is there without the motion. The
* chart and the counter carry figures, so each is an image named by the
* sentence it shows; the alerts and the table are decoration beside words
* that say the same. A server component; every word and figure is in
* `./bento-01.data`.
*/
export function Bento01() {
const titleId = React.useId()
return (
<section
data-section="bento-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-3xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<BentoStage label={MOTION_LABEL}>
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
<Tile title={REVENUE.title} body={REVENUE.body} className="md:col-span-2 lg:col-span-4">
<RevenuePicture />
</Tile>
<Tile title={ACCOUNTS_KPI.title} body={ACCOUNTS_KPI.body} className="lg:col-span-2">
<CounterPicture />
</Tile>
<Tile title={ALERTS.title} body={ALERTS.body} className="lg:col-span-2">
<AlertsPicture />
</Tile>
<Tile title={ACCOUNTS.title} body={ACCOUNTS.body} className="md:col-span-2 lg:col-span-4">
<AccountsPicture />
</Tile>
</ul>
</BentoStage>
</div>
</div>
</section>
)
}
export default Bento01"use client"
import * as React from "react"
import { MotionStage } from "@/components/ui/motion-stage"
/**
* What bento-01's pictures move on. Every loop starts and ends on the picture
* as it is drawn without motion — the final, informative frame — so a loop can
* begin at any moment without a jump, and stopping it lands on the frame that
* says the most. The tiles apply these through Tailwind, keyed to a running
* stage; the kit's MotionStage decides when that is and hoists this stylesheet
* into the head once, however many copies of the section a page holds, so the
* section installs with its motion and nothing is added to your CSS.
*/
const KEYFRAMES = `
@keyframes bento-01-draw {
0%, 62% { stroke-dashoffset: 0; opacity: 1 }
66% { stroke-dashoffset: 0; opacity: 0 }
67% { stroke-dashoffset: 1; opacity: 0 }
68% { stroke-dashoffset: 1; opacity: 1; animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1) }
90%, 100% { stroke-dashoffset: 0; opacity: 1 }
}
@keyframes bento-01-fill {
0%, 62% { opacity: 1 }
66%, 86% { opacity: 0 }
96%, 100% { opacity: 1 }
}
@keyframes bento-01-pop {
0%, 62% { opacity: 1; transform: scale(1) }
66%, 89% { opacity: 0; transform: scale(0.6) }
93%, 100% { opacity: 1; transform: scale(1) }
}
@keyframes bento-01-roll {
0%, 58% { transform: translateY(var(--to)); opacity: 1 }
62% { transform: translateY(var(--to)); opacity: 0 }
63% { transform: translateY(var(--from)); opacity: 0 }
66% { transform: translateY(var(--from)); opacity: 1; animation-timing-function: cubic-bezier(0.2, 0, 0, 1) }
86%, 100% { transform: translateY(var(--to)); opacity: 1 }
}
@keyframes bento-01-arrive-0 {
0%, 55% { opacity: 1; transform: translateY(0) }
60% { opacity: 0; transform: translateY(0) }
61%, 88% { opacity: 0; transform: translateY(-8px) }
92%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-01-arrive-1 {
0%, 55% { opacity: 1; transform: translateY(0) }
60% { opacity: 0; transform: translateY(0) }
61%, 75% { opacity: 0; transform: translateY(-72px) }
79%, 88% { opacity: 1; transform: translateY(-64px) }
92%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-01-arrive-2 {
0%, 55% { opacity: 1; transform: translateY(0) }
60% { opacity: 0; transform: translateY(0) }
61% { opacity: 0; transform: translateY(-136px) }
65%, 75% { opacity: 1; transform: translateY(-128px) }
79%, 88% { opacity: 1; transform: translateY(-64px) }
92%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-01-pointer {
0%, 16% { transform: translate(0, 0) }
20%, 36% { transform: translate(-36px, 40px) }
40%, 56% { transform: translate(-12px, 80px) }
60%, 76% { transform: translate(-52px, -40px) }
80%, 100% { transform: translate(0, 0) }
}
@keyframes bento-01-home {
0%, 16% { background-color: color-mix(in oklch, var(--muted) 50%, transparent) }
19%, 78% { background-color: transparent }
81%, 100% { background-color: color-mix(in oklch, var(--muted) 50%, transparent) }
}
@keyframes bento-01-visit {
0%, 100% { background-color: transparent }
3%, 16% { background-color: color-mix(in oklch, var(--muted) 50%, transparent) }
19% { background-color: transparent }
}
`
/** bento-01's stage: the kit's MotionStage, carrying this section's keyframes. */
export function BentoStage({ label, children }: { label: string; children: React.ReactNode }) {
return (
<MotionStage label={label} keyframes={{ href: "bento-01-keyframes", css: KEYFRAMES }}>
{children}
</MotionStage>
)
}/**
* Every word and figure `bento-01` sets: the claim, the words on the motion
* switch, and four tiles — each a heading, one line, and what its moving
* picture shows. The pictures are of a sample Northwind workspace, so their
* figures are copy like the headings: written once, here. A picture that
* carries figures also carries the sentence a screen reader hears instead.
*/
import type { LogoName } from "@/components/ui/logo-mark"
export const EYEBROW = "The workspace"
export const HEADLINE = "The weekly review, ready before anyone asks"
export const LEDE =
"Revenue, alerts and accounts from the tools you already run, kept current while the team gets on with the week."
/** The name of the toggle that holds every picture still: pressed, it holds them; pressed again, they move. */
export const MOTION_LABEL = "Pause motion"
export const REVENUE = {
title: "Revenue, redrawn as it lands",
body: "MRR from billing, refreshed every five minutes and set against the year before it.",
label: "MRR, last 12 months",
figure: "$48.2K",
delta: "+8.1%",
months: ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"],
/** Thousands of dollars, one a month, oldest first. */
values: [31.4, 32.1, 33.8, 33.2, 35.6, 36.9, 38.4, 39.1, 41.7, 43.0, 44.6, 48.2],
caption: "Line chart of monthly recurring revenue from October to September, rising from $31.4K to $48.2K, up 8.1% on August.",
}
export const ACCOUNTS_KPI = {
title: "A figure you can open",
body: "Every number leads to the rows it was counted from.",
label: "Active accounts",
/** The figure the picture counts up to, and the one it counts up from. */
value: 2418,
from: 2361,
delta: "+57",
note: "since August",
/** New accounts a week, the last 12 weeks, oldest first. */
weeks: [9, 12, 8, 14, 11, 13, 10, 15, 12, 16, 14, 18],
weeksLabel: "New accounts a week, last 12 weeks",
caption: "Active accounts: 2,418, up 57 since August; new accounts a week have risen from 9 to 18 over the last 12 weeks.",
}
export type AlertKind = "churn" | "upgrade" | "trial"
export const ALERTS = {
title: "Alerts before the Monday meeting",
body: "A change outside its usual range reaches the person who owns it.",
/** Newest first: the picture stacks them the way a feed does. */
items: [
{ kind: "churn", title: "Churn above its usual range", detail: "3 accounts, 2.6× a normal week", time: "09:14" },
{ kind: "upgrade", title: "Meridian moved to Business", detail: "+$1,840 MRR", time: "08:52" },
{ kind: "trial", title: "Trial conversion fell to 18%", detail: "From 24% last week", time: "08:30" },
] satisfies { kind: AlertKind; title: string; detail: string; time: string }[],
}
export const ACCOUNTS = {
title: "Every account on one row",
body: "Billing, CRM and support records matched into one customer you can open.",
columns: { account: "Account", plan: "Plan", mrr: "MRR", tickets: "Tickets" },
/** The second row is the one the pointer rests on when the picture holds still. */
rows: [
{ account: "Keystone", plan: "Business", mrr: "$4,120", tickets: "2" },
{ account: "Meridian", plan: "Business", mrr: "$3,860", tickets: "0" },
{ account: "Tidewater", plan: "Team", mrr: "$1,190", tickets: "5" },
{ account: "Lumen", plan: "Team", mrr: "$980", tickets: "1" },
] satisfies { account: LogoName; plan: string; mrr: string; tickets: string }[],
}npx shadcn@latest add @vibra/bento-01Monitoring bento
bento-02A metric leaving its usual band and the alert that says why, a live map of who is working where, sources taking turns to sync and a day of signups far past the rest, marked.
import * as React from "react"
import { ActivityIcon, BellRingIcon, BriefcaseIcon, LifeBuoyIcon, ReceiptTextIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusBadge } from "@/components/ui/status-badge"
import { WorldMap } from "@/components/ui/world-map"
import { BentoStage } from "./bento-02.client"
import { ANOMALY, EYEBROW, HEADLINE, LEDE, LIVE, MOTION_LABEL, SYNCS, THRESHOLD, type SourceIcon } from "./bento-02.data"
/** One tile: its heading and line on the frame, its picture on the sheet. */
function Tile({ title, body, className, children }: { title: string; body: string; className?: string; children: React.ReactNode }) {
return (
<li className={cn("flex min-w-0", className)}>
<Card className="w-full">
<CardHeader>
<CardTitle>
<h3>{title}</h3>
</CardTitle>
<CardDescription className="text-pretty">{body}</CardDescription>
</CardHeader>
<CardContent className="relative flex min-h-56 flex-col justify-center">{children}</CardContent>
</Card>
</li>
)
}
/** The metric inside its usual band, then out of it: the line draws, the point that left the band rings, the alert arrives. */
function ThresholdPicture() {
const { values, band, alert } = THRESHOLD
const x = (i: number) => 10 + (i * 280) / (values.length - 1)
const y = (v: number) => 270 - (v / 9) * 256
const line = values.map((v, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(" ")
const endX = x(values.length - 1)
const endY = y(values[values.length - 1])
return (
<div role="img" aria-label={THRESHOLD.caption} className="flex flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{THRESHOLD.label}</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-xs bg-muted ring-1 ring-border" />
{THRESHOLD.range}
</span>
</div>
<svg viewBox="0 0 300 280" className="h-auto w-full overflow-visible">
<rect x={0} y={y(band[1])} width={300} height={y(band[0]) - y(band[1])} fill="var(--muted)" />
{band.map((edge) => (
<line key={edge} x1={0} x2={300} y1={y(edge)} y2={y(edge)} stroke="var(--chart-grid)" strokeDasharray="3 4" />
))}
<path
d={line}
fill="none"
stroke="var(--chart-1)"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
pathLength={1}
strokeDasharray="1 1"
className="motion-safe:in-data-[playback=running]:animate-[bento-02-draw_12s_linear_infinite]"
/>
<circle
cx={endX}
cy={endY}
r={5}
fill="var(--danger)"
opacity={0}
className="origin-center [transform-box:fill-box] motion-safe:in-data-[playback=running]:animate-[bento-02-ring_12s_ease-out_infinite]"
/>
<circle
cx={endX}
cy={endY}
r={4.5}
fill="var(--card)"
stroke="var(--danger)"
strokeWidth={2}
className="origin-center [transform-box:fill-box] motion-safe:in-data-[playback=running]:animate-[bento-02-pop_12s_var(--ease-standard)_infinite]"
/>
</svg>
<div className="mt-auto flex items-start gap-3 elev-1 p-3 motion-safe:in-data-[playback=running]:animate-[bento-02-note_12s_var(--ease-standard)_infinite]">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-danger-muted text-danger">
<BellRingIcon className="size-4" />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">{alert.title}</span>
<span className="text-xs text-muted-foreground">{alert.detail}</span>
<Badge variant="secondary">{alert.sent}</Badge>
</span>
</div>
</div>
)
}
/**
* Where people are working right now: the kit's WorldMap, each place a marker
* that sends out a slow beacon — the markers take turns, a third at a time.
*/
function LivePicture() {
const total = LIVE.places.reduce((sum, place) => sum + place.value, 0)
return (
<div role="img" aria-label={LIVE.caption} className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 text-xs font-medium">
<span className="relative flex size-2">
<span className="absolute inset-0 rounded-full bg-success opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-02-live_2s_ease-out_infinite]" />
<span className="relative size-2 rounded-full bg-success" />
</span>
{LIVE.live}
</span>
<span className="text-sm text-muted-foreground">
<span className="type-numeral text-lg text-foreground">{formatNumber(total)}</span> {LIVE.label}
</span>
</div>
<WorldMap
markers={[...LIVE.places]}
height={190}
className="motion-safe:in-data-[playback=running]:[&_[data-slot=world-map-marker]]:animate-[bento-02-beacon_3s_ease-out_infinite] [&_[data-slot=world-map-marker]:nth-child(3n+2)]:[animation-delay:1s] [&_[data-slot=world-map-marker]:nth-child(3n)]:[animation-delay:2s]"
/>
</div>
)
}
const SOURCE_ICONS: Record<SourceIcon, LucideIcon> = {
billing: ReceiptTextIcon,
crm: BriefcaseIcon,
product: ActivityIcon,
support: LifeBuoyIcon,
}
/** The sources, each taking its turn to sync: the row's bar fills and its word says so, then it settles back. Decoration. */
function SyncPicture() {
return (
<ul aria-hidden="true" inert className="flex flex-col">
{SYNCS.sources.map((source, index) => {
const Icon = SOURCE_ICONS[source.icon]
const delay = { animationDelay: `${0.6 + index * 2.4}s` }
return (
<li key={source.name} className="relative flex h-11 items-center gap-3 border-b border-border last:border-b-0">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-surface ring-1 ring-border">
<Icon className="size-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm">{source.name}</span>
<span className="grid justify-items-end *:col-start-1 *:row-start-1">
<span style={delay} className="text-xs text-muted-foreground tabular-nums motion-safe:in-data-[playback=running]:animate-[bento-02-synced_12s_linear_infinite]">
{source.synced}
</span>
<span style={delay} className="opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-02-syncing_12s_linear_infinite]">
<StatusBadge status="running" variant="info" size="sm" label={SYNCS.syncing} />
</span>
</span>
<span
style={delay}
className="absolute inset-x-0 bottom-0 h-0.5 origin-left rounded-full bg-brand opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-02-progress_12s_linear_infinite]"
/>
</li>
)
})}
</ul>
)
}
/** Fourteen days of signups growing in, the last one far past the rest and marked in the warning tone. */
function AnomalyPicture() {
const peak = Math.max(...ANOMALY.values)
const last = ANOMALY.values.length - 1
return (
<div role="img" aria-label={ANOMALY.caption} className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{ANOMALY.label}</span>
<span className="motion-safe:in-data-[playback=running]:animate-[bento-02-mark_12s_linear_infinite]">
<StatusBadge status="warning" variant="warning" label={ANOMALY.note} />
</span>
</div>
<div className="flex h-28 items-end gap-1">
{ANOMALY.values.map((value, index) => (
<span
key={index}
style={{ height: `${(value / peak) * 100}%`, animationDelay: index === last ? undefined : `${index * 50}ms` }}
className={cn(
"flex-1 origin-bottom rounded-t-[3px]",
index === last
? "bg-warning motion-safe:in-data-[playback=running]:animate-[bento-02-spike_12s_linear_infinite]"
: "bg-(--chart-1)/40 motion-safe:in-data-[playback=running]:animate-[bento-02-grow_12s_linear_infinite]"
)}
/>
))}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
{ANOMALY.days.map((day) => (
<span key={day}>{day}</span>
))}
</div>
</div>
)
}
/**
* The monitoring bento: what Northwind watches, as four moving pictures — a
* metric leaving its usual band and the alert that says why, a live map of
* who is working where, each source taking its turn to sync, and a day of
* signups far past the rest, marked. The alert's tile stands two rows tall on
* the left from `lg`, beside the map across the top and the two smaller tiles
* under it; two columns on a tablet, one on a phone.
*
* The pictures move only on the kit's MotionStage, which the island hands
* this section's keyframes — while on screen, in a tab in front, and for a
* reader who has not asked for less motion — and its Pause motion toggle
* holds them still; still, each shows its final frame. The chart, the map and
* the bars carry figures, so each is an image named by what it shows; the
* syncing list is decoration beside words that say the same. A server
* component; every word and figure is in `./bento-02.data`.
*/
export function Bento02() {
const titleId = React.useId()
return (
<section
data-section="bento-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-2xl text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<BentoStage label={MOTION_LABEL}>
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Tile title={THRESHOLD.title} body={THRESHOLD.body} className="md:row-span-2">
<ThresholdPicture />
</Tile>
<Tile title={LIVE.title} body={LIVE.body} className="lg:col-span-2">
<LivePicture />
</Tile>
<Tile title={SYNCS.title} body={SYNCS.body}>
<SyncPicture />
</Tile>
<Tile title={ANOMALY.title} body={ANOMALY.body} className="md:col-span-2 lg:col-span-1">
<AnomalyPicture />
</Tile>
</ul>
</BentoStage>
</div>
</div>
</section>
)
}
export default Bento02"use client"
import * as React from "react"
import { MotionStage } from "@/components/ui/motion-stage"
/**
* What bento-02's pictures move on. Every loop starts and ends on the picture
* as it is drawn without motion — the final, informative frame — so a loop can
* begin at any moment without a jump, and stopping it lands on the frame that
* says the most. The tiles apply these through Tailwind, keyed to a running
* stage; the kit's MotionStage decides when that is and hoists this stylesheet
* into the head once, however many copies of the section a page holds, so the
* section installs with its motion and nothing is added to your CSS.
*/
const KEYFRAMES = `
@keyframes bento-02-draw {
0%, 60% { stroke-dashoffset: 0; opacity: 1 }
64% { stroke-dashoffset: 0; opacity: 0 }
65% { stroke-dashoffset: 1; opacity: 0 }
66% { stroke-dashoffset: 1; opacity: 1; animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1) }
88%, 100% { stroke-dashoffset: 0; opacity: 1 }
}
@keyframes bento-02-pop {
0%, 60% { opacity: 1; transform: scale(1) }
64%, 87% { opacity: 0; transform: scale(0.5) }
91%, 100% { opacity: 1; transform: scale(1) }
}
@keyframes bento-02-ring {
0%, 16%, 32%, 48% { opacity: 0.5; transform: scale(1) }
8%, 15.9%, 24%, 31.9%, 40%, 47.9%, 56%, 100% { opacity: 0; transform: scale(3.2) }
}
@keyframes bento-02-note {
0%, 60% { opacity: 1; transform: translateY(0) }
64%, 90% { opacity: 0; transform: translateY(6px) }
95%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-02-live {
0% { opacity: 0.6; transform: scale(1) }
70%, 100% { opacity: 0; transform: scale(2.6) }
}
@keyframes bento-02-beacon {
0% { stroke: var(--brand); stroke-width: 0.35; stroke-opacity: 0.7 }
70%, 100% { stroke: var(--brand); stroke-width: 6; stroke-opacity: 0 }
}
@keyframes bento-02-synced {
0%, 100% { opacity: 1 }
1.5%, 17.5% { opacity: 0 }
19% { opacity: 1 }
}
@keyframes bento-02-syncing {
0%, 1.5%, 17.5%, 100% { opacity: 0 }
3%, 16% { opacity: 1 }
}
@keyframes bento-02-progress {
0%, 100% { opacity: 0; transform: scaleX(0) }
2% { opacity: 1; transform: scaleX(0) }
16% { opacity: 1; transform: scaleX(1) }
19% { opacity: 0; transform: scaleX(1) }
}
@keyframes bento-02-grow {
0%, 58% { opacity: 1; transform: scaleY(1) }
62% { opacity: 0; transform: scaleY(1) }
63% { opacity: 0; transform: scaleY(0) }
64% { opacity: 1; transform: scaleY(0); animation-timing-function: cubic-bezier(0.2, 0, 0, 1) }
74%, 100% { opacity: 1; transform: scaleY(1) }
}
@keyframes bento-02-spike {
0%, 58% { opacity: 1; transform: scaleY(1); background-color: var(--warning) }
62% { opacity: 0; transform: scaleY(1); background-color: var(--warning) }
63% { opacity: 0; transform: scaleY(0); background-color: var(--chart-1) }
66% { opacity: 1; transform: scaleY(0); background-color: var(--chart-1); animation-timing-function: cubic-bezier(0.2, 0, 0, 1) }
80% { transform: scaleY(1); background-color: var(--chart-1) }
86%, 100% { opacity: 1; transform: scaleY(1); background-color: var(--warning) }
}
@keyframes bento-02-mark {
0%, 58% { opacity: 1 }
62%, 86% { opacity: 0 }
90%, 100% { opacity: 1 }
}
`
/** bento-02's stage: the kit's MotionStage, carrying this section's keyframes. */
export function BentoStage({ label, children }: { label: string; children: React.ReactNode }) {
return (
<MotionStage label={label} keyframes={{ href: "bento-02-keyframes", css: KEYFRAMES }}>
{children}
</MotionStage>
)
}/**
* Every word and figure `bento-02` sets: the claim, the words on the motion
* switch, and four tiles about what Northwind watches — each a heading, one
* line, and what its moving picture shows. The figures are a sample
* workspace's, written once here; a picture that carries figures also
* carries the sentence a screen reader hears instead.
*/
export const EYEBROW = "Monitoring"
export const HEADLINE = "Know the moment a number leaves its range"
export const LEDE =
"Northwind watches every metric against its own history and tells the person who owns it, with the accounts behind the change."
/** The name of the toggle that holds every picture still: pressed, it holds them; pressed again, they move. */
export const MOTION_LABEL = "Pause motion"
export const THRESHOLD = {
title: "Thresholds that explain themselves",
body: "Each metric learns its usual range; a move outside it becomes an alert with the reason attached.",
label: "Churned MRR, daily",
range: "Usual range",
/** Thousands of dollars a day, oldest first; the last three leave the range. */
values: [2.1, 1.8, 2.4, 2.0, 1.6, 2.2, 2.6, 1.9, 2.3, 2.8, 2.1, 1.7, 2.5, 2.2, 2.9, 2.4, 3.0, 4.4, 6.3, 7.9],
/** The band the metric usually stays in, in the same units. */
band: [1.4, 3.4] as const,
alert: { title: "Above its usual range", detail: "3 accounts, since 09:14", sent: "Sent to Finance" },
caption:
"Line chart of churned MRR over 20 days, inside its usual range of $1.4K to $3.4K a day until the last three days, when it climbs to $7.9K; an alert says it is above its usual range because of 3 accounts and was sent to Finance.",
}
export const LIVE = {
title: "Live across every region",
body: "See who is in a dashboard right now, and where they are working from.",
live: "Live",
label: "people in a dashboard now",
caption: "World map of the 1,284 people in a Northwind dashboard now, most of them in London, 312, and New York, 268.",
/** Where they are; the count the tile prints is the sum of these. */
places: [
{ id: "london", label: "London", lat: 51.5, lng: -0.13, value: 312 },
{ id: "new-york", label: "New York", lat: 40.71, lng: -74.0, value: 268 },
{ id: "berlin", label: "Berlin", lat: 52.52, lng: 13.4, value: 140 },
{ id: "singapore", label: "Singapore", lat: 1.35, lng: 103.82, value: 118 },
{ id: "tokyo", label: "Tokyo", lat: 35.68, lng: 139.69, value: 100 },
{ id: "toronto", label: "Toronto", lat: 43.65, lng: -79.38, value: 96 },
{ id: "sydney", label: "Sydney", lat: -33.87, lng: 151.21, value: 90 },
{ id: "sao-paulo", label: "São Paulo", lat: -23.55, lng: -46.63, value: 84 },
{ id: "mumbai", label: "Mumbai", lat: 19.08, lng: 72.88, value: 76 },
],
}
export type SourceIcon = "billing" | "crm" | "product" | "support"
export const SYNCS = {
title: "Every sync, checked",
body: "Each source says when it last synced, and flags the one that stops.",
syncing: "Syncing",
sources: [
{ icon: "billing", name: "Billing", synced: "2 min ago" },
{ icon: "crm", name: "CRM", synced: "6 min ago" },
{ icon: "product", name: "Product events", synced: "1 min ago" },
{ icon: "support", name: "Support desk", synced: "9 min ago" },
] satisfies { icon: SourceIcon; name: string; synced: string }[],
}
export const ANOMALY = {
title: "Anomalies, marked overnight",
body: "A day far outside the pattern is marked before anyone opens the chart.",
label: "Signups, last 14 days",
/** Signups a day, oldest first; the last is the anomaly. */
values: [42, 38, 45, 40, 44, 39, 47, 43, 41, 46, 44, 48, 45, 131],
note: "+191% on Sep 3",
days: ["Aug 21", "Sep 3"],
caption: "Bar chart of daily signups over 14 days, steady between 38 and 48 until the last day, Sep 3, which reached 131, 191% above the day before; it is marked.",
}npx shadcn@latest add @vibra/bento-02Collaboration bento
bento-03Five tiles about working together, the shared dashboard tall in the middle with two people's pointers on it, beside a comment answered, an invitation typed, a face arriving and a version history.
import * as React from "react"
import { MailIcon, MousePointer2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { Badge } from "@/components/ui/badge"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { BentoStage } from "./bento-03.client"
import { COMMENTS, EYEBROW, HEADLINE, HISTORY, LEDE, MOTION_LABEL, PEOPLE, PRESENCE, SHARE, TOGETHER } from "./bento-03.data"
/** One tile: its heading and line on the frame, its picture on the sheet. Every picture here is decoration beside its words. */
function Tile({ title, body, className, children }: { title: string; body: string; className?: string; children: React.ReactNode }) {
return (
<li className={cn("flex min-w-0", className)}>
<Card className="w-full">
<CardHeader>
<CardTitle>
<h3>{title}</h3>
</CardTitle>
<CardDescription className="text-pretty">{body}</CardDescription>
</CardHeader>
<CardContent className="relative flex min-h-52 flex-col justify-center">
<div aria-hidden="true" inert className="flex flex-1 flex-col justify-center">
{children}
</div>
</CardContent>
</Card>
</li>
)
}
/** A person's face from the kit's `avatarFor`, with their initials until it loads. */
function Face({ name, className }: { name: string; className?: string }) {
return (
<Avatar size="sm" className={cn("after:hidden", className)}>
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
)
}
/** Two people in one dashboard: each pointer carries its owner's face, and one of them picks a week. */
function TogetherPicture() {
const peak = Math.max(...TOGETHER.weeks)
return (
<div className="relative flex h-[26rem] flex-col rounded-lg bg-surface p-3 ring-1 ring-border">
<div className="grid grid-cols-2 gap-2">
{TOGETHER.kpis.map((kpi) => (
<div key={kpi.label} className="rounded-md bg-card p-2.5 ring-1 ring-border">
<span className="text-xs text-muted-foreground">{kpi.label}</span>
<span className="block type-numeral text-lg">{kpi.value}</span>
</div>
))}
</div>
<div className="mt-2 flex flex-1 flex-col rounded-md bg-card p-2.5 ring-1 ring-border">
<span className="text-xs text-muted-foreground">{TOGETHER.chart}</span>
<div className="mt-2 flex flex-1 items-end gap-1.5">
{TOGETHER.weeks.map((week, index) => (
<span
key={index}
style={{ height: `${(week / peak) * 100}%` }}
className={cn(
"flex-1 rounded-t-[3px]",
index === TOGETHER.selected
? "bg-(--chart-1) motion-safe:in-data-[playback=running]:animate-[bento-03-pick_12s_linear_infinite]"
: "bg-(--chart-1)/40"
)}
/>
))}
</div>
</div>
<span className="absolute top-[18%] left-[52%] rounded-md bg-foreground px-2 py-1 text-2xs font-medium text-background motion-safe:in-data-[playback=running]:animate-[bento-03-tip_12s_var(--ease-standard)_infinite]">
{TOGETHER.tooltip}
</span>
<span
style={{ left: "66%", top: "34%" }}
className="absolute flex items-start motion-safe:in-data-[playback=running]:animate-[bento-03-dana_12s_var(--ease-emphasized)_infinite]"
>
<MousePointer2Icon className="size-4 fill-foreground text-background" />
<span className="mt-3 -ml-0.5 flex items-center gap-1 rounded-full bg-foreground py-0.5 pr-2 pl-0.5 text-2xs font-medium text-background">
<Face name={PEOPLE.dana} className="size-4" />
{PEOPLE.dana.split(" ")[0]}
</span>
</span>
<span
style={{ left: "78%", top: "54%" }}
className="absolute flex items-start motion-safe:in-data-[playback=running]:animate-[bento-03-leo_12s_var(--ease-emphasized)_infinite]"
>
<MousePointer2Icon className="size-4 fill-(--chart-2) text-background" />
<span className="mt-3 -ml-0.5 flex items-center gap-1 rounded-full bg-card py-0.5 pr-2 pl-0.5 text-2xs font-medium ring-1 ring-border">
<Face name={PEOPLE.leo} className="size-4" />
{PEOPLE.leo.split(" ")[0]}
</span>
</span>
</div>
)
}
/** A question on a figure and its answer arriving after a moment of typing. */
function CommentsPicture() {
return (
<div className="flex flex-col gap-3">
<div className="flex items-end gap-2">
<Face name={PEOPLE.dana} />
<Bubble variant="muted">
<BubbleContent>{COMMENTS.question}</BubbleContent>
</Bubble>
</div>
<div className="flex items-end justify-end gap-2">
<div className="grid min-w-0 justify-items-end *:col-start-1 *:row-start-1">
<Bubble align="end" className="max-w-full motion-safe:in-data-[playback=running]:animate-[bento-03-reply_12s_var(--ease-standard)_infinite]">
<BubbleContent>{COMMENTS.answer}</BubbleContent>
</Bubble>
<span className="flex h-9 items-center gap-1 self-end rounded-xl bg-muted px-3 opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-03-typing_12s_linear_infinite]">
{[0, 150, 300].map((delay) => (
<span
key={delay}
style={{ animationDelay: `${delay}ms` }}
className="size-1.5 rounded-full bg-muted-foreground motion-safe:in-data-[playback=running]:animate-[bento-03-dot_1.2s_ease-in-out_infinite]"
/>
))}
</span>
</div>
<Face name={PEOPLE.leo} />
</div>
</div>
)
}
/** An address typed into the invite field, one character at a time, and the person it names joining the list. */
function SharePicture() {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-lg bg-card px-2.5 ring-1 ring-input">
<MailIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span
className="min-w-0 truncate font-mono text-xs"
// The typing and the caret step once per character of this address.
style={{ "--bento-03-chars": SHARE.email.length } as React.CSSProperties}
>
<span className="inline-block motion-safe:in-data-[playback=running]:animate-[bento-03-type_12s_steps(var(--bento-03-chars))_infinite]">{SHARE.email}</span>
<span className="ml-px inline-block h-3.5 w-px bg-foreground align-middle motion-safe:in-data-[playback=running]:animate-[bento-03-caret-move_12s_steps(var(--bento-03-chars))_infinite,bento-03-caret_1s_step-end_infinite]" />
</span>
</span>
<span className={buttonVariants({ size: "sm" })}>{SHARE.invite}</span>
</div>
<ul className="flex flex-col">
{[...SHARE.members, SHARE.invited].map((member, index) => (
<li
key={member.name}
className={cn(
"flex h-10 items-center gap-2.5 border-b border-border text-sm last:border-b-0",
index === SHARE.members.length && "motion-safe:in-data-[playback=running]:animate-[bento-03-invite_12s_var(--ease-standard)_infinite]"
)}
>
<Face name={member.name} />
<span className="min-w-0 flex-1 truncate">{member.name}</span>
<span className="text-xs text-muted-foreground">{member.role}</span>
</li>
))}
</ul>
</div>
)
}
/** The faces of everyone reading the dashboard; the fourth arrives, and the count says so. */
function PresencePicture() {
return (
<div className="flex flex-col items-center gap-4">
<AvatarGroup
size="lg"
users={PRESENCE.people.map((name) => ({ name, src: avatarFor(name) }))}
className="[&>[data-slot=avatar]:last-child]:motion-safe:in-data-[playback=running]:animate-[bento-03-join_12s_var(--ease-standard)_infinite]"
/>
<span className="grid text-sm text-muted-foreground tabular-nums *:col-start-1 *:row-start-1">
<span className="motion-safe:in-data-[playback=running]:animate-[bento-03-after_12s_linear_infinite]">{PRESENCE.after}</span>
<span className="opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-03-before_12s_linear_infinite]">{PRESENCE.before}</span>
</span>
</div>
)
}
/** The versions of a dashboard, newest first, with a highlight walking back through them and home to the current one. */
function HistoryPicture() {
return (
<div className="relative">
<span className="absolute inset-x-0 top-0 h-11 rounded-md bg-muted/60 motion-safe:in-data-[playback=running]:animate-[bento-03-scan_12s_var(--ease-emphasized)_infinite]" />
<ul className="relative flex flex-col">
{HISTORY.versions.map((version, index) => (
<li key={version.version} className="flex h-11 items-center gap-2.5 px-2 text-sm">
<span className="w-7 font-mono text-xs text-muted-foreground">{version.version}</span>
<Face name={version.who} />
<span className="min-w-0 flex-1 truncate">{version.who}</span>
{index === 0 ? <Badge variant="secondary">{HISTORY.current}</Badge> : null}
<span className="text-xs text-muted-foreground">{version.when}</span>
</li>
))}
</ul>
</div>
)
}
/**
* The collaboration bento: five tiles about working on the numbers together,
* the dashboard everyone is in standing two rows tall in the middle from
* `lg` — two people's pointers moving over it, one picking a week — with a
* comment answered, an invitation typed, a face arriving and a version history
* on either side. Two columns on a tablet, the tall tile first across both;
* one column on a phone.
*
* The pictures move only on the kit's MotionStage, which the island hands
* this section's keyframes — while on screen, in a tab in front, and for a
* reader who has not asked for less motion — and its Pause motion toggle
* holds them still; still, each shows its final frame. Every picture here is
* decoration beside the words that say what it shows, and every face is the
* kit's `avatarFor(name)`. A server component; every word is in
* `./bento-03.data`.
*/
export function Bento03() {
const titleId = React.useId()
return (
<section
data-section="bento-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="grid gap-x-16 gap-y-5 lg:grid-cols-2 lg:items-end">
<div>
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
</div>
<p className="text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<BentoStage label={MOTION_LABEL}>
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Tile title={TOGETHER.title} body={TOGETHER.body} className="md:col-span-2 lg:col-span-1 lg:col-start-2 lg:row-span-2 lg:row-start-1">
<TogetherPicture />
</Tile>
<Tile title={COMMENTS.title} body={COMMENTS.body}>
<CommentsPicture />
</Tile>
<Tile title={SHARE.title} body={SHARE.body}>
<SharePicture />
</Tile>
<Tile title={PRESENCE.title} body={PRESENCE.body}>
<PresencePicture />
</Tile>
<Tile title={HISTORY.title} body={HISTORY.body}>
<HistoryPicture />
</Tile>
</ul>
</BentoStage>
</div>
</div>
</section>
)
}
export default Bento03"use client"
import * as React from "react"
import { MotionStage } from "@/components/ui/motion-stage"
/**
* What bento-03's pictures move on. Every loop starts and ends on the picture
* as it is drawn without motion — the final, informative frame — so a loop can
* begin at any moment without a jump, and stopping it lands on the frame that
* says the most. The tiles apply these through Tailwind, keyed to a running
* stage; the kit's MotionStage decides when that is and hoists this stylesheet
* into the head once, however many copies of the section a page holds, so the
* section installs with its motion and nothing is added to your CSS.
*
* The invite field types its address one character a step, with the caret on
* the typed edge: both step `steps(var(--bento-03-chars))` times and the caret
* starts that many `ch` back, where the field sets `--bento-03-chars` from the
* address's own length, so a different address never throws them out. The
* step count sits on the animation itself, not in a keyframe, because a
* keyframe's timing function does not take a custom property; the wipe that
* clears the field stays linear by naming its own.
*/
const KEYFRAMES = `
@keyframes bento-03-dana {
0%, 18% { left: 66%; top: 34% }
24%, 40% { left: 24%; top: 10% }
46%, 62% { left: 33%; top: 66% }
68%, 100% { left: 66%; top: 34% }
}
@keyframes bento-03-leo {
0%, 30% { left: 78%; top: 54% }
36%, 60% { left: 70%; top: 12% }
66%, 100% { left: 78%; top: 54% }
}
@keyframes bento-03-pick {
0%, 18% { background-color: var(--chart-1) }
22%, 66% { background-color: color-mix(in oklab, var(--chart-1) 40%, transparent) }
70%, 100% { background-color: var(--chart-1) }
}
@keyframes bento-03-tip {
0%, 17% { opacity: 1; transform: translateY(0) }
20%, 69% { opacity: 0; transform: translateY(4px) }
73%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-03-reply {
0%, 54% { opacity: 1; transform: translateY(0) }
58%, 81% { opacity: 0; transform: translateY(6px) }
85%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-03-typing {
0%, 58% { opacity: 0 }
62%, 80% { opacity: 1 }
83%, 100% { opacity: 0 }
}
@keyframes bento-03-dot {
0%, 60%, 100% { opacity: 0.35; transform: translateY(0) }
30% { opacity: 1; transform: translateY(-3px) }
}
@keyframes bento-03-type {
0%, 50% { clip-path: inset(0 0 0 0); animation-timing-function: linear }
53%, 56% { clip-path: inset(0 100% 0 0) }
74%, 100% { clip-path: inset(0 0 0 0) }
}
@keyframes bento-03-caret-move {
0%, 50% { transform: translateX(0); animation-timing-function: linear }
53%, 56% { transform: translateX(calc(var(--bento-03-chars) * -1ch)) }
74%, 100% { transform: translateX(0) }
}
@keyframes bento-03-caret {
0%, 100% { opacity: 1 }
50% { opacity: 0 }
}
@keyframes bento-03-invite {
0%, 50% { opacity: 1; transform: translateY(0) }
53%, 76% { opacity: 0; transform: translateY(-6px) }
80%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-03-join {
0%, 55% { opacity: 1; transform: scale(1) }
60%, 80% { opacity: 0; transform: scale(0.6) }
86%, 100% { opacity: 1; transform: scale(1) }
}
@keyframes bento-03-after {
0%, 55% { opacity: 1 }
57%, 86% { opacity: 0 }
88%, 100% { opacity: 1 }
}
@keyframes bento-03-before {
0%, 57%, 86%, 100% { opacity: 0 }
59%, 84% { opacity: 1 }
}
@keyframes bento-03-scan {
0%, 20% { transform: translateY(0) }
26%, 44% { transform: translateY(44px) }
50%, 68% { transform: translateY(88px) }
74%, 100% { transform: translateY(0) }
}
`
/** bento-03's stage: the kit's MotionStage, carrying this section's keyframes. */
export function BentoStage({ label, children }: { label: string; children: React.ReactNode }) {
return (
<MotionStage label={label} keyframes={{ href: "bento-03-keyframes", css: KEYFRAMES }}>
{children}
</MotionStage>
)
}/**
* Every word and figure `bento-03` sets: the claim, the words on the motion
* switch, and five tiles about working on the numbers together — each a
* heading, one line, and what its moving picture shows. The people are the
* sample workspace's, drawn with the kit's `avatarFor(name)` faces, so change
* a name and its face follows.
*/
export const EYEBROW = "Collaboration"
export const HEADLINE = "Work on the numbers together, in the same place"
export const LEDE =
"Comments sit on the figure they are about, sharing is one field, and everyone sees who else is looking and what changed since they last did."
/** The name of the toggle that holds every picture still: pressed, it holds them; pressed again, they move. */
export const MOTION_LABEL = "Pause motion"
export const PEOPLE = {
dana: "Dana Whitfield",
leo: "Leo Park",
priya: "Priya Raman",
sam: "Sam Okafor",
}
export const TOGETHER = {
title: "Everyone in the same dashboard",
body: "See who else is looking, and what they have selected, as it happens.",
kpis: [
{ label: "MRR", value: "$48.2K" },
{ label: "Churn", value: "2.1%" },
],
chart: "New MRR by week",
/** Thousands of dollars a week, oldest first; the sixth is the one selected. */
weeks: [6.2, 7.8, 5.9, 8.4, 9.1, 12.4, 8.8, 10.2],
selected: 5,
tooltip: "$12.4K, week of Aug 10",
}
export const COMMENTS = {
title: "Comments on the number itself",
body: "Ask about a figure where it sits, and the answer stays with it.",
question: "Why did churn jump on Monday?",
answer: "Harbour cancelled three seats. Row 14 has the detail.",
}
export const SHARE = {
title: "Share in one field",
body: "Invite anyone by address and choose what they can do before they arrive.",
field: "Invite by email",
invite: "Invite",
email: "priya@keystone.example",
role: "Can view",
members: [
{ name: "Dana Whitfield", role: "Owner" },
{ name: "Leo Park", role: "Can edit" },
],
invited: { name: "Priya Raman", role: "Can view" },
}
export const PRESENCE = {
title: "Who is here now",
body: "Faces on every dashboard say who is reading it with you.",
/** Everyone reading; the last one joins in the picture. */
people: ["Dana Whitfield", "Leo Park", "Sam Okafor", "Priya Raman"],
before: "3 viewing",
after: "4 viewing",
}
export const HISTORY = {
title: "Every version kept",
body: "Go back to any earlier version of a dashboard and bring it back in one step.",
current: "Current",
versions: [
{ version: "v14", who: "Priya Raman", when: "Today, 09:12" },
{ version: "v13", who: "Leo Park", when: "Yesterday" },
{ version: "v12", who: "Dana Whitfield", when: "Aug 28" },
],
}npx shadcn@latest add @vibra/bento-03Connections bento
bento-04Four sources wired into one hub with data running down each wire to the accounts it matched, a sync's steps turning over, fields wired to the metric they become and the ring that fills until the next sync.
import * as React from "react"
import { ActivityIcon, BriefcaseIcon, CircleCheckIcon, CircleDashedIcon, LifeBuoyIcon, LoaderCircleIcon, ReceiptTextIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { LogoMark } from "@/components/ui/logo-mark"
import { BentoStage } from "./bento-04.client"
import { EYEBROW, FIELDS, FRESH, HEADLINE, LEDE, MATCH, MOTION_LABEL, PIPELINE, type SourceIcon } from "./bento-04.data"
/** One tile: its heading and line on the frame, its picture on the sheet. */
function Tile({ title, body, className, children }: { title: string; body: string; className?: string; children: React.ReactNode }) {
return (
<li className={cn("flex min-w-0", className)}>
<Card className="w-full">
<CardHeader>
<CardTitle>
<h3>{title}</h3>
</CardTitle>
<CardDescription className="text-pretty">{body}</CardDescription>
</CardHeader>
<CardContent className="relative flex min-h-52 flex-col justify-center">{children}</CardContent>
</Card>
</li>
)
}
const SOURCE_ICONS: Record<SourceIcon, LucideIcon> = {
billing: ReceiptTextIcon,
crm: BriefcaseIcon,
product: ActivityIcon,
support: LifeBuoyIcon,
}
/** A dot running along one wire, left to right; the wires take turns. */
function Beam({ delay }: { delay: number }) {
return (
<span
style={{ animationDelay: `${delay}s` }}
className="absolute top-1/2 left-0 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-04-beam_2.4s_linear_infinite]"
/>
)
}
/** Four sources wired to Northwind, a dot of data running down each wire, and the accounts it matched them into. */
function MatchPicture() {
return (
<div role="img" aria-label={MATCH.caption} className="grid grid-cols-[auto_minmax(1.5rem,1fr)_auto_minmax(1.5rem,1fr)_auto] items-center">
<ul className="flex flex-col gap-3">
{MATCH.sources.map((source) => {
const Icon = SOURCE_ICONS[source.icon]
return (
<li key={source.name} className="flex h-9 items-center gap-2 rounded-lg bg-card pr-3 pl-2 text-sm ring-1 ring-border">
<span className="flex size-6 items-center justify-center rounded-md bg-surface ring-1 ring-border">
<Icon className="size-3.5" />
</span>
{source.name}
</li>
)
})}
</ul>
{/* The wires: one from each source, joined on a bus at the hub's side. */}
<div className="relative h-[180px]">
{MATCH.sources.map((source, index) => (
<span key={source.name} style={{ top: `${18 + index * 48}px` }} className="absolute inset-x-0 h-px bg-border">
<Beam delay={index * 0.6} />
</span>
))}
<span className="absolute top-[18px] right-0 bottom-[18px] w-px bg-border" />
</div>
<span className="relative flex size-11 items-center justify-center rounded-full bg-card ring-1 ring-border motion-safe:in-data-[playback=running]:animate-[bento-04-hub_2.4s_ease-out_infinite]">
<LogoMark name="Northwind" size="sm" className="[&>span]:sr-only" />
</span>
<div className="relative h-px bg-border">
<Beam delay={1.2} />
</div>
<div className="flex flex-col rounded-lg bg-card px-3 py-2.5 ring-1 ring-border">
<span className="type-numeral text-xl">{formatNumber(MATCH.accounts)}</span>
<span className="text-xs text-muted-foreground">{MATCH.accountsLabel}</span>
<span className="mt-1.5 max-w-32 text-xs text-pretty text-muted-foreground">{MATCH.matched}</span>
</div>
</div>
)
}
const DONE = [
"motion-safe:in-data-[playback=running]:animate-[bento-04-done-0_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-04-done-1_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-04-done-2_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-04-done-3_12s_linear_infinite]",
]
const SPIN = [
"motion-safe:in-data-[playback=running]:animate-[bento-04-spin-0_12s_linear_infinite,bento-04-rotate_0.9s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-04-spin-1_12s_linear_infinite,bento-04-rotate_0.9s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-04-spin-2_12s_linear_infinite,bento-04-rotate_0.9s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-04-spin-3_12s_linear_infinite,bento-04-rotate_0.9s_linear_infinite]",
]
/** The four steps of a sync, each waiting, then turning, then done, one after another. Decoration. */
function PipelinePicture() {
return (
<div aria-hidden="true" inert className="flex flex-col gap-4">
<ol className="flex flex-col">
{PIPELINE.steps.map((step, index) => (
<li key={step} className="relative flex h-10 items-center gap-3 text-sm before:absolute before:top-7 before:bottom-[-0.25rem] before:left-2.5 before:w-px before:bg-border last:before:hidden">
<span className="grid size-5 shrink-0 *:col-start-1 *:row-start-1">
<CircleDashedIcon className="size-5 text-muted-foreground" />
<span className={cn("rounded-full bg-card", DONE[index])}>
<CircleCheckIcon className="size-5 text-success" />
</span>
<LoaderCircleIcon className={cn("size-5 bg-card text-brand opacity-0", SPIN[index])} />
</span>
{step}
</li>
))}
</ol>
<p className="border-t border-border pt-3 text-xs text-muted-foreground">{PIPELINE.done}</p>
</div>
)
}
/** Three billing and CRM fields, each wired to the Northwind field it becomes, one after another. Decoration. */
function FieldsPicture() {
return (
<ul aria-hidden="true" inert className="flex flex-col gap-3">
{FIELDS.pairs.map((pair, index) => (
<li key={pair.from} className="grid grid-cols-[minmax(0,1fr)_2rem_auto] items-center gap-2">
<span className="truncate rounded-md bg-surface px-2 py-1.5 font-mono text-xs ring-1 ring-border">{pair.from}</span>
<span
style={{ animationDelay: `${index * 1}s` }}
className="h-px origin-left bg-foreground/60 motion-safe:in-data-[playback=running]:animate-[bento-04-link_12s_var(--ease-standard)_infinite]"
/>
<span
style={{ animationDelay: `${index * 1}s` }}
className="min-w-16 rounded-md bg-card px-2.5 py-1.5 text-center text-xs font-medium ring-1 ring-border motion-safe:in-data-[playback=running]:animate-[bento-04-land_12s_linear_infinite]"
>
{pair.to}
</span>
</li>
))}
</ul>
)
}
/** The ring that fills until the next sync, then says it synced and starts again. Decoration beside the words. */
function FreshPicture() {
return (
<div aria-hidden="true" inert className="flex items-center gap-5">
<span className="relative flex size-24 shrink-0 items-center justify-center">
<svg viewBox="0 0 100 100" className="absolute inset-0 -rotate-90">
<circle cx={50} cy={50} r={42} fill="none" stroke="var(--muted)" strokeWidth={8} />
<circle
cx={50}
cy={50}
r={42}
fill="none"
stroke="var(--brand)"
strokeWidth={8}
strokeLinecap="round"
pathLength={1}
strokeDasharray="1 1"
strokeDashoffset={0.25}
className="motion-safe:in-data-[playback=running]:animate-[bento-04-countdown_12s_linear_infinite]"
/>
</svg>
<span className="relative text-xs font-medium text-success opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-04-synced_12s_linear_infinite]">
{FRESH.synced}
</span>
</span>
<span className="flex flex-col gap-1">
<span className="text-sm font-medium">{FRESH.next}</span>
<span className="text-xs text-muted-foreground">{FRESH.last}</span>
</span>
</div>
)
}
/**
* The connections bento: how Northwind reads your tools, as four moving
* pictures — the sources wired into one hub with data running down each wire
* to the accounts it matched, a sync's four steps turning over one after
* another, fields wired to the metric they become, and the ring that fills
* until the next sync. The wiring runs the whole width across the top; the
* other three sit under it, three across from `lg`.
*
* The pictures move only on the kit's MotionStage, which the island hands
* this section's keyframes — while on screen, in a tab in front, and for a
* reader who has not asked for less motion — and its Pause motion toggle
* holds them still; still, each shows its final frame. The wiring carries
* figures, so it is an image named by what it shows; the rest are decoration
* beside words that say the same. A server component; every word and figure
* is in `./bento-04.data`.
*/
export function Bento04() {
const titleId = React.useId()
return (
<section
data-section="bento-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-3xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<BentoStage label={MOTION_LABEL}>
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Tile title={MATCH.title} body={MATCH.body} className="md:col-span-2 lg:col-span-3">
<div className="mx-auto w-full max-w-3xl">
<MatchPicture />
</div>
</Tile>
<Tile title={PIPELINE.title} body={PIPELINE.body}>
<PipelinePicture />
</Tile>
<Tile title={FIELDS.title} body={FIELDS.body}>
<FieldsPicture />
</Tile>
<Tile title={FRESH.title} body={FRESH.body} className="md:col-span-2 lg:col-span-1">
<FreshPicture />
</Tile>
</ul>
</BentoStage>
</div>
</div>
</section>
)
}
export default Bento04"use client"
import * as React from "react"
import { MotionStage } from "@/components/ui/motion-stage"
/**
* What bento-04's pictures move on. Every loop starts and ends on the picture
* as it is drawn without motion — the final, informative frame — so a loop can
* begin at any moment without a jump, and stopping it lands on the frame that
* says the most. The tiles apply these through Tailwind, keyed to a running
* stage; the kit's MotionStage decides when that is and hoists this stylesheet
* into the head once, however many copies of the section a page holds, so the
* section installs with its motion and nothing is added to your CSS.
*/
const KEYFRAMES = `
@keyframes bento-04-beam {
0% { left: 0%; opacity: 0 }
10% { opacity: 1 }
80% { opacity: 1 }
100% { left: 100%; opacity: 0 }
}
@keyframes bento-04-hub {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--brand) 0%, transparent) }
30% { box-shadow: 0 0 0 6px color-mix(in oklch, var(--brand) 14%, transparent) }
}
@keyframes bento-04-rotate {
to { transform: rotate(360deg) }
}
@keyframes bento-04-done-0 {
0%, 48% { opacity: 1 }
50%, 61% { opacity: 0 }
62%, 100% { opacity: 1 }
}
@keyframes bento-04-done-1 {
0%, 48% { opacity: 1 }
50%, 71% { opacity: 0 }
72%, 100% { opacity: 1 }
}
@keyframes bento-04-done-2 {
0%, 48% { opacity: 1 }
50%, 81% { opacity: 0 }
82%, 100% { opacity: 1 }
}
@keyframes bento-04-done-3 {
0%, 48% { opacity: 1 }
50%, 91% { opacity: 0 }
92%, 100% { opacity: 1 }
}
@keyframes bento-04-spin-0 {
0%, 52% { opacity: 0 }
53%, 61% { opacity: 1 }
62%, 100% { opacity: 0 }
}
@keyframes bento-04-spin-1 {
0%, 62% { opacity: 0 }
63%, 71% { opacity: 1 }
72%, 100% { opacity: 0 }
}
@keyframes bento-04-spin-2 {
0%, 72% { opacity: 0 }
73%, 81% { opacity: 1 }
82%, 100% { opacity: 0 }
}
@keyframes bento-04-spin-3 {
0%, 82% { opacity: 0 }
83%, 91% { opacity: 1 }
92%, 100% { opacity: 0 }
}
@keyframes bento-04-link {
0%, 50% { transform: scaleX(1); opacity: 1 }
54% { transform: scaleX(1); opacity: 0 }
55% { transform: scaleX(0); opacity: 1 }
63%, 100% { transform: scaleX(1); opacity: 1 }
}
@keyframes bento-04-land {
0%, 62% { background-color: var(--card) }
64%, 70% { background-color: var(--brand-muted) }
76%, 100% { background-color: var(--card) }
}
@keyframes bento-04-countdown {
0% { stroke-dashoffset: 0.25 }
25%, 27% { stroke-dashoffset: 0 }
27.01% { stroke-dashoffset: 1 }
100% { stroke-dashoffset: 0.25 }
}
@keyframes bento-04-synced {
0%, 24% { opacity: 0; transform: translateY(2px) }
27%, 36% { opacity: 1; transform: translateY(0) }
40%, 100% { opacity: 0; transform: translateY(0) }
}
`
/** bento-04's stage: the kit's MotionStage, carrying this section's keyframes. */
export function BentoStage({ label, children }: { label: string; children: React.ReactNode }) {
return (
<MotionStage label={label} keyframes={{ href: "bento-04-keyframes", css: KEYFRAMES }}>
{children}
</MotionStage>
)
}/**
* Every word and figure `bento-04` sets: the claim, the words on the motion
* switch, and four tiles about how Northwind reads your tools — each a
* heading, one line, and what its moving picture shows. The figures are a
* sample workspace's, written once here; the tools are named by what they
* are, never by a vendor.
*/
export const EYEBROW = "Connections"
export const HEADLINE = "Every source, matched into one customer"
export const LEDE =
"Northwind reads billing, your CRM, product events and your support desk without writing to any of them, and joins them on the customer."
/** The name of the toggle that holds every picture still: pressed, it holds them; pressed again, they move. */
export const MOTION_LABEL = "Pause motion"
export type SourceIcon = "billing" | "crm" | "product" | "support"
export const MATCH = {
title: "Connect once, matched for good",
body: "Four read-only connections, joined on each customer's domain into one account.",
sources: [
{ icon: "billing", name: "Billing" },
{ icon: "crm", name: "CRM" },
{ icon: "product", name: "Product" },
{ icon: "support", name: "Support" },
] satisfies { icon: SourceIcon; name: string }[],
hub: "Northwind",
accounts: 2418,
accountsLabel: "accounts",
matched: "98.6% matched on their own",
caption: "Billing, CRM, product events and the support desk feed Northwind, which matches them into 2,418 accounts, 98.6% of them without help.",
}
export const PIPELINE = {
title: "Every run, step by step",
body: "Each sync reads, matches, models and publishes, and says where it has got to.",
steps: ["Read the sources", "Match the customers", "Build the metrics", "Publish the dashboards"],
done: "Last run 4 min ago, 38 seconds",
}
export const FIELDS = {
title: "Fields mapped for you",
body: "Northwind knows that billing's amount and your CRM's plan are the same customer's.",
pairs: [
{ from: "customer_email", to: "Account" },
{ from: "plan_name", to: "Plan" },
{ from: "amount_cents", to: "MRR" },
],
}
export const FRESH = {
title: "Fresh to the minute",
body: "Business workspaces sync every five minutes, and every chart says when.",
next: "Next sync in 1 min",
last: "Last synced 4 min ago",
synced: "Synced",
}npx shadcn@latest add @vibra/bento-04Reports bento
bento-05A month with each scheduled report landing on its day, the board pack's pages shuffling into order, an export filling and turning ready, and the pack reaching each recipient in turn.
import * as React from "react"
import { CircleCheckIcon, FileTextIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { BentoStage } from "./bento-05.client"
import { CALENDAR, DELIVERY, EXPORT, EYEBROW, HEADLINE, LEDE, MOTION_LABEL, PACK } from "./bento-05.data"
/** One tile: its heading and line on the frame, its picture on the sheet. */
function Tile({ title, body, className, children }: { title: string; body: string; className?: string; children: React.ReactNode }) {
return (
<li className={cn("flex min-w-0", className)}>
<Card className="w-full">
<CardHeader>
<CardTitle>
<h3>{title}</h3>
</CardTitle>
<CardDescription className="text-pretty">{body}</CardDescription>
</CardHeader>
<CardContent className="relative flex min-h-52 flex-col justify-center">{children}</CardContent>
</Card>
</li>
)
}
/** The month as weeks of seven, Monday first: August's last day before it, October's first days after. */
function monthCells() {
const lead = Array.from({ length: CALENDAR.firstWeekday }, (_, i) => ({ day: CALENDAR.daysBefore - CALENDAR.firstWeekday + 1 + i, inMonth: false }))
const days = Array.from({ length: CALENDAR.days }, (_, i) => ({ day: i + 1, inMonth: true }))
const tail = Array.from({ length: (7 - ((lead.length + days.length) % 7)) % 7 }, (_, i) => ({ day: i + 1, inMonth: false }))
return [...lead, ...days, ...tail]
}
/** September, with each scheduled report landing on its day in turn; today is ringed. */
function CalendarPicture() {
// Each report lands in the order the data lists them, a third of a second apart.
const order = new Map(CALENDAR.events.map((event, index) => [`${event.day}-${event.title}`, index]))
return (
<div role="img" aria-label={CALENDAR.caption} className="flex flex-col gap-3">
<p className="text-sm font-medium">{CALENDAR.month}</p>
<div className="grid grid-cols-7 gap-1 text-xs">
{CALENDAR.weekdays.map((weekday, index) => (
<span key={index} className="pb-1 text-center text-muted-foreground">
{weekday}
</span>
))}
{monthCells().map((cell, index) => {
const events = cell.inMonth ? CALENDAR.events.filter((event) => event.day === cell.day) : []
const today = cell.inMonth && cell.day === CALENDAR.today
return (
<div
key={index}
className={cn(
"flex min-h-11 min-w-0 flex-col gap-1 rounded-md p-1 sm:min-h-14",
cell.inMonth ? "bg-surface" : "text-faint-foreground",
today && "ring-2 ring-brand motion-safe:in-data-[playback=running]:animate-[bento-05-today_2.4s_ease-out_infinite]"
)}
>
<span className={cn("tabular-nums", today && "font-semibold")}>{cell.day}</span>
{events.map((event) => {
const delay = { animationDelay: `${(order.get(`${event.day}-${event.title}`) ?? 0) * 0.35}s` }
return (
<span key={event.title} style={delay} className="motion-safe:in-data-[playback=running]:animate-[bento-05-chip_12s_var(--ease-standard)_infinite]">
<span className="block size-1.5 rounded-full bg-(--chart-2) sm:hidden" />
<span className="hidden truncate rounded-sm border-l-2 border-(--chart-2) bg-card px-1 py-0.5 text-2xs font-medium sm:block">
{event.title}
</span>
</span>
)
})}
</div>
)
})}
</div>
</div>
)
}
/** The pack's three pages as a stack, the front one going to the back in turn. Decoration. */
function PackPicture() {
const resting = ["none", "translateY(-20px) scale(0.9)", "translateY(-10px) scale(0.95)"]
const layers = [3, 1, 2]
return (
<div aria-hidden="true" inert className="relative mx-auto h-44 w-full max-w-sm">
{PACK.pages.map((page, index) => (
<div
key={page.title}
style={{ transform: resting[index], zIndex: layers[index], animationDelay: `${-index * 3}s` }}
className="absolute inset-x-0 top-6 bottom-0 flex origin-top flex-col gap-1 elev-1 p-4 motion-safe:in-data-[playback=running]:animate-[bento-05-shuffle_9s_var(--ease-emphasized)_infinite]"
>
<span className="text-xs text-muted-foreground">{page.title}</span>
<span className="type-numeral text-2xl">{page.figure}</span>
<span className="text-xs text-muted-foreground">{page.note}</span>
<svg viewBox="0 0 120 24" className="mt-auto h-6 w-full" preserveAspectRatio="none">
<polyline points="0,20 20,16 40,18 60,11 80,12 100,6 120,4" fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
</svg>
</div>
))}
</div>
)
}
/** A report exporting: the bar fills, the word turns from working to ready. Decoration. */
function ExportPicture() {
return (
<div aria-hidden="true" inert className="flex flex-col gap-4">
<div className="flex items-start gap-3 rounded-lg bg-surface p-3 ring-1 ring-border">
<FileTextIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<span className="truncate font-mono text-xs">{EXPORT.file}</span>
<Progress
value={100}
className="[&_[data-slot=progress-indicator]]:origin-left motion-safe:in-data-[playback=running]:[&_[data-slot=progress-indicator]]:animate-[bento-05-fill_12s_linear_infinite]"
/>
<span className="grid text-xs *:col-start-1 *:row-start-1">
<span className="text-muted-foreground motion-safe:in-data-[playback=running]:animate-[bento-05-ready_12s_linear_infinite]">{EXPORT.ready}</span>
<span className="opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-05-working_12s_linear_infinite]">{EXPORT.working}</span>
</span>
</div>
</div>
<div className="flex gap-1.5">
{EXPORT.formats.map((format) => (
<Badge key={format} variant="outline">
{format}
</Badge>
))}
</div>
</div>
)
}
/** The pack going out: each recipient's check arrives in turn. Decoration. */
function DeliveryPicture() {
return (
<div aria-hidden="true" inert className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">{DELIVERY.report}</p>
<ul className="flex flex-col">
{DELIVERY.recipients.map((name, index) => (
<li key={name} className="flex h-10 items-center gap-2.5 border-b border-border text-sm last:border-b-0">
<Avatar size="sm" className="after:hidden">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate">{name}</span>
<span
style={{ animationDelay: `${index * 0.6}s` }}
className="inline-flex items-center gap-1 text-xs text-success motion-safe:in-data-[playback=running]:animate-[bento-05-sent_12s_var(--ease-standard)_infinite]"
>
<CircleCheckIcon className="size-3.5" />
{DELIVERY.sent}
</span>
</li>
))}
</ul>
</div>
)
}
/**
* The reports bento: four tiles about reports that arrive on their own — the
* month with each scheduled report landing on its day, the board pack's pages
* shuffling into order, an export filling and turning ready, and the pack
* reaching each recipient in turn. The calendar is the large square of a
* four-column mosaic from `lg`, the pack across the top of the other half and
* the two small tiles under it; two columns on a tablet, one on a phone,
* where each report on the calendar is a dot on its day.
*
* The pictures move only on the kit's MotionStage, which the island hands
* this section's keyframes — while on screen, in a tab in front, and for a
* reader who has not asked for less motion — and its Pause motion toggle
* holds them still; still, each shows its final frame. The calendar carries
* dates, so it is an image named by what it shows; the rest are decoration
* beside words that say the same. A server component; every word is in
* `./bento-05.data`.
*/
export function Bento05() {
const titleId = React.useId()
return (
<section
data-section="bento-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-3xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<BentoStage label={MOTION_LABEL}>
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Tile title={CALENDAR.title} body={CALENDAR.body} className="md:col-span-2 lg:row-span-2">
<CalendarPicture />
</Tile>
<Tile title={PACK.title} body={PACK.body} className="md:col-span-2">
<PackPicture />
</Tile>
<Tile title={EXPORT.title} body={EXPORT.body}>
<ExportPicture />
</Tile>
<Tile title={DELIVERY.title} body={DELIVERY.body}>
<DeliveryPicture />
</Tile>
</ul>
</BentoStage>
</div>
</div>
</section>
)
}
export default Bento05"use client"
import * as React from "react"
import { MotionStage } from "@/components/ui/motion-stage"
/**
* What bento-05's pictures move on. Every loop starts and ends on the picture
* as it is drawn without motion — the final, informative frame — so a loop can
* begin at any moment without a jump, and stopping it lands on the frame that
* says the most. The tiles apply these through Tailwind, keyed to a running
* stage; the kit's MotionStage decides when that is and hoists this stylesheet
* into the head once, however many copies of the section a page holds, so the
* section installs with its motion and nothing is added to your CSS.
*/
const KEYFRAMES = `
@keyframes bento-05-chip {
0%, 52% { opacity: 1; transform: scale(1) }
56%, 80% { opacity: 0; transform: scale(0.8) }
84%, 100% { opacity: 1; transform: scale(1) }
}
@keyframes bento-05-today {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--brand) 30%, transparent) }
50% { box-shadow: 0 0 0 4px color-mix(in oklch, var(--brand) 0%, transparent) }
}
@keyframes bento-05-shuffle {
0%, 26% { transform: translateY(0) scale(1); opacity: 1; z-index: 3 }
31% { transform: translateY(20px) scale(1); opacity: 0; z-index: 3 }
31.9% { transform: translateY(-20px) scale(0.9); opacity: 0; z-index: 1 }
36%, 60% { transform: translateY(-20px) scale(0.9); opacity: 1; z-index: 1 }
66.6%, 93% { transform: translateY(-10px) scale(0.95); opacity: 1; z-index: 2 }
100% { transform: translateY(0) scale(1); opacity: 1; z-index: 3 }
}
@keyframes bento-05-fill {
0%, 52% { transform: scaleX(1) }
54% { transform: scaleX(0) }
56% { transform: scaleX(0); animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1) }
84%, 100% { transform: scaleX(1) }
}
@keyframes bento-05-ready {
0%, 52% { opacity: 1 }
53.5%, 84.5% { opacity: 0 }
86%, 100% { opacity: 1 }
}
@keyframes bento-05-working {
0%, 53.5%, 84.5%, 100% { opacity: 0 }
55%, 83% { opacity: 1 }
}
@keyframes bento-05-sent {
0%, 50% { opacity: 1; transform: scale(1) }
54%, 70% { opacity: 0; transform: scale(0.6) }
74%, 100% { opacity: 1; transform: scale(1) }
}
`
/** bento-05's stage: the kit's MotionStage, carrying this section's keyframes. */
export function BentoStage({ label, children }: { label: string; children: React.ReactNode }) {
return (
<MotionStage label={label} keyframes={{ href: "bento-05-keyframes", css: KEYFRAMES }}>
{children}
</MotionStage>
)
}/**
* Every word and figure `bento-05` sets: the claim, the words on the motion
* switch, and four tiles about reports that arrive on their own — each a
* heading, one line, and what its moving picture shows. The calendar is
* September 2026, the month the sample workspace's "now" falls in, written out
* here with its reports so the picture never reads a clock; its test holds it
* to that month, so it cannot drift from the rest of the kit unnoticed.
*/
export const EYEBROW = "Reports"
export const HEADLINE = "Reports that arrive before the meeting"
export const LEDE =
"Schedule any dashboard as a report once, and Northwind assembles it, exports it and delivers it to the people who read it."
/** The name of the toggle that holds every picture still: pressed, it holds them; pressed again, they move. */
export const MOTION_LABEL = "Pause motion"
export const CALENDAR = {
title: "Scheduled, not remembered",
body: "Pick the day and the hour once; every report after that arrives on its own.",
month: "September 2026",
weekdays: ["M", "T", "W", "T", "F", "S", "S"],
/** The day of the week the month starts on, Monday first (September 1, 2026 is a Tuesday), its length and the length of the month before it. */
firstWeekday: 1,
days: 30,
daysBefore: 31,
today: 4,
/** Each report and the day it arrives: the weekly review on Mondays, the forecast mid-month, the board pack at the end. */
events: [
{ day: 7, title: "Review" },
{ day: 14, title: "Review" },
{ day: 15, title: "Forecast" },
{ day: 21, title: "Review" },
{ day: 28, title: "Review" },
{ day: 30, title: "Board pack" },
],
caption: "A September 2026 calendar with the weekly review arriving every Monday, the forecast on the 15th and the board pack on the 30th; today is the 4th.",
}
export const PACK = {
title: "The board pack, assembled",
body: "Revenue, retention and pipeline, in the order the board reads them.",
pages: [
{ title: "Revenue", figure: "$1.38M", note: "Q3, up 11%" },
{ title: "Retention", figure: "112%", note: "Net revenue retention" },
{ title: "Pipeline", figure: "$2.9M", note: "Weighted, next quarter" },
],
}
export const EXPORT = {
title: "PDF, CSV or a link",
body: "Every report exports in the format its reader wants.",
file: "board-pack-q3.pdf",
working: "Exporting",
ready: "Ready, 2.4 MB",
formats: ["PDF", "CSV", "Link"],
}
export const DELIVERY = {
title: "Delivered to the right inboxes",
body: "Each recipient gets it at 08:00 in their own time zone.",
report: "Board pack, Monday 08:00",
sent: "Sent",
recipients: ["Dana Whitfield", "Leo Park", "Priya Raman"],
}npx shadcn@latest add @vibra/bento-05Search bento
bento-06Beside the claim, a command palette typed into one character at a time, a question answered in plain words, two keys pressed and a saved view narrowing its count.
import * as React from "react"
import { ChartColumnIcon, LayoutDashboardIcon, SearchIcon, SparklesIcon, TableIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { FilterChip } from "@/components/ui/filter-chip"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { BentoStage } from "./bento-06.client"
import { ANSWER, EYEBROW, HEADLINE, KEYS, LEDE, MOTION_LABEL, PALETTE, VIEWS, type ResultKind } from "./bento-06.data"
/** One tile: its heading and line on the frame, its picture on the sheet. Every picture here is decoration beside its words. */
function Tile({ title, body, className, children }: { title: string; body: string; className?: string; children: React.ReactNode }) {
return (
<li className={cn("flex min-w-0", className)}>
<Card className="w-full">
<CardHeader>
<CardTitle>
<h3>{title}</h3>
</CardTitle>
<CardDescription className="text-pretty">{body}</CardDescription>
</CardHeader>
<CardContent className="relative flex min-h-48 flex-col justify-center">
<div aria-hidden="true" inert className="flex flex-col justify-center">
{children}
</div>
</CardContent>
</Card>
</li>
)
}
const RESULT_ICONS: Record<ResultKind, LucideIcon> = {
dashboard: LayoutDashboardIcon,
metric: ChartColumnIcon,
table: TableIcon,
}
/**
* The palette: the old query fades, the new one is typed a character at a
* time — each character opens in turn, a tenth of a second apart — then the
* results arrive, the first is chosen and Enter goes down.
*/
function PalettePicture() {
return (
<div className="mx-auto w-full max-w-xl elev-2 p-1.5">
<div className="flex h-11 items-center gap-2.5 border-b border-border px-3">
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-1 items-center text-sm">
<span className="whitespace-nowrap motion-safe:in-data-[playback=running]:animate-[bento-06-query_12s_linear_infinite]">
{[...PALETTE.query].map((char, index) => (
<span
key={index}
style={{ animationDelay: `${index * 90}ms` }}
className="inline-block max-w-4 overflow-hidden align-top leading-5 whitespace-pre motion-safe:in-data-[playback=running]:animate-[bento-06-key_12s_step-end_infinite]"
>
{char}
</span>
))}
</span>
<span className="ml-px inline-block h-4 w-px bg-foreground motion-safe:in-data-[playback=running]:animate-[bento-06-caret_1s_step-end_infinite]" />
</span>
<KbdGroup>
{PALETTE.shortcut.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
</div>
<ul className="flex flex-col gap-0.5 p-1.5">
{PALETTE.results.map((result, index) => {
const Icon = RESULT_ICONS[result.kind]
return (
<li
key={result.title}
style={{ animationDelay: `${index * 150}ms` }}
className="motion-safe:in-data-[playback=running]:animate-[bento-06-result_12s_var(--ease-standard)_infinite]"
>
<span
className={cn(
"flex h-9 items-center gap-2.5 rounded-md px-2.5 text-sm",
index === 0 && "bg-muted motion-safe:in-data-[playback=running]:animate-[bento-06-pick_12s_linear_infinite]"
)}
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{result.title}</span>
<span className="text-xs text-muted-foreground">{result.where}</span>
{index === 0 ? (
<Kbd className="motion-safe:in-data-[playback=running]:animate-[bento-06-enter_12s_linear_infinite]">{PALETTE.enter}</Kbd>
) : null}
</span>
</li>
)
})}
</ul>
</div>
)
}
/** A question in plain words, a moment of working it out, then the answer with its figures and where they came from. */
function AnswerPicture() {
const peak = Math.max(...ANSWER.bars.map((bar) => bar.value))
return (
<div className="flex flex-col gap-3">
<Bubble variant="muted" align="end">
<BubbleContent>{ANSWER.question}</BubbleContent>
</Bubble>
<div className="grid *:col-start-1 *:row-start-1">
<div className="flex flex-col gap-2 rounded-lg bg-surface p-3 opacity-0 ring-1 ring-border motion-safe:in-data-[playback=running]:animate-[bento-06-think_12s_linear_infinite]">
{["w-11/12", "w-4/5", "w-3/5"].map((width) => (
<span key={width} className={cn("h-2.5 rounded-full bg-muted motion-safe:in-data-[playback=running]:animate-[bento-06-shimmer_1.2s_ease-in-out_infinite]", width)} />
))}
</div>
<div className="flex flex-col gap-2.5 rounded-lg bg-surface p-3 ring-1 ring-border motion-safe:in-data-[playback=running]:animate-[bento-06-answer_12s_linear_infinite]">
<p className="flex items-start gap-2 text-sm">
<SparklesIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
{ANSWER.answer}
</p>
<ul className="flex flex-col gap-1.5">
{ANSWER.bars.map((bar, index) => (
<li key={bar.label} className="grid grid-cols-[4.5rem_minmax(0,1fr)_2.5rem] items-center gap-2 text-xs">
<span className="text-muted-foreground">{bar.label}</span>
<span className="h-2 rounded-full bg-muted">
<span
style={{ width: `${(bar.value / peak) * 100}%`, animationDelay: `${index * 120}ms` }}
className={cn(
"block h-full origin-left rounded-full motion-safe:in-data-[playback=running]:animate-[bento-06-bar_12s_var(--ease-standard)_infinite]",
index === 0 ? "bg-(--chart-1)" : "bg-(--chart-1)/40"
)}
/>
</span>
<span className="text-right tabular-nums">+{bar.value}%</span>
</li>
))}
</ul>
<p className="text-xs text-muted-foreground">{ANSWER.source}</p>
</div>
</div>
</div>
)
}
/** Two keys pressed one after the other, and where they go. */
function KeysPicture() {
return (
<div className="flex flex-col items-center gap-4">
<span className="flex items-center gap-2">
{KEYS.keys.map((key, index) => (
<Kbd
key={key}
style={{ animationDelay: `${5.8 + index * 0.6}s` }}
className="h-10 min-w-10 rounded-md text-base font-semibold text-foreground ring-1 ring-border motion-safe:in-data-[playback=running]:animate-[bento-06-press_12s_linear_infinite]"
>
{key}
</Kbd>
))}
</span>
<span className="text-sm text-muted-foreground motion-safe:in-data-[playback=running]:animate-[bento-06-goto_12s_var(--ease-standard)_infinite]">
{KEYS.destination}
</span>
</div>
)
}
const COUNT = [
"opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-06-count-0_12s_linear_infinite]",
"opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-06-count-1_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-06-count-2_12s_linear_infinite]",
]
/** A saved view's filters going on one at a time, and the count narrowing with each. */
function ViewsPicture() {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-2">
{VIEWS.filters.map((filter, index) => (
<span
key={filter.label}
style={{ animationDelay: `${index * 1.2}s` }}
className="motion-safe:in-data-[playback=running]:animate-[bento-06-chip_12s_var(--ease-standard)_infinite]"
>
<FilterChip label={filter.label} value={filter.value} active />
</span>
))}
</div>
<div className="flex flex-col sm:items-end">
<span className="grid type-numeral text-xl *:col-start-1 *:row-start-1 sm:justify-items-end">
{VIEWS.counts.map((count, index) => (
<span key={count} className={COUNT[index]}>
{count}
</span>
))}
</span>
<span className="text-xs text-muted-foreground">{VIEWS.saved}</span>
</div>
</div>
)
}
/**
* The search bento: four tiles about finding and asking, beside the claim.
* The command palette is typed into one character at a time across the top,
* with a question answered in plain words and two keys pressed under it and a
* saved view narrowing its count along the bottom. From `lg` the claim holds
* the left third and stays in view; the tiles fill the rest, two across.
*
* The pictures move only on the kit's MotionStage, which the island hands
* this section's keyframes — while on screen, in a tab in front, and for a
* reader who has not asked for less motion — and its Pause motion toggle
* holds them still; still, each shows its final frame. Every picture is
* decoration beside the words that say what it shows. A server component;
* every word is in `./bento-06.data`.
*/
export function Bento06() {
const titleId = React.useId()
return (
<section
data-section="bento-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl gap-x-12 gap-y-10 lg:grid-cols-3">
<div className="lg:sticky lg:top-24 lg:self-start lg:pt-11">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<div className="min-w-0 lg:col-span-2">
<BentoStage label={MOTION_LABEL}>
<ul className="grid gap-4 sm:grid-cols-2">
<Tile title={PALETTE.title} body={PALETTE.body} className="sm:col-span-2">
<PalettePicture />
</Tile>
<Tile title={ANSWER.title} body={ANSWER.body}>
<AnswerPicture />
</Tile>
<Tile title={KEYS.title} body={KEYS.body}>
<KeysPicture />
</Tile>
<Tile title={VIEWS.title} body={VIEWS.body} className="sm:col-span-2">
<ViewsPicture />
</Tile>
</ul>
</BentoStage>
</div>
</div>
</section>
)
}
export default Bento06"use client"
import * as React from "react"
import { MotionStage } from "@/components/ui/motion-stage"
/**
* What bento-06's pictures move on. Every loop starts and ends on the picture
* as it is drawn without motion — the final, informative frame — so a loop can
* begin at any moment without a jump, and stopping it lands on the frame that
* says the most. The tiles apply these through Tailwind, keyed to a running
* stage; the kit's MotionStage decides when that is and hoists this stylesheet
* into the head once, however many copies of the section a page holds, so the
* section installs with its motion and nothing is added to your CSS.
*/
const KEYFRAMES = `
@keyframes bento-06-query {
0%, 42% { opacity: 1 }
44%, 55% { opacity: 0 }
55.1%, 100% { opacity: 1 }
}
@keyframes bento-06-key {
0%, 44% { max-width: 1em }
44.1%, 56% { max-width: 0 }
56.1%, 100% { max-width: 1em }
}
@keyframes bento-06-caret {
0%, 100% { opacity: 1 }
50% { opacity: 0 }
}
@keyframes bento-06-result {
0%, 42% { opacity: 1; transform: translateY(0) }
44%, 70% { opacity: 0; transform: translateY(4px) }
73%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-06-pick {
0%, 42% { background-color: var(--muted) }
44%, 76% { background-color: transparent }
79%, 100% { background-color: var(--muted) }
}
@keyframes bento-06-enter {
0%, 82%, 88%, 100% { transform: scale(1) }
85% { transform: scale(0.88) }
}
@keyframes bento-06-answer {
0%, 44% { opacity: 1 }
46%, 74% { opacity: 0 }
76%, 100% { opacity: 1 }
}
@keyframes bento-06-think {
0%, 46%, 74%, 100% { opacity: 0 }
47.5%, 72.5% { opacity: 1 }
}
@keyframes bento-06-shimmer {
0%, 100% { opacity: 1 }
50% { opacity: 0.45 }
}
@keyframes bento-06-bar {
0%, 44% { transform: scaleX(1) }
47%, 74% { transform: scaleX(0) }
84%, 100% { transform: scaleX(1) }
}
@keyframes bento-06-press {
0%, 100% { transform: translateY(0); background-color: var(--muted) }
3% { transform: translateY(2px); background-color: var(--brand-muted) }
7% { transform: translateY(0); background-color: var(--muted) }
}
@keyframes bento-06-goto {
0%, 42% { opacity: 1; transform: translateY(0) }
45%, 57% { opacity: 0; transform: translateY(4px) }
60%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-06-chip {
0%, 46% { opacity: 1; transform: scale(1) }
50%, 60% { opacity: 0; transform: scale(0.85) }
64%, 100% { opacity: 1; transform: scale(1) }
}
@keyframes bento-06-count-0 {
0%, 48%, 63%, 100% { opacity: 0 }
50%, 62% { opacity: 1 }
}
@keyframes bento-06-count-1 {
0%, 63%, 75%, 100% { opacity: 0 }
64.5%, 74% { opacity: 1 }
}
@keyframes bento-06-count-2 {
0%, 46% { opacity: 1 }
48%, 75% { opacity: 0 }
76.5%, 100% { opacity: 1 }
}
`
/** bento-06's stage: the kit's MotionStage, carrying this section's keyframes. */
export function BentoStage({ label, children }: { label: string; children: React.ReactNode }) {
return (
<MotionStage label={label} keyframes={{ href: "bento-06-keyframes", css: KEYFRAMES }}>
{children}
</MotionStage>
)
}/**
* Every word and figure `bento-06` sets: the claim, the words on the motion
* switch, and four tiles about finding and asking — each a heading, one line,
* and what its moving picture shows. The query is typed one character at a
* time, so change it here and the typing follows; the answer's figures are a
* sample workspace's, written once here.
*/
export const EYEBROW = "Search and answers"
export const HEADLINE = "Ask, and get the rows behind the answer"
export const LEDE =
"Every metric, chart and account is two keys away, and a question in plain words comes back with the figure and where it was counted from."
/** The name of the toggle that holds every picture still: pressed, it holds them; pressed again, they move. */
export const MOTION_LABEL = "Pause motion"
export type ResultKind = "dashboard" | "metric" | "table"
export const PALETTE = {
title: "Find anything in two keys",
body: "Press Command K, type a few letters and open the chart, metric or table you meant.",
shortcut: ["⌘", "K"],
query: "revenue by plan",
enter: "↵",
results: [
{ kind: "dashboard", title: "Revenue by plan", where: "Dashboard" },
{ kind: "metric", title: "MRR by plan", where: "Metric" },
{ kind: "table", title: "Plans", where: "Table" },
] satisfies { kind: ResultKind; title: string; where: string }[],
}
export const ANSWER = {
title: "Answers in plain words",
body: "Ask the way you would ask a colleague, and see the figure with its source.",
question: "Which plan grew fastest in August?",
answer: "Team, up 14%: $12,400 of new MRR, from 38 upgrades.",
source: "Counted from billing, August 2026",
bars: [
{ label: "Team", value: 14 },
{ label: "Business", value: 9 },
{ label: "Starter", value: 4 },
],
}
export const KEYS = {
title: "Keyboard first",
body: "Two keys go anywhere: G then R opens revenue, G then A the accounts.",
keys: ["G", "R"],
destination: "Go to Revenue",
}
export const VIEWS = {
title: "Views you can keep",
body: "Save a set of filters once and open it with the count already right.",
filters: [
{ label: "Plan", value: "Business" },
{ label: "Region", value: "EMEA" },
],
/** The count before any filter, after the first, and after both. */
counts: ["2,418 accounts", "1,120 accounts", "214 accounts"],
saved: "Saved as EMEA Business",
}npx shadcn@latest add @vibra/bento-06Security bento
bento-07The audit log taking new entries on top, a sign-in code entered digit by digit and accepted, a member's rights switched on, and the region a workspace's data stays in.
import * as React from "react"
import { ShieldCheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusBadge } from "@/components/ui/status-badge"
import { Switch } from "@/components/ui/switch"
import { WorldMap } from "@/components/ui/world-map"
import { BentoStage } from "./bento-07.client"
import { AUDIT, EYEBROW, HEADLINE, LEDE, MOTION_LABEL, REGION, ROLES, TWO_STEP } from "./bento-07.data"
/** One tile: its heading and line on the frame, its picture on the sheet. */
function Tile({ title, body, className, children }: { title: string; body: string; className?: string; children: React.ReactNode }) {
return (
<li className={cn("flex min-w-0", className)}>
<Card className="w-full">
<CardHeader>
<CardTitle>
<h3>{title}</h3>
</CardTitle>
<CardDescription className="text-pretty">{body}</CardDescription>
</CardHeader>
<CardContent className="relative flex min-h-48 flex-col justify-center">{children}</CardContent>
</Card>
</li>
)
}
const ARRIVE = [
"motion-safe:in-data-[playback=running]:animate-[bento-07-arrive-0_12s_var(--ease-standard)_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-07-arrive-1_12s_var(--ease-standard)_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-07-arrive-2_12s_var(--ease-standard)_infinite]",
]
/**
* One line of the log: when, who and what, on a 44px row the arrivals are
* measured in. A row, not a panel: the card plane is there so a row arriving
* on top covers the ones sliding under it.
*/
function Entry({ entry, className }: { entry: (typeof AUDIT.entries)[number]; className?: string }) {
return (
<li className={cn("grid h-11 grid-cols-[2.75rem_minmax(0,1fr)] items-center gap-x-3 bg-card text-xs border-b border-border", className)}>
<span className="font-mono text-muted-foreground">{entry.time}</span>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{entry.action}</span>
<span className="truncate font-mono text-muted-foreground">{entry.actor}</span>
</span>
</li>
)
}
/** The log, three new entries arriving on top one after another, pushing the older ones down. Decoration. */
function AuditPicture() {
const fresh = AUDIT.entries.slice(0, 3)
const older = AUDIT.entries.slice(3)
return (
<div aria-hidden="true" inert className="flex flex-1 flex-col gap-3">
<div className="relative h-[264px] overflow-hidden rounded-md">
<ul className="relative">
{fresh.map((entry, index) => (
<Entry key={entry.time} entry={entry} className={cn("relative z-10", ARRIVE[index])} />
))}
<li className="motion-safe:in-data-[playback=running]:animate-[bento-07-older_12s_var(--ease-standard)_infinite]">
<ul>
{older.map((entry) => (
<Entry key={entry.time} entry={entry} />
))}
</ul>
</li>
</ul>
</div>
<p className="text-xs text-muted-foreground">{AUDIT.retention}</p>
</div>
)
}
const DIGIT = [
"motion-safe:in-data-[playback=running]:animate-[bento-07-digit-0_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-07-digit-1_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-07-digit-2_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-07-digit-3_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-07-digit-4_12s_linear_infinite]",
"motion-safe:in-data-[playback=running]:animate-[bento-07-digit-5_12s_linear_infinite]",
]
/** A code entered one digit at a time, then accepted. Decoration. */
function TwoStepPicture() {
return (
<div aria-hidden="true" inert className="flex flex-col items-center gap-4">
<p className="text-xs text-muted-foreground">{TWO_STEP.label}</p>
<div className="flex items-center gap-1.5">
{[...TWO_STEP.code].map((digit, index) => (
<React.Fragment key={index}>
{index === 3 ? <span className="w-2 text-center text-muted-foreground">-</span> : null}
<span className="flex size-10 items-center justify-center rounded-md bg-card text-lg font-medium tabular-nums ring-1 ring-input">
<span className={DIGIT[index]}>{digit}</span>
</span>
</React.Fragment>
))}
</div>
<span className="motion-safe:in-data-[playback=running]:animate-[bento-07-verified_12s_var(--ease-standard)_infinite]">
<StatusBadge status="verified" variant="success" label={TWO_STEP.verified} />
</span>
</div>
)
}
/** A member's role turning from viewer to editor, and the two rights that come with it switching on. Decoration. */
function RolesPicture() {
const switchMotion =
"motion-safe:in-data-[playback=running]:animate-[bento-07-track_12s_var(--ease-standard)_infinite] motion-safe:in-data-[playback=running]:[&_[data-slot=switch-thumb]]:animate-[bento-07-thumb_12s_var(--ease-standard)_infinite]"
return (
<div aria-hidden="true" inert className="flex flex-col gap-3">
<div className="flex items-center gap-3 rounded-lg bg-surface p-3 ring-1 ring-border">
<Avatar className="after:hidden">
<AvatarImage src={avatarFor(ROLES.person)} alt="" />
<AvatarFallback>{getInitials(ROLES.person)}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{ROLES.person}</span>
<span className="grid justify-items-end *:col-start-1 *:row-start-1">
<Badge className="motion-safe:in-data-[playback=running]:animate-[bento-07-after_12s_linear_infinite]">{ROLES.after}</Badge>
<Badge variant="outline" className="opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-07-before_12s_linear_infinite]">
{ROLES.before}
</Badge>
</span>
</div>
<ul className="flex flex-col">
{ROLES.permissions.map((permission) => (
<li key={permission} className="flex h-10 items-center justify-between gap-3 border-b border-border px-1 text-sm last:border-b-0">
{permission}
<Switch defaultChecked tabIndex={-1} className={switchMotion} />
</li>
))}
</ul>
</div>
)
}
/** The workspace's region on the kit's WorldMap, its marker sending out a slow beacon, and what stays there. */
function RegionPicture() {
return (
<div role="img" aria-label={REGION.caption} className="flex flex-1 flex-col gap-4">
<WorldMap
markers={[...REGION.places]}
height={220}
className="motion-safe:in-data-[playback=running]:[&_[data-slot=world-map-marker]]:animate-[bento-07-beacon_3s_ease-out_infinite]"
/>
<div className="mt-auto flex flex-wrap items-center gap-2 rounded-lg bg-surface p-3 ring-1 ring-border">
<ShieldCheckIcon className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">{REGION.region}</span>
<span className="ms-auto flex flex-wrap gap-1.5">
{REGION.parts.map((part) => (
<Badge key={part} variant="secondary">
{part}
</Badge>
))}
</span>
</div>
</div>
)
}
/**
* The security bento: the four controls a security review asks about, as
* moving pictures — the audit log taking three new entries on top, a sign-in
* code entered digit by digit and accepted, a member's rights switched on, and
* the region a workspace's data stays in, its marker sending out a beacon. Two
* staggered columns from `md`: the log tall on the left over the code, the
* rights short on the right over the tall map; one column on a phone.
*
* The pictures move only on the kit's MotionStage, which the island hands
* this section's keyframes — while on screen, in a tab in front, and for a
* reader who has not asked for less motion — and its Pause motion toggle
* holds them still; still, each shows its final frame. The map names the
* region, so it is an image named by what it shows; the others are
* decoration, inert, beside words that say the same. A server component;
* every word is in `./bento-07.data`.
*/
export function Bento07() {
const titleId = React.useId()
return (
<section
data-section="bento-07"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-2xl text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mx-auto mt-10 max-w-5xl md:mt-12">
<BentoStage label={MOTION_LABEL}>
<ul className="grid gap-4 md:grid-cols-2">
<Tile title={AUDIT.title} body={AUDIT.body} className="md:row-span-2">
<AuditPicture />
</Tile>
<Tile title={TWO_STEP.title} body={TWO_STEP.body} className="md:col-start-1 md:row-start-3">
<TwoStepPicture />
</Tile>
<Tile title={ROLES.title} body={ROLES.body} className="md:col-start-2 md:row-start-1">
<RolesPicture />
</Tile>
<Tile title={REGION.title} body={REGION.body} className="md:col-start-2 md:row-span-2 md:row-start-2">
<RegionPicture />
</Tile>
</ul>
</BentoStage>
</div>
</div>
</section>
)
}
export default Bento07"use client"
import * as React from "react"
import { MotionStage } from "@/components/ui/motion-stage"
/**
* What bento-07's pictures move on. Every loop starts and ends on the picture
* as it is drawn without motion — the final, informative frame — so a loop can
* begin at any moment without a jump, and stopping it lands on the frame that
* says the most. The tiles apply these through Tailwind, keyed to a running
* stage; the kit's MotionStage decides when that is and hoists this stylesheet
* into the head once, however many copies of the section a page holds, so the
* section installs with its motion and nothing is added to your CSS.
*/
const KEYFRAMES = `
@keyframes bento-07-arrive-0 {
0%, 55% { opacity: 1; transform: translateY(0) }
60% { opacity: 0; transform: translateY(0) }
61%, 88% { opacity: 0; transform: translateY(-6px) }
92%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-07-arrive-1 {
0%, 55% { opacity: 1; transform: translateY(0) }
60% { opacity: 0; transform: translateY(0) }
61%, 75% { opacity: 0; transform: translateY(-50px) }
79%, 88% { opacity: 1; transform: translateY(-44px) }
92%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-07-arrive-2 {
0%, 55% { opacity: 1; transform: translateY(0) }
60% { opacity: 0; transform: translateY(0) }
61% { opacity: 0; transform: translateY(-94px) }
65%, 75% { opacity: 1; transform: translateY(-88px) }
79%, 88% { opacity: 1; transform: translateY(-44px) }
92%, 100% { opacity: 1; transform: translateY(0) }
}
@keyframes bento-07-older {
0%, 56% { transform: translateY(0) }
60%, 61% { transform: translateY(-132px) }
64%, 75% { transform: translateY(-88px) }
79%, 88% { transform: translateY(-44px) }
92%, 100% { transform: translateY(0) }
}
@keyframes bento-07-digit-0 { 0%, 40% { opacity: 1 } 42%, 59% { opacity: 0 } 60%, 100% { opacity: 1 } }
@keyframes bento-07-digit-1 { 0%, 40% { opacity: 1 } 42%, 62% { opacity: 0 } 63%, 100% { opacity: 1 } }
@keyframes bento-07-digit-2 { 0%, 40% { opacity: 1 } 42%, 65% { opacity: 0 } 66%, 100% { opacity: 1 } }
@keyframes bento-07-digit-3 { 0%, 40% { opacity: 1 } 42%, 68% { opacity: 0 } 69%, 100% { opacity: 1 } }
@keyframes bento-07-digit-4 { 0%, 40% { opacity: 1 } 42%, 71% { opacity: 0 } 72%, 100% { opacity: 1 } }
@keyframes bento-07-digit-5 { 0%, 40% { opacity: 1 } 42%, 74% { opacity: 0 } 75%, 100% { opacity: 1 } }
@keyframes bento-07-verified {
0%, 40% { opacity: 1; transform: scale(1) }
42%, 77% { opacity: 0; transform: scale(0.85) }
81%, 100% { opacity: 1; transform: scale(1) }
}
@keyframes bento-07-thumb {
0%, 40% { transform: translateX(0) }
43%, 62% { transform: translateX(calc(-100% + 2px)) }
65%, 100% { transform: translateX(0) }
}
@keyframes bento-07-track {
0%, 40% { background-color: var(--primary) }
43%, 62% { background-color: var(--input) }
65%, 100% { background-color: var(--primary) }
}
@keyframes bento-07-after {
0%, 40% { opacity: 1 }
41.5%, 63.5% { opacity: 0 }
65%, 100% { opacity: 1 }
}
@keyframes bento-07-before {
0%, 41.5%, 63.5%, 100% { opacity: 0 }
43%, 62% { opacity: 1 }
}
@keyframes bento-07-beacon {
0% { stroke: var(--brand); stroke-width: 0.35; stroke-opacity: 0.7 }
70%, 100% { stroke: var(--brand); stroke-width: 6; stroke-opacity: 0 }
}
`
/** bento-07's stage: the kit's MotionStage, carrying this section's keyframes. */
export function BentoStage({ label, children }: { label: string; children: React.ReactNode }) {
return (
<MotionStage label={label} keyframes={{ href: "bento-07-keyframes", css: KEYFRAMES }}>
{children}
</MotionStage>
)
}/**
* Every word and figure `bento-07` sets: the claim, the words on the motion
* switch, and four tiles about the controls a security review asks for —
* each a heading, one line, and what its moving picture shows. The log and
* the people are a sample workspace's, written once here; faces are the kit's
* `avatarFor(name)`.
*/
export const EYEBROW = "Security"
export const HEADLINE = "The answers your security review asks for"
export const LEDE =
"Every view is logged, every sign-in can take a second step, every right can be taken back, and every row stays in the region you chose."
/** The name of the toggle that holds every picture still: pressed, it holds them; pressed again, they move. */
export const MOTION_LABEL = "Pause motion"
export const AUDIT = {
title: "Every view and export, logged",
body: "Who opened which dashboard, exported which rows and changed which definition, kept for a year.",
/** Newest first; the first three arrive in the picture, the rest are already there. */
entries: [
{ time: "09:52", actor: "dana@keystone.example", action: "Shared Board pack, Q3" },
{ time: "09:41", actor: "leo@keystone.example", action: "Exported 1,284 rows" },
{ time: "09:38", actor: "ops@keystone.example", action: "Granted support access" },
{ time: "09:12", actor: "priya@keystone.example", action: "Changed MRR to v14" },
{ time: "09:03", actor: "leo@keystone.example", action: "Signed in with SSO" },
{ time: "08:57", actor: "SCIM", action: "Removed sam@keystone.example" },
],
retention: "Kept for 365 days",
}
export const TWO_STEP = {
title: "A second step for everyone",
body: "Owners can require a code from an authenticator app at every sign-in.",
label: "Enter the 6-digit code",
code: "482913",
verified: "Verified",
}
export const ROLES = {
title: "Rights in one switch",
body: "Give someone edit or export rights, and take them back, without a ticket.",
person: "Leo Park",
before: "Viewer",
after: "Editor",
permissions: ["Can edit dashboards", "Can export rows"],
}
export const REGION = {
title: "Your data stays in its region",
body: "Storage, processing and backups stay in the region chosen when the workspace was made.",
region: "EU region",
parts: ["Storage", "Processing", "Backups"],
places: [{ id: "frankfurt", label: "Frankfurt", lat: 50.11, lng: 8.68, value: 1 }],
caption: "World map with the workspace's region marked at Frankfurt, in the EU, where its storage, processing and backups stay.",
}npx shadcn@latest add @vibra/bento-07Planning bento
bento-08Six ruled cells, no boxes: a forecast opening out with its range, a goal ring filling, cohorts filling in, pipeline stages growing, a price lever moving the quarter and a year of months with a wave passing through.
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { BentoStage } from "./bento-08.client"
import { COHORTS, EYEBROW, FORECAST, GOAL, HEADLINE, LEDE, MOTION_LABEL, PIPELINE, SCENARIO, SEASONS } from "./bento-08.data"
/** One cell of the ruled grid: its picture, then its heading and line under it. */
function Cell({ title, body, children }: { title: string; body: string; children: React.ReactNode }) {
return (
<li className="flex min-w-0 flex-col gap-6 bg-background p-6 md:p-8">
<div className="flex h-40 flex-col justify-center">{children}</div>
<div className="flex flex-col gap-1.5">
<h3 className="text-base font-semibold text-balance">{title}</h3>
<p className="text-sm text-pretty text-muted-foreground">{body}</p>
</div>
</li>
)
}
/** Revenue booked, drawn as a line, then the forecast opening out to the right with the range it could land in. */
function ForecastPicture() {
const all = [...FORECAST.actual, ...FORECAST.forecast.map((month) => month.mid)]
const x = (i: number) => 4 + (i * 232) / (all.length - 1)
const y = (v: number) => 116 - ((v - 90) / 80) * 108
const last = FORECAST.actual.length - 1
const actual = FORECAST.actual.map((v, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(" ")
const ahead = FORECAST.forecast.map((month, i) => ({ ...month, x: x(last + 1 + i) }))
const mid = `M${x(last)} ${y(FORECAST.actual[last])} ${ahead.map((m) => `L${m.x.toFixed(1)} ${y(m.mid).toFixed(1)}`).join(" ")}`
const band = `M${x(last)} ${y(FORECAST.actual[last])} ${ahead.map((m) => `L${m.x.toFixed(1)} ${y(m.high).toFixed(1)}`).join(" ")} ${[...ahead]
.reverse()
.map((m) => `L${m.x.toFixed(1)} ${y(m.low).toFixed(1)}`)
.join(" ")} Z`
return (
<svg role="img" aria-label={FORECAST.caption} viewBox="0 0 240 120" className="h-full w-full overflow-visible">
<line x1={x(last)} x2={x(last)} y1={4} y2={116} stroke="var(--chart-grid)" strokeDasharray="3 4" />
<g className="motion-safe:in-data-[playback=running]:animate-[bento-08-reveal_12s_linear_infinite]">
<path d={band} fill="var(--chart-2)" fillOpacity={0.12} />
<path d={mid} fill="none" stroke="var(--chart-2)" strokeWidth={2} strokeDasharray="4 4" />
</g>
<path
d={actual}
fill="none"
stroke="var(--chart-1)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray="1 1"
className="motion-safe:in-data-[playback=running]:animate-[bento-08-draw_12s_linear_infinite]"
/>
</svg>
)
}
/** The quarter's goal as a ring filling to where the team is today. */
function GoalPicture() {
const rest = { strokeDashoffset: 1 - GOAL.share, "--rest": 1 - GOAL.share } as React.CSSProperties
return (
<div role="img" aria-label={GOAL.caption} className="flex items-center gap-5">
<svg viewBox="0 0 100 100" className="size-32 shrink-0 -rotate-90">
<circle cx={50} cy={50} r={40} fill="none" stroke="var(--muted)" strokeWidth={10} />
<circle
cx={50}
cy={50}
r={40}
fill="none"
stroke="var(--brand)"
strokeWidth={10}
strokeLinecap="round"
pathLength={1}
strokeDasharray="1 1"
style={rest}
className="motion-safe:in-data-[playback=running]:animate-[bento-08-ring_12s_linear_infinite]"
/>
</svg>
<span className="flex flex-col">
<span className="text-xs text-muted-foreground">{GOAL.label}</span>
<span className="type-numeral text-2xl">{GOAL.value}</span>
<span className="text-xs text-muted-foreground">{GOAL.target}</span>
</span>
</div>
)
}
/** Each cohort's retention as a row of cells in one hue, the rows filling in from the oldest. Decoration. */
function CohortPicture() {
const columns = Math.max(...COHORTS.values.map((row) => row.length))
return (
<div aria-hidden="true" inert className="flex flex-col gap-1">
{COHORTS.values.map((row, r) => (
<div
key={COHORTS.rows[r]}
style={{ animationDelay: `${r * 0.3}s` }}
className="grid grid-cols-[2rem_repeat(6,minmax(0,1fr))] items-center gap-1 motion-safe:in-data-[playback=running]:animate-[bento-08-cell_12s_linear_infinite]"
>
<span className="text-xs text-muted-foreground">{COHORTS.rows[r]}</span>
{Array.from({ length: columns }, (_, c) => (
<span
key={c}
style={row[c] === undefined ? undefined : { backgroundColor: `color-mix(in oklch, var(--chart-2) ${Math.round(((row[c] - 60) / 40) * 90 + 10)}%, transparent)` }}
className={cn("h-5 rounded-[3px]", row[c] === undefined && "bg-muted/50")}
/>
))}
</div>
))}
</div>
)
}
/** The pipeline's stages as bars growing in from the left, each shorter than the one before. */
function PipelinePicture() {
const top = PIPELINE.stages[0].value
return (
// One picture of the funnel, read by its caption: a list inside an image
// role orphaned its items, so the rows are plain boxes.
<div role="img" aria-label={PIPELINE.caption} className="flex flex-col gap-2">
{PIPELINE.stages.map((stage, index) => (
<div key={stage.label} className="grid grid-cols-[4.5rem_minmax(0,1fr)_3rem] items-center gap-2 text-xs">
<span className="text-muted-foreground">{stage.label}</span>
<span className="h-5">
<span
style={{ width: `${(stage.value / top) * 100}%`, animationDelay: `${index * 0.15}s` }}
className={cn(
"block h-full origin-left rounded-[3px] motion-safe:in-data-[playback=running]:animate-[bento-08-stage_12s_var(--ease-standard)_infinite]",
index === PIPELINE.stages.length - 1 ? "bg-(--chart-1)" : "bg-(--chart-1)/40"
)}
/>
</span>
<span className="text-right tabular-nums">{formatNumber(stage.value)}</span>
</div>
))}
</div>
)
}
/** A price lever slid back and forward again, and the quarter it moves. Decoration. */
function ScenarioPicture() {
const ends = { "--from": "38%", "--to": "62%" } as React.CSSProperties
return (
<div aria-hidden="true" inert style={ends} className="flex flex-col gap-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{SCENARIO.lever}</span>
<span className="grid justify-items-end font-medium tabular-nums *:col-start-1 *:row-start-1">
<span className="motion-safe:in-data-[playback=running]:animate-[bento-08-now_12s_linear_infinite]">{SCENARIO.to.label}</span>
<span className="opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-08-then_12s_linear_infinite]">{SCENARIO.from.label}</span>
</span>
</div>
<div className="relative h-4">
<span className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" />
<span className="absolute top-1/2 left-0 h-1.5 w-(--to) -translate-y-1/2 rounded-full bg-brand motion-safe:in-data-[playback=running]:animate-[bento-08-fill_12s_var(--ease-emphasized)_infinite]" />
<span className="absolute top-0 left-(--to) size-4 -translate-x-1/2 rounded-full bg-background ring-2 ring-brand motion-safe:in-data-[playback=running]:animate-[bento-08-thumb_12s_var(--ease-emphasized)_infinite]" />
</div>
<div className="flex items-end justify-between">
<span className="text-xs text-muted-foreground">{SCENARIO.note}</span>
<span className="grid justify-items-end type-numeral text-2xl *:col-start-1 *:row-start-1">
<span className="motion-safe:in-data-[playback=running]:animate-[bento-08-now_12s_linear_infinite]">{SCENARIO.to.figure}</span>
<span className="opacity-0 motion-safe:in-data-[playback=running]:animate-[bento-08-then_12s_linear_infinite]">{SCENARIO.from.figure}</span>
</span>
</div>
</div>
)
}
/** Twelve months of signups, a slow wave passing through them; this month in the accent. Decoration. */
function SeasonsPicture() {
return (
<div aria-hidden="true" inert className="flex flex-col gap-2">
<div className="flex h-28 items-end gap-1.5">
{SEASONS.values.map((value, index) => (
<span
key={index}
style={{ height: `${value}%`, animationDelay: `${index * 0.12}s` }}
className={cn(
"flex-1 origin-bottom rounded-t-[3px] motion-safe:in-data-[playback=running]:animate-[bento-08-wave_6s_var(--ease-standard)_infinite]",
index === SEASONS.current ? "bg-(--chart-1)" : "bg-(--chart-1)/40"
)}
/>
))}
</div>
<div className="flex gap-1.5 text-center text-xs text-muted-foreground">
{SEASONS.months.map((month, index) => (
<span key={index} className="flex-1">
{month}
</span>
))}
</div>
</div>
)
}
/**
* The planning bento, ruled rather than boxed: six cells divided by hairlines,
* each a small moving picture over its heading and line — a forecast opening
* out with its range, a goal ring filling, cohorts filling row by row, the
* pipeline's stages growing in, a price lever moving the quarter, and a year
* of months with a slow wave passing through. Three across from `lg`, two from
* `sm`, one on a phone.
*
* The pictures move only on the kit's MotionStage, which the island hands
* this section's keyframes — while on screen, in a tab in front, and for a
* reader who has not asked for less motion — and its Pause motion toggle
* holds them still; still, each shows its final frame. The forecast, the goal
* and the pipeline carry figures, so each is an image named by what it shows;
* the other three are decoration beside words that say the same. A server
* component; every word and figure is in `./bento-08.data`.
*/
export function Bento08() {
const titleId = React.useId()
return (
<section
data-section="bento-08"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-3xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<BentoStage label={MOTION_LABEL}>
{/* The rules are the gaps: a hairline of the rule colour between cells, and no box around them. */}
<ul className="grid gap-px border-y border-rule bg-rule sm:grid-cols-2 lg:grid-cols-3">
<Cell title={FORECAST.title} body={FORECAST.body}>
<ForecastPicture />
</Cell>
<Cell title={GOAL.title} body={GOAL.body}>
<GoalPicture />
</Cell>
<Cell title={COHORTS.title} body={COHORTS.body}>
<CohortPicture />
</Cell>
<Cell title={PIPELINE.title} body={PIPELINE.body}>
<PipelinePicture />
</Cell>
<Cell title={SCENARIO.title} body={SCENARIO.body}>
<ScenarioPicture />
</Cell>
<Cell title={SEASONS.title} body={SEASONS.body}>
<SeasonsPicture />
</Cell>
</ul>
</BentoStage>
</div>
</div>
</section>
)
}
export default Bento08"use client"
import * as React from "react"
import { MotionStage } from "@/components/ui/motion-stage"
/**
* What bento-08's pictures move on. Every loop starts and ends on the picture
* as it is drawn without motion — the final, informative frame — so a loop can
* begin at any moment without a jump, and stopping it lands on the frame that
* says the most. The tiles apply these through Tailwind, keyed to a running
* stage; the kit's MotionStage decides when that is and hoists this stylesheet
* into the head once, however many copies of the section a page holds, so the
* section installs with its motion and nothing is added to your CSS.
*/
const KEYFRAMES = `
@keyframes bento-08-draw {
0%, 56% { stroke-dashoffset: 0; opacity: 1 }
60% { stroke-dashoffset: 0; opacity: 0 }
61% { stroke-dashoffset: 1; opacity: 0 }
62% { stroke-dashoffset: 1; opacity: 1; animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1) }
78%, 100% { stroke-dashoffset: 0; opacity: 1 }
}
@keyframes bento-08-reveal {
0%, 56% { clip-path: inset(0 0 0 0) }
60% { clip-path: inset(0 100% 0 0) }
78% { clip-path: inset(0 100% 0 0); animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1) }
92%, 100% { clip-path: inset(0 0 0 0) }
}
@keyframes bento-08-ring {
0%, 56% { stroke-dashoffset: var(--rest) }
60% { stroke-dashoffset: 1; animation-timing-function: cubic-bezier(0.2, 0, 0, 1) }
86%, 100% { stroke-dashoffset: var(--rest) }
}
@keyframes bento-08-cell {
0%, 52% { opacity: 1 }
56%, 62% { opacity: 0 }
66%, 100% { opacity: 1 }
}
@keyframes bento-08-stage {
0%, 56% { transform: scaleX(1) }
60%, 64% { transform: scaleX(0) }
76%, 100% { transform: scaleX(1) }
}
@keyframes bento-08-thumb {
0%, 36% { left: var(--to) }
44%, 60% { left: var(--from) }
76%, 100% { left: var(--to) }
}
@keyframes bento-08-then {
0%, 41%, 67%, 100% { opacity: 0 }
44%, 64% { opacity: 1 }
}
@keyframes bento-08-now {
0%, 38% { opacity: 1 }
41%, 67% { opacity: 0 }
70%, 100% { opacity: 1 }
}
@keyframes bento-08-wave {
0%, 100% { transform: scaleY(1) }
8% { transform: scaleY(0.82) }
16% { transform: scaleY(1) }
}
@keyframes bento-08-fill {
0%, 36% { width: var(--to) }
44%, 60% { width: var(--from) }
76%, 100% { width: var(--to) }
}
`
/** bento-08's stage: the kit's MotionStage, carrying this section's keyframes. */
export function BentoStage({ label, children }: { label: string; children: React.ReactNode }) {
return (
<MotionStage label={label} keyframes={{ href: "bento-08-keyframes", css: KEYFRAMES }}>
{children}
</MotionStage>
)
}/**
* Every word and figure `bento-08` sets: the claim, the words on the motion
* switch, and six ruled cells about planning — each a picture, a heading and
* one line. The figures are a sample workspace's plan for its fourth quarter,
* written once here; a picture that carries figures also carries the sentence
* a screen reader hears instead.
*/
export const EYEBROW = "Planning"
export const HEADLINE = "Plan the quarter from the numbers you already trust"
export const LEDE =
"Forecasts, goals, cohorts and scenarios are built on the same definitions as the dashboards, so the plan and the review never disagree."
/** The name of the toggle that holds every picture still: pressed, it holds them; pressed again, they move. */
export const MOTION_LABEL = "Pause motion"
export const FORECAST = {
title: "Forecasts with a range",
body: "Every forecast shows how far it could move, not only where it lands.",
/** Revenue in $K a month: eight months booked, then four forecast with their low and high. */
actual: [98, 104, 101, 109, 114, 112, 121, 126],
forecast: [
{ mid: 131, low: 126, high: 136 },
{ mid: 137, low: 129, high: 145 },
{ mid: 142, low: 131, high: 153 },
{ mid: 149, low: 134, high: 164 },
],
caption: "Monthly revenue booked from $98K to $126K over eight months, then forecast to reach $149K in four more, somewhere between $134K and $164K.",
}
export const GOAL = {
title: "Goals you can see",
body: "Set a target for the quarter and see how far along the team is today.",
label: "Q4 new MRR",
value: "$1.04M",
target: "of $1.44M",
share: 0.72,
caption: "Quarterly goal: $1.04M of $1.44M of new MRR, 72% of the way.",
}
export const COHORTS = {
title: "Cohorts at a glance",
body: "Retention by signup month, one row for each cohort.",
rows: ["Apr", "May", "Jun", "Jul", "Aug"],
/** Percent still paying after each month; a month that has not happened is left out. */
values: [
[100, 86, 79, 75, 73, 72],
[100, 88, 81, 78, 76],
[100, 87, 82, 79],
[100, 90, 84],
[100, 91],
],
}
export const PIPELINE = {
title: "Pipeline by stage",
body: "From lead to won, with the share each stage keeps.",
stages: [
{ label: "Leads", value: 1240 },
{ label: "Qualified", value: 610 },
{ label: "Proposal", value: 240 },
{ label: "Won", value: 96 },
],
caption: "Pipeline: 1,240 leads, 610 qualified, 240 at proposal and 96 won.",
}
export const SCENARIO = {
title: "What-if, before you commit",
body: "Move a price or a churn rate and watch the quarter change.",
lever: "Price",
from: { label: "+0%", figure: "$1.44M" },
to: { label: "+5%", figure: "$1.52M" },
note: "Q4 revenue",
}
export const SEASONS = {
title: "Seasons, not surprises",
body: "Twelve months side by side show which ones always dip.",
months: ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"],
/** Signups a month as a share of the year's best, January first; September is this month. */
values: [62, 70, 84, 80, 76, 58, 52, 55, 88, 96, 100, 71],
current: 8,
}npx shadcn@latest add @vibra/bento-08