/saas/customersCustomers dashboard
A customer acquisition page: how many accounts there are and what they are worth, the funnel from ad clicks down to paying workspaces, signups a month with the month in progress projected out, campaign targets, and the whole directory.
A server component inside AppShell: accounts come from db.customers, the spend that brought them in from db.campaigns, and the tiers from db.plans. The growth chart shows twelve complete months plus the one in progress, and because that last month is short by definition it carries a second, hatched bar — the projection at the trailing 90-day signup rate. That hatch is a chart-core escape hatch rather than a new component: a series colour that is not one of the eight chart tokens is passed straight through as a CSS paint, so the series names an SVG pattern with url(#id) and the bars resolve to it. The chart's own legend paints its swatch as a background colour, which a paint server cannot fill, so the key is drawn in the footer in SVG instead. Two things the rows do not carry are rules over them: that projection, and each campaign's conversion target — a twentieth of the clicks it bought, rounded to the nearest 25. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, ChartCard, BarChart, Widget, FunnelChart, GoalTracker, DataTable, UserCell, StatusBadge, NumberCell, DateCell and TruncateCell.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AcquisitionFunnel } from "./components/acquisition-funnel"
import { CampaignGoals } from "./components/campaign-goals"
import { CustomerDirectory } from "./components/customer-directory"
import { CustomerStats } from "./components/customer-stats"
import { GrowthByMonth } from "./components/growth-by-month"
import {
acquisitionFunnel,
acquisitionSpend,
campaignGoals,
currentUser,
customerStats,
directory,
growthByMonth,
lastUpdated,
planNames,
shellNotifications,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/saas/nav"
/**
* The customer overview. A server component: it reads accounts and campaigns
* through `db` and hands plain rows to the client islands that draw them, so
* the data layer never has to cross into the browser bundle.
*/
export default function CustomersOverviewPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.customers}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Customers"
description="Where accounts come from, how many arrive, and what they are worth."
meta={lastUpdated()}
/>
<CustomerStats stats={customerStats()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<GrowthByMonth rows={growthByMonth()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<AcquisitionFunnel steps={acquisitionFunnel()} spend={acquisitionSpend()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<CampaignGoals goals={campaignGoals()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<CustomerDirectory rows={directory()} planNames={planNames()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/saas-customersNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AcquisitionFunnel } from "./components/acquisition-funnel"
import { CampaignGoals } from "./components/campaign-goals"
import { CustomerDirectory } from "./components/customer-directory"
import { CustomerStats } from "./components/customer-stats"
import { GrowthByMonth } from "./components/growth-by-month"
import {
acquisitionFunnel,
acquisitionSpend,
campaignGoals,
currentUser,
customerStats,
directory,
growthByMonth,
lastUpdated,
planNames,
shellNotifications,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/saas/nav"
/**
* The customer overview. A server component: it reads accounts and campaigns
* through `db` and hands plain rows to the client islands that draw them, so
* the data layer never has to cross into the browser bundle.
*/
export default function CustomersOverviewPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.customers}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Customers"
description="Where accounts come from, how many arrive, and what they are worth."
meta={lastUpdated()}
/>
<CustomerStats stats={customerStats()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<GrowthByMonth rows={growthByMonth()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<AcquisitionFunnel steps={acquisitionFunnel()} spend={acquisitionSpend()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
<CampaignGoals goals={campaignGoals()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
<CustomerDirectory rows={directory()} planNames={planNames()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}/**
* What /customers/overview reads. The accounts are `db.customers`, the spend
* that brought them in is `db.campaigns`, and the plans they sit on are
* `db.plans`. Two things the rows do not carry are rules over them: a
* campaign's conversion target, set from the clicks it bought, and next
* month's projection, run on the trailing daily rate. "Now" is REFERENCE_DATE,
* so the month in progress is always the same one.
*/
import { getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Campaign, type Customer, type Member } from "@/lib/sample-data"
export type { Customer }
// Read per call, never held at module scope: an account or a campaign written
// since the server started is what the next render counts.
const allCustomers = () => db.customers.all()
const allCampaigns = () => db.campaigns.all()
const DAY_MS = 86_400_000
/** Midnight UTC on the first of the month `k` months before the current one. */
function monthStart(k: number): Date {
return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - k, 1))
}
const THIS_MONTH = monthStart(0)
const LAST_MONTH = monthStart(1)
const PRIOR_MONTH = monthStart(2)
const createdIn = (from: Date, to: Date): number =>
allCustomers().filter((row) => row.createdAt >= from && row.createdAt < to).length
const paying = (row: Customer): boolean => row.mrrCents > 0
export type CustomerStat = {
key: string
label: string
value: number
delta?: number
description: string
format: "number" | "percent"
positiveIsGood: boolean
}
/** The four headline numbers: how many, how many new, how many pay, how many left. */
export function customerStats(): CustomerStat[] {
const customers = allCustomers()
const live = customers.filter((row) => row.status === "active" || row.status === "trial").length
const fresh = createdIn(LAST_MONTH, THIS_MONTH)
const before = createdIn(PRIOR_MONTH, LAST_MONTH)
const churned = customers.filter((row) => row.status === "churned").length
return [
{
key: "live",
label: "Live workspaces",
value: live,
description: `of ${customers.length} ever created`,
format: "number",
positiveIsGood: true,
},
{
key: "new",
label: "New last month",
value: fresh,
delta: before > 0 ? fresh / before - 1 : 0,
description: "vs the month before",
format: "number",
positiveIsGood: true,
},
{
key: "paying",
label: "Paying",
value: customers.filter(paying).length,
description: "on a plan that bills",
format: "number",
positiveIsGood: true,
},
{
key: "churned",
label: "Churned",
value: churned / customers.length,
description: "of every account ever opened",
format: "percent",
positiveIsGood: false,
},
]
}
const isLive = (row: Customer): boolean => row.status === "active" || row.status === "trial"
/**
* Every workspace ever opened, down to the ones on a big tier. Each step is a
* subset of the one above it, so the funnel narrows for a reason rather than
* comparing two unrelated counts — which is also why the clicks and leads that
* bought these accounts are a line of copy beside it rather than a first step:
* half a million clicks against 250 workspaces draws four invisible bars.
*/
export function acquisitionFunnel(): { label: string; value: number }[] {
const customers = allCustomers()
const live = customers.filter(isLive)
return [
{ label: "Workspaces created", value: customers.length },
{ label: "Still live", value: live.length },
{ label: "On a paid plan", value: live.filter((row) => row.plan !== "free").length },
// Not "Paying": that is the stat card above, which counts every account
// that bills anything. This step is narrower — a live account on a paid
// plan that is being invoiced now, which a trial on that plan is not.
{ label: "Billing now", value: live.filter(paying).length },
{
label: "Team or Enterprise",
value: live.filter((row) => paying(row) && (row.plan === "team" || row.plan === "enterprise"))
.length,
},
]
}
/** What the campaigns delivered, for the line under the funnel. */
export function acquisitionSpend(): { clicks: number; leads: number } {
const campaigns = allCampaigns()
return {
clicks: campaigns.reduce((total, campaign) => total + campaign.clicks, 0),
leads: campaigns.reduce((total, campaign) => total + campaign.conversions, 0),
}
}
/** How many complete months of signups the chart shows before the one in progress. */
export const GROWTH_MONTHS = 12
// The window the run rate is measured over: long enough to smooth a slow week,
// short enough that a projection still describes now.
const RATE_DAYS = 90
export type GrowthPoint = {
month: string
created: number
/** Only the month in progress carries one; every other month is done. */
projected: number | null
}
/**
* Signups by month, ending on the month in progress. That last bar is only as
* long as the month has run, so it gets a second, hatched bar beside it: where
* it lands if the trailing daily rate holds to the end of the month.
*/
export function growthByMonth(): GrowthPoint[] {
const rate = createdIn(new Date(REFERENCE_DATE.getTime() - RATE_DAYS * DAY_MS), REFERENCE_DATE) / RATE_DAYS
// Day 0 of the next month is the last day of this one.
const monthEnd = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + 1, 0))
const daysLeft = Math.max(0, Math.round((monthEnd.getTime() - REFERENCE_DATE.getTime()) / DAY_MS))
return Array.from({ length: GROWTH_MONTHS + 1 }, (_, index) => {
const from = monthStart(GROWTH_MONTHS - index)
const to = monthStart(GROWTH_MONTHS - index - 1)
const created = createdIn(from, index === GROWTH_MONTHS ? REFERENCE_DATE : to)
return {
month: from.toISOString().slice(0, 7),
created,
projected: index === GROWTH_MONTHS ? created + Math.round(rate * daysLeft) : null,
}
})
}
// The plans as a reader sees them on the pricing page, keyed by the value a
// customer row carries: "team" → "Team".
const PLAN_NAMES = new Map(db.plans.all().map((plan) => [plan.name.toLowerCase(), plan.name]))
/** A customer's plan, as the pricing page writes it. */
export function planName(plan: Customer["plan"]): string {
return PLAN_NAMES.get(plan) ?? plan
}
/** The plans a row can carry, in the order the pricing page lists them. */
const PLANS: Customer["plan"][] = ["free", "starter", "team", "enterprise"]
/** Every plan as the pricing page writes it, for the directory to look up — it is an island, and never reads the store. */
export function planNames(): Record<Customer["plan"], string> {
return Object.fromEntries(PLANS.map((plan) => [plan, planName(plan)])) as Record<Customer["plan"], string>
}
/** Every account, newest first — the directory the table pages through. */
export function directory(): Customer[] {
const customers = allCustomers()
return [...customers].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}
export type CampaignGoal = {
id: string
name: string
channel: Campaign["channel"]
conversions: number
/** What the clicks it bought should have converted, rounded to a round number. */
goal: number
status: Campaign["status"]
deadline: string
}
const ENDED = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
/**
* The five campaigns that bought the most clicks, each against its own target:
* a twentieth of those clicks, rounded to the nearest 25, which is how a
* marketing target gets written down. Ranked by clicks rather than by
* conversions, so the list is not just the ones that already beat it.
*/
export function campaignGoals(): CampaignGoal[] {
const campaigns = allCampaigns()
return [...campaigns]
.sort((a, b) => b.clicks - a.clicks)
.slice(0, 5)
.map((campaign) => ({
id: campaign.id,
name: campaign.name,
channel: campaign.channel,
conversions: campaign.conversions,
goal: Math.max(25, Math.round((campaign.clicks * 0.05) / 25) * 25),
status: campaign.status,
deadline: campaign.endedAt
? `Ended ${ENDED.format(campaign.endedAt)}`
: `Running since ${ENDED.format(campaign.startedAt)}`,
}))
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })
/** Which month the page is reading, and which one is still running. */
export function lastUpdated(): string {
return `Closed through ${MONTH_NAME.format(LAST_MONTH)} · ${MONTH_NAME.format(THIS_MONTH)} still running`
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* The one thing this page changes. A server action so the page can stay a
* server component and still hand the shell something to call, and a `Result`
* so the caller reads the same success-or-error shape every mutation returns.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import { formatCompact } from "@/lib/format"
import { FunnelChart } from "@/components/ui/funnel-chart"
import { Widget } from "@/components/ui/widget"
export type AcquisitionFunnelProps = {
steps: { label: string; value: number }[]
spend: { clicks: number; leads: number }
}
/** Every workspace ever opened at the top, the biggest tiers at the bottom. */
export function AcquisitionFunnel({ steps, spend }: AcquisitionFunnelProps) {
return (
<Widget
data-widget="widget-saas-customers-acquisition-funnel"
title="Acquisition funnel"
description="Every workspace ever opened, down to the ones on a big tier"
className="h-full"
footer={`${formatCompact(spend.clicks)} ad clicks and ${formatCompact(spend.leads)} leads brought them in`}
>
<FunnelChart steps={steps} valueFormatter={(value) => formatCompact(value)} />
</Widget>
)
}"use client"
import { formatNumber } from "@/lib/format"
import { GoalTracker } from "@/components/ui/goal-tracker"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"
import { type CampaignGoal } from "../data"
const STATUS_MAP: Record<string, StatusVariant> = {
active: "success",
paused: "warning",
completed: "neutral",
}
const conversions = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
/** The five biggest spends, each against the conversions its clicks should buy. */
export function CampaignGoals({ goals }: { goals: CampaignGoal[] }) {
return (
<Widget
data-widget="widget-saas-customers-campaign-goals"
title="Campaign goals"
description="Conversions against target, for the five campaigns that bought the most clicks"
className="h-full"
>
<div className="flex flex-col gap-5">
{goals.map((goal) => (
<GoalTracker
key={goal.id}
// A plain string, so the bar takes the campaign's name as its own
// accessible name; the state goes in the footer beside the dates.
label={goal.name}
current={goal.conversions}
goal={goal.goal}
format={conversions}
deadline={
<span className="flex flex-wrap items-center gap-2">
<StatusBadge status={goal.status} map={STATUS_MAP} size="sm" />
{goal.deadline}
</span>
}
/>
))}
</div>
</Widget>
)
}"use client"
import * as React from "react"
import { ArrowRightIcon, MailIcon, UserRoundXIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import {
DataTable,
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
type DataTableFacet,
} from "@/components/ui/data-table"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { DateCell, NumberCell, TruncateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { type Customer } from "../data"
/** The three account states StatusBadge has no default variant for. */
const STATUS_MAP: Record<string, StatusVariant> = {
trial: "info",
churned: "danger",
suspended: "warning",
}
const STATUS_FACET: DataTableFacet<Customer> = {
columnId: "status",
title: "Status",
options: ["active", "trial", "churned", "suspended"].map((status) => ({
label: status.charAt(0).toUpperCase() + status.slice(1),
value: status,
})),
}
export type CustomerDirectoryProps = {
rows: Customer[]
/** Each plan as the pricing page writes it, in the pricing page's order — looked up on the server. */
planNames: Record<Customer["plan"], string>
}
export function CustomerDirectory({ rows, planNames }: CustomerDirectoryProps) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const facets = React.useMemo<DataTableFacet<Customer>[]>(
() => [
{
columnId: "plan",
title: "Plan",
options: Object.entries(planNames).map(([value, label]) => ({ label, value })),
},
STATUS_FACET,
],
[planNames]
)
const columns = React.useMemo<DataTableColumnDef<Customer>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Person" />,
cell: ({ row }) => (
<UserCell size="sm" name={row.original.name} email={row.original.email} src={row.original.avatarUrl} />
),
meta: { label: "Person" },
},
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Workspace" />,
cell: ({ row }) => <TruncateCell maxWidth={200}>{row.original.company}</TruncateCell>,
meta: { label: "Workspace" },
},
{
accessorKey: "plan",
header: ({ column }) => <DataTableColumnHeader column={column} title="Plan" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{planNames[row.original.plan] ?? row.original.plan}</span>
),
meta: { label: "Plan" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} map={STATUS_MAP} />,
meta: { label: "Status" },
},
{
accessorKey: "seats",
header: ({ column }) => <DataTableColumnHeader column={column} title="Seats" />,
cell: ({ row }) => <NumberCell value={row.original.seats} />,
meta: { align: "right", label: "Seats" },
},
{
accessorKey: "mrrCents",
header: ({ column }) => <DataTableColumnHeader column={column} title="MRR" />,
// Zero is not "no revenue recorded" — it is a free, trial or churned
// account, which reads better as a dash than as $0.00.
cell: ({ row }) => (
<span className="block text-right tabular-nums">
{row.original.mrrCents > 0 ? (
formatCurrency(row.original.mrrCents / 100, "USD", { maximumFractionDigits: 0 })
) : (
<span className="text-muted-foreground">—</span>
)}
</span>
),
meta: { align: "right", label: "MRR" },
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Joined" />,
cell: ({ row }) => (
<DateCell date={row.original.createdAt} className="text-muted-foreground" />
),
meta: { align: "right", label: "Joined" },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Open menu for ${row.original.name}`}
actions={[
{ label: "Open workspace", icon: <ArrowRightIcon />, onSelect: () => {} },
{ label: "Email the owner", icon: <MailIcon />, onSelect: () => {} },
{
label: "Suspend account",
icon: <UserRoundXIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => {},
},
]}
/>
),
},
],
[planNames]
)
return (
<Widget titleId={titleId} data-widget="widget-saas-customers-customer-directory" title="Directory" description="Every workspace, newest first">
<DataTable
aria-labelledby={titleId}
size="sm"
columns={columns}
data={rows}
pageSize={8}
searchKey="company"
searchPlaceholder="Search workspaces…"
facets={facets}
enableRowSelection={false}
getRowId={(customer) => customer.id}
initialSorting={[{ id: "createdAt", desc: true }]}
/>
</Widget>
)
}import { BuildingIcon, CreditCardIcon, UserMinusIcon, UserPlusIcon } from "lucide-react"
import { formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type CustomerStat } from "../data"
// One icon per headline, keyed by the stat rather than its position, so the row
// can be reordered without the icons following the wrong numbers.
const ICONS: Record<string, React.ReactNode> = {
live: <BuildingIcon />,
new: <UserPlusIcon />,
paying: <CreditCardIcon />,
churned: <UserMinusIcon />,
}
/** How many accounts there are, how many arrived, how many pay, how many left. */
export function CustomerStats({ stats }: { stats: CustomerStat[] }) {
return (
<StatCardGroup data-widget="widget-saas-customers-customer-stats" columns={4}>
{stats.map((stat) => (
<StatCard
key={stat.key}
label={stat.label}
value={
stat.format === "percent"
? formatPercent(stat.value, { maximumFractionDigits: 1 })
: formatNumber(stat.value, { maximumFractionDigits: 0 })
}
delta={stat.delta}
positiveIsGood={stat.positiveIsGood}
description={stat.description}
icon={ICONS[stat.key]}
/>
))}
</StatCardGroup>
)
}"use client"
import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { type GrowthPoint } from "../data"
// Fixed to UTC so the axis reads the same wherever the page is rendered, and
// carrying the year: thirteen months cross one, and two bars both reading
// "Sep" would be a year apart.
const MONTH_LABEL = new Intl.DateTimeFormat("en-US", {
month: "short",
year: "2-digit",
timeZone: "UTC",
})
// The hatch that says "not counted yet". Referenced by url() rather than named
// as a chart token, which chart-core allows: a series colour that is not one of
// the eight tokens is passed through as a raw CSS paint, so the bar's fill
// resolves to this pattern and everything else about the series is unchanged.
const PATTERN_ID = "vibra-growth-projected"
const SERIES = [
{ key: "created", label: "New workspaces", color: "chart-1" as const },
{ key: "projected", label: "Projected", color: `url(#${PATTERN_ID})` },
]
const count = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
/** A swatch for the footer key, drawn with the same paint as the bars. */
function Swatch({ fill }: { fill: string }) {
return (
<svg aria-hidden="true" focusable="false" width={10} height={10} className="shrink-0">
<rect width={10} height={10} rx={2} fill={fill} />
</svg>
)
}
/**
* Signups by month. The month in progress is short by definition, so it carries
* a second, hatched bar: where it lands if the trailing rate holds.
*/
export function GrowthByMonth({ rows }: { rows: GrowthPoint[] }) {
return (
<ChartCard
data-widget="widget-saas-customers-growth-by-month"
title="Growth by month"
description="New workspaces a month, with the month in progress projected out"
height={340}
className="h-full"
footer={
// The chart's own legend paints its swatch as a background colour,
// which a paint-server url() cannot fill, so the key is drawn here in
// SVG where the pattern resolves.
<span className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span className="flex items-center gap-1.5">
<Swatch fill="var(--chart-1)" />
New workspaces
</span>
<span className="flex items-center gap-1.5">
<Swatch fill={`url(#${PATTERN_ID})`} />
Projected, at the trailing 90-day rate
</span>
</span>
}
>
{/* The paint server itself. It only has to be somewhere in the document
for url(#id) to resolve, so it sits outside the plot with no size. */}
<svg aria-hidden="true" focusable="false" className="absolute size-0">
<defs>
<pattern
id={PATTERN_ID}
width={6}
height={6}
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<rect width={6} height={6} fill="var(--chart-1)" opacity={0.16} />
<rect width={2.5} height={6} fill="var(--chart-1)" opacity={0.75} />
</pattern>
</defs>
</svg>
<BarChart
data={rows}
index="month"
series={SERIES}
height={340}
showYAxis
showLegend={false}
valueFormatter={count}
indexFormatter={(value) => MONTH_LABEL.format(new Date(`${value}-01T00:00:00Z`))}
/>
</ChartCard>
)
}