feat(docs): add analytics and pattern showcase stories

This commit is contained in:
2026-03-25 19:59:42 +08:00
parent a5d75f42e9
commit f049736c8a
21 changed files with 5765 additions and 1204 deletions
+346
View File
@@ -0,0 +1,346 @@
import {
Chart,
ChartChange,
ChartDescription,
ChartHeader,
ChartHeaderAside,
ChartHeaderLeading,
ChartMetrics,
ChartTitle,
ChartValue,
type ChartSeries,
type ChartTooltipContext
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type SalesImpactDatum = {
gross: number;
month: string;
revenue: number;
};
const salesImpactData: SalesImpactDatum[] = [
{ gross: 52450, month: "Jan", revenue: 71280 },
{ gross: 112400, month: "Feb", revenue: 92320 },
{ gross: 98420, month: "Mar", revenue: 138260 },
{ gross: 152800, month: "Apr", revenue: 165420 },
{ gross: 82450, month: "May", revenue: 125320 },
{ gross: 136200, month: "Jun", revenue: 186540 },
{ gross: 118320, month: "Jul", revenue: 172480 },
{ gross: 121560, month: "Aug", revenue: 149860 },
{ gross: 109440, month: "Sep", revenue: 143220 },
{ gross: 141880, month: "Oct", revenue: 176340 },
{ gross: 132760, month: "Nov", revenue: 167920 }
];
const compactCurrencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 1,
notation: "compact",
style: "currency"
});
const currencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency"
});
function formatCurrency(value: number) {
return currencyFormatter.format(value);
}
function formatCompactCurrency(value: number) {
return compactCurrencyFormatter.format(value);
}
const salesImpactSeries: ChartSeries<SalesImpactDatum>[] = [
{
getValue: (datum) => datum.revenue,
id: "revenue",
label: "Revenue",
style: "line-area",
tone: "primary",
valueFormatter: formatCurrency
},
{
getValue: (datum) => datum.gross,
id: "gross",
label: "Gross",
strokeDasharray: "6 8",
style: "line",
tone: "neutral",
valueFormatter: formatCurrency
}
];
function SalesImpactTooltip({
activeLabel,
series
}: ChartTooltipContext<SalesImpactDatum>) {
return (
<div className="grid gap-3">
<div className="text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{activeLabel}
</div>
<div className="grid gap-2">
{series.map((entry) => (
<div
className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 text-sm"
key={entry.id}
>
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-[var(--color-muted-foreground)]">{entry.label}</span>
<span className="font-medium text-[var(--color-foreground)]">
{entry.formattedValue}
</span>
</div>
))}
</div>
</div>
);
}
function SalesImpactPlayground() {
return (
<div className="w-full max-w-[960px]">
<Chart
data={salesImpactData}
defaultActiveIndex={4}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
header={
<ChartHeader>
<ChartHeaderLeading>
<ChartTitle>Sales Impact</ChartTitle>
<ChartDescription>
Use Chart when a workflow needs a compact trend surface with one active comparison
moment instead of a generic analytics embed.
</ChartDescription>
</ChartHeaderLeading>
<ChartHeaderAside>
<ChartMetrics>
<ChartValue>{formatCurrency(101820)}</ChartValue>
<ChartChange>
<>
<span className="inline-flex items-center rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-success)_14%,white_86%)] px-2.5 py-1 text-[color-mix(in_oklch,var(--color-success)_72%,var(--color-foreground))]">
+84 bps
</span>
<span>from the last month</span>
</>
</ChartChange>
</ChartMetrics>
</ChartHeaderAside>
</ChartHeader>
}
height={336}
renderTooltip={SalesImpactTooltip}
series={salesImpactSeries}
title="Sales Impact"
yAxisValueFormatter={formatCompactCurrency}
/>
</div>
);
}
function ChartStates() {
return (
<div className="grid w-full max-w-[1040px] gap-4 lg:grid-cols-2">
<Chart
data={salesImpactData}
defaultActiveIndex={7}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
legendValueMode="active"
series={[
salesImpactSeries[0],
{
...salesImpactSeries[1],
tone: "success"
}
]}
showLegend
title="Active comparison"
value={formatCurrency(149860)}
valueChange="Legend values follow the active point when the chart is interactive."
yAxisValueFormatter={formatCompactCurrency}
/>
<Chart
data={[] as SalesImpactDatum[]}
empty="Keep the surrounding card chrome and swap the plot for an explicit empty message instead of collapsing the whole panel."
getXAxisLabel={() => ""}
series={salesImpactSeries}
title="Empty state"
/>
</div>
);
}
function ChartAnatomy() {
return (
<div className="w-full max-w-[960px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Chart anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The stable review hooks are the header, plot, tooltip, and legend slots. The chart is
intentionally narrow: one cartesian trend surface with optional active comparison and
legend values.
</p>
</div>
<Chart
className="shadow-none"
data={salesImpactData}
defaultActiveIndex={5}
description="Use the tooltip slot for the floating active summary and the legend slot for persistent series labels or values."
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
renderTooltip={SalesImpactTooltip}
series={salesImpactSeries}
showLegend
title="Contracted slots"
value={formatCurrency(186540)}
yAxisValueFormatter={formatCompactCurrency}
/>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code> owns the
header frame, while <code className="text-[var(--color-foreground)]">data-slot="leading"</code>{" "}
and <code className="text-[var(--color-foreground)]">data-slot="aside"</code> let
product code compose a custom title stack or review chip without losing the chart's
slot contract.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="plot"</code> is the
bordered trend surface. <code className="text-[var(--color-foreground)]">data-slot="tooltip"</code>{" "}
stays optional, and <code className="text-[var(--color-foreground)]">data-slot="legend"</code>{" "}
exposes the persistent series labels below the plot.
</p>
</div>
</div>
</div>
);
}
function ChartAccessibility() {
return (
<div className="grid w-full max-w-[960px] gap-4">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Keyboard review
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Each x-position is backed by a focusable hit target, so the active point summary works on
hover and on keyboard focus. Tab across the chart to move the tooltip and legend without
relying on pointer-only interaction.
</p>
</div>
<Chart
data={salesImpactData}
defaultActiveIndex={3}
getActiveLabel={(datum) => `${datum.month} 2025`}
getPointAriaLabel={({ activeLabel, series }) =>
`${activeLabel}. ${series.map((entry) => `${entry.label} ${entry.formattedValue}`).join(". ")}.`
}
getXAxisLabel={(datum) => datum.month}
renderTooltip={SalesImpactTooltip}
series={[
salesImpactSeries[0],
{
...salesImpactSeries[1],
tone: "success"
}
]}
showLegend
title="Focusable active points"
value={formatCurrency(165420)}
valueChange="Hover or tab through the plot to move the active summary."
yAxisValueFormatter={formatCompactCurrency}
/>
</div>
);
}
function ChartMotion() {
return (
<div className="grid w-full max-w-[960px] gap-4">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Motion review
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Move across the plot and watch for three coordinated cues: the active guide should fade
to the next position, the point markers should bloom instead of snapping, and the tooltip
card should rise in with a short 120-180ms transition. The motion should stay calm enough
that the chart still reads like a dashboard surface, not a demo toy.
</p>
</div>
<SalesImpactPlayground />
</div>
);
}
const meta = {
title: "Components/Chart",
component: SalesImpactPlayground,
parameters: {
docs: {
description: {
component:
"Chart is Cadence UI's dashboard trend surface for compact multi-series analytics panels. Use it when a product workflow needs a source-owned line chart with an active comparison state, soft area treatment, and stable slots for header, tooltip, and legend content."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof SalesImpactPlayground>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <SalesImpactPlayground />
};
export const States: Story = {
render: () => <ChartStates />
};
export const Anatomy: Story = {
render: () => <ChartAnatomy />
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Chart motion is limited to the active comparison layer. The plot itself stays stable while the guide, marker, and tooltip transition together."
}
}
},
render: () => <ChartMotion />
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"The rendered SVG is decorative; the reviewable interaction contract comes from focusable hit targets layered over each x-position. That keeps the active comparison usable with a keyboard while avoiding pointer-only hover state."
}
}
},
render: () => <ChartAccessibility />
};
+301
View File
@@ -0,0 +1,301 @@
import { Button, Gauge } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
function GaugePanel({
description,
label,
shape = "dial",
tone = "default",
value,
variant = "default"
}: {
description: string;
label: string;
shape?: "dial" | "semi";
tone?: "default" | "subtle" | "accent";
value: number | null;
variant?: "default" | "success" | "warning" | "destructive";
}) {
return (
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<Gauge
description={description}
label={label}
shape={shape}
tone={tone}
value={value}
variant={variant}
/>
</article>
);
}
const meta = {
title: "Components/Gauge",
component: Gauge,
args: {
description: "Lead routing stays stable enough for this week's forecast handoff.",
label: "Forecast confidence",
shape: "dial",
size: "md",
tickCount: 0,
tone: "default",
value: 72,
variant: "default"
},
argTypes: {
className: {
control: false
},
description: {
control: "text"
},
label: {
control: "text"
},
shape: {
control: "radio",
options: ["dial", "semi"]
},
size: {
control: "radio",
options: ["sm", "md", "lg"]
},
tickCount: {
control: {
type: "range",
min: 0,
max: 24,
step: 1
}
},
tone: {
control: "radio",
options: ["default", "subtle", "accent"]
},
value: {
control: {
type: "range",
min: 0,
max: 100,
step: 1
}
},
valueFormatter: {
control: false
},
variant: {
control: "radio",
options: ["default", "success", "warning", "destructive"]
}
},
parameters: {
docs: {
description: {
component:
"Gauge is the system's radial meter for current measurements inside a known range. Use it for capacity, forecast confidence, health scores, and KPI thresholds where the UI should communicate the current level rather than the progress of an ongoing task. The component now stages its readout with a restrained sweep and count-up on entry, while reduced or static motion snaps directly to the final state."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof Gauge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div className="w-[380px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Capacity Watch
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Use Gauge for the current level of a metric, not for task completion.
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
This component is a meter. It reads like a dashboard instrument and belongs beside KPI
cards, score panels, and health indicators rather than uploads or async job states.
</p>
</div>
<div className="mt-6 flex justify-center">
<Gauge {...args} />
</div>
</div>
)
};
export const Shapes: Story = {
render: () => (
<div className="grid w-[860px] gap-4 md:grid-cols-3">
<GaugePanel
description="Sales planning is stable enough for next week's spend allocation."
label="Forecast confidence"
value={72}
/>
<GaugePanel
description="Coverage is drifting closer to the review threshold than finance would like."
label="Budget saturation"
shape="semi"
tone="subtle"
value={61}
variant="warning"
/>
<GaugePanel
description="The latest model health score is comfortably within the healthy range."
label="Model health"
tone="accent"
value={84}
variant="success"
/>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[780px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="space-y-5">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Gauge anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Gauge keeps the public structure small: one canvas, one SVG ring system, one value
plate, and optional framing copy below the visual.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<div className="flex justify-center">
<Gauge
description="Qualified coverage is still within the board's target band."
label="Pipeline health"
shape="semi"
tone="subtle"
value={68}
variant="success"
/>
</div>
</div>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="canvas"</code> owns the
responsive gauge frame, while <code className="text-[var(--color-foreground)]">data-slot="svg"</code>{" "}
holds the radial drawing primitives.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="track"</code> is the full
meter range, and <code className="text-[var(--color-foreground)]">data-slot="indicator"</code>{" "}
is the active measured arc. If a denser dashboard wants calibration marks, opt into
them with <code className="text-[var(--color-foreground)]">tickCount</code>, which
exposes <code className="text-[var(--color-foreground)]">data-slot="tick"</code>.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="value"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="label"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="description"</code>
keep center readout and supporting copy stable for theming, docs, and tests.
</p>
</div>
</div>
</div>
)
};
function GaugeMotionShowcase() {
const [replayKey, setReplayKey] = useState(0);
return (
<div className="grid w-[760px] gap-5 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="max-w-[34rem] space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Gauge should arrive like an instrument waking up, not a number teleporting in.
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The indicator sweeps in once, the center value counts up with it, and reduced/static
motion still resolves instantly.
</p>
</div>
<Button onClick={() => setReplayKey((value) => value + 1)} size="sm" variant="secondary">
Replay startup
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_82%,white_18%)] p-5 shadow-[var(--shadow-xs)]">
<Gauge
key={`dial-${replayKey}`}
description="Lead scoring remains steady enough for this week's planning handoff."
label="Forecast confidence"
tone="accent"
value={72}
/>
</div>
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_82%,white_18%)] p-5 shadow-[var(--shadow-xs)]">
<Gauge
key={`semi-${replayKey}`}
description="Budget headroom is drifting closer to the review threshold."
label="Budget saturation"
shape="semi"
tickCount={7}
tone="subtle"
value={61}
variant="warning"
/>
</div>
</div>
</div>
);
}
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Gauge uses a one-time staged startup: the ring sweeps to the target, ticks wake up with the same progress, and the center value counts into place. The effect is intentionally calm and should still feel correct when motion is reduced."
}
}
},
render: () => <GaugeMotionShowcase />
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Gauge uses the ARIA meter pattern, not progressbar. Use it for a current reading inside a bounded range, provide a concrete label, and keep the center number or supporting copy readable enough that color is never the only signal."
}
}
},
render: () => (
<div className="grid w-[840px] gap-4 lg:grid-cols-[minmax(0,0.94fr)_minmax(0,1.06fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Review guidance
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Choose Gauge for current measurement. Choose Progress for ongoing completion.</p>
<p>Always give the meter a concrete label such as <code>Forecast confidence</code>.</p>
<p>Keep the numeric readout or description meaningful enough that color is supplemental.</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<Gauge
description="Lead scoring remains predictable enough for finance planning."
label="Forecast confidence"
value={72}
/>
</div>
</div>
)
};
+238
View File
@@ -0,0 +1,238 @@
import { Col, Row } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function GridTile({
body,
eyebrow,
title
}: {
body: string;
eyebrow: string;
title: string;
}) {
return (
<div className="flex min-h-28 flex-col justify-between rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_80%,white_20%),color-mix(in_oklch,var(--color-surface-container)_84%,white_16%))] p-4 shadow-[var(--shadow-xs)]">
<div className="space-y-1.5">
<p className="text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{eyebrow}
</p>
<p className="text-base font-semibold text-[var(--color-foreground)]">{title}</p>
</div>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">{body}</p>
</div>
);
}
const meta = {
title: "Components/Grid",
component: Row,
args: {
align: "stretch",
gap: "md"
},
argTypes: {
align: {
control: "select",
options: ["start", "center", "end", "stretch"]
},
className: {
control: false
},
gap: {
control: "select",
options: ["none", "xs", "sm", "md", "lg", "xl"]
},
xGap: {
control: "select",
options: ["none", "xs", "sm", "md", "lg", "xl"]
},
yGap: {
control: "select",
options: ["none", "xs", "sm", "md", "lg", "xl"]
}
},
parameters: {
docs: {
description: {
component:
"Use `Row` and `Col` when a screen needs a reusable 12-column layout contract instead of one-off `grid-cols-*` strings or manual width arithmetic. The primitives stay source-owned, tokenized for spacing, and keep responsive placement on the item itself through `span`, `offset`, and breakpoint props such as `md` or `xl`."
}
},
layout: "padded"
},
tags: ["autodocs"]
} satisfies Meta<typeof Row>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div className="w-full max-w-6xl">
<Row {...args}>
<Col span={12} md={7}>
<GridTile
body="Mission status, copy, and actions can stay in the wider column without any hardcoded pixel math."
eyebrow="Span 12 / md 7"
title="Launch Overview"
/>
</Col>
<Col span={12} md={5}>
<GridTile
body="The companion panel narrows responsively by changing only its column span."
eyebrow="Span 12 / md 5"
title="Launch Guardrails"
/>
</Col>
<Col span={12} md={4}>
<GridTile
body="Smaller summary surfaces can align beneath the main content without custom grid templates."
eyebrow="Span 12 / md 4"
title="Traffic"
/>
</Col>
<Col span={12} md={4}>
<GridTile
body="Spacing stays on the row via semantic gap tokens rather than ad hoc margins."
eyebrow="Span 12 / md 4"
title="Conversion"
/>
</Col>
<Col span={12} md={4}>
<GridTile
body="Each card keeps its own breakpoint behavior through the matching `Col` props."
eyebrow="Span 12 / md 4"
title="Retention"
/>
</Col>
</Row>
</div>
)
};
export const Responsive: Story = {
render: () => (
<div className="w-full max-w-6xl space-y-4">
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
<code>Col</code> accepts either a direct span like <code>md=&#123;6&#125;</code> or
an object like <code>xl=&#123;&#123; span: 4, offset: 2 &#125;&#125;</code>. Base
layout stays mobile-first, so panels stack by default and only split when the
breakpoint prop applies.
</p>
<Row gap="lg">
<Col span={12} md={6} xl={4}>
<GridTile
body="Stacks full-width on mobile, becomes half width on medium screens, and three-up on extra wide layouts."
eyebrow="12 / md 6 / xl 4"
title="Overview"
/>
</Col>
<Col span={12} md={6} xl={4}>
<GridTile
body="Breakpoints live on the column rather than hidden in a one-off parent template string."
eyebrow="12 / md 6 / xl 4"
title="Active Cohorts"
/>
</Col>
<Col span={12} xl={{ span: 4, offset: 4 }}>
<GridTile
body="Offset creates deliberate asymmetry while keeping the DOM order unchanged."
eyebrow="12 / xl 4 + offset 4"
title="Approval Queue"
/>
</Col>
</Row>
</div>
),
parameters: {
docs: {
description: {
story:
"Use responsive `span` and `offset` on `Col` for the common split-layout cases. That keeps layout intent close to the item being placed instead of scattering width calculations through the parent container."
}
}
}
};
export const Anatomy: Story = {
render: () => (
<div className="w-full max-w-5xl space-y-4">
<Row gap="md">
<Col span={12}>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)] p-5">
<p className="text-sm font-medium text-[var(--color-foreground)]">
`Row` renders `data-slot="root"` and owns the shared gap and alignment contract.
</p>
</div>
</Col>
<Col span={12} md={6}>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-primary)]/35 bg-[color-mix(in_oklch,var(--color-primary-container)_42%,white_58%)] p-5">
<p className="text-sm font-medium text-[var(--color-foreground)]">
`Col` renders `data-slot="item"` and owns placement through `span`, `offset`,
and breakpoint props.
</p>
</div>
</Col>
<Col span={12} md={6}>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-tertiary)]/35 bg-[color-mix(in_oklch,var(--color-tertiary-container)_56%,white_44%)] p-5">
<p className="text-sm font-medium text-[var(--color-foreground)]">
Consumers can still layer normal surface components inside each grid item.
</p>
</div>
</Col>
</Row>
</div>
),
parameters: {
docs: {
description: {
story:
"The contract is intentionally small: one row container with stable layout hooks, and one item primitive that carries its own placement decisions."
}
}
}
};
export const Accessibility: Story = {
render: () => (
<div className="w-full max-w-5xl space-y-4">
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep the source order equal to the reading order. `offset` should create spatial rhythm,
not fake a different semantic sequence. If the content reads as Step 1, Step 2, Step 3,
keep that order in the DOM and only let the grid change where the panels sit.
</p>
<Row gap="md">
<Col span={12} md={7}>
<GridTile
body="Users encounter this content first both visually and in the DOM."
eyebrow="Step 1"
title="Scan the shared dashboard summary"
/>
</Col>
<Col span={12} md={5}>
<GridTile
body="This still comes second semantically even when the widths differ."
eyebrow="Step 2"
title="Review the flagged metrics"
/>
</Col>
<Col span={12} md={{ span: 5, offset: 7 }}>
<GridTile
body="The third panel uses offset for placement, but not for reordering."
eyebrow="Step 3"
title="Approve the release gate"
/>
</Col>
</Row>
</div>
),
parameters: {
docs: {
description: {
story:
"Cadence UI intentionally omits visual reordering props here. Responsive layout should not create a second, less accessible reading order."
}
}
}
};
@@ -0,0 +1,176 @@
import {
Field,
FieldControl,
FieldDescription,
FieldError,
Input,
InputGroup,
InputGroupPrefix,
InputGroupSuffix,
Label
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function SearchIcon() {
return (
<svg aria-hidden="true" className="size-4" viewBox="0 0 16 16">
<path
d="M7.25 12.5a5.25 5.25 0 1 1 0-10.5a5.25 5.25 0 0 1 0 10.5Zm3.75-1.5 3 3"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
}
const meta = {
title: "Components/InputGroup",
component: InputGroup,
args: {
size: "md"
},
argTypes: {
className: {
control: false
},
disabled: {
control: "boolean"
},
invalid: {
control: "boolean"
},
readOnly: {
control: "boolean"
},
required: {
control: "boolean"
},
size: {
control: "select",
options: ["sm", "md", "lg"]
}
},
parameters: {
docs: {
description: {
component:
"Use InputGroup when a text field needs leading or trailing inline content such as a search icon, shortcut hint, status chip, or clear action. Keep plain Input for unadorned fields and let Field continue owning labels, descriptions, and errors."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof InputGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<InputGroup {...args} className="w-[360px]">
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input placeholder="Search launches, owners, or notes" />
<InputGroupSuffix>
<kbd className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[0.7rem] font-medium uppercase tracking-[var(--tracking-caps)]">
/
</kbd>
</InputGroupSuffix>
</InputGroup>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[760px] gap-4 sm:grid-cols-2">
<InputGroup>
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input defaultValue="Release workspace" />
<InputGroupSuffix>
<span className="text-xs uppercase tracking-[var(--tracking-caps)]">Live</span>
</InputGroupSuffix>
</InputGroup>
<InputGroup disabled>
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input defaultValue="Disabled search" />
</InputGroup>
<InputGroup invalid>
<InputGroupPrefix aria-hidden="true">
<span>@</span>
</InputGroupPrefix>
<Input defaultValue="ops" />
<InputGroupSuffix>
<span className="text-xs font-medium text-[var(--color-destructive)]">needs domain</span>
</InputGroupSuffix>
</InputGroup>
<InputGroup readOnly>
<InputGroupPrefix aria-hidden="true">
<span>#</span>
</InputGroupPrefix>
<Input defaultValue="release-quiet-cycle" />
<InputGroupSuffix>
<span className="text-xs uppercase tracking-[var(--tracking-caps)]">locked</span>
</InputGroupSuffix>
</InputGroup>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<InputGroup className="w-[420px]">
<InputGroupPrefix>prefix</InputGroupPrefix>
<Input aria-label="InputGroup anatomy" placeholder='data-slot="input"' />
<InputGroupSuffix>suffix</InputGroupSuffix>
</InputGroup>
),
parameters: {
docs: {
description: {
story:
"InputGroup exposes one control container plus explicit prefix, input, and suffix slots so consumers can style or test the affix anatomy without relying on absolute positioning."
}
}
}
};
export const Accessibility: Story = {
render: () => (
<Field invalid className="w-[420px]">
<Label requiredIndicator>Routing search</Label>
<FieldControl>
<InputGroup required>
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input defaultValue="ops" />
<InputGroupSuffix>
<span className="text-xs uppercase tracking-[var(--tracking-caps)]">scope</span>
</InputGroupSuffix>
</InputGroup>
<FieldDescription>Search lanes, owners, or note fragments before assigning work.</FieldDescription>
<FieldError>Add a narrower query before applying bulk actions.</FieldError>
</FieldControl>
</Field>
),
parameters: {
docs: {
description: {
story:
"Decorative affixes should usually be marked `aria-hidden`, while the actual field labelling and error messaging still come from Field plus the inner Input."
}
}
}
};
@@ -0,0 +1,236 @@
import {
Button,
MetricCard,
MetricCardActions,
MetricCardAside,
MetricCardDelta,
MetricCardDescription,
MetricCardEyebrow,
MetricCardFooter,
MetricCardHeader,
MetricCardLabel,
MetricCardLeading,
MetricCardMedia,
MetricCardMetric,
MetricCardValue,
Progress,
Sparkbar,
StatCard,
StatCardDelta,
StatCardDescription,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const reductionBars = [
52, 55, 58, 60, 64, 66, 68, 72, 74, 72, 70, 71, 68, 66, 64, 62, 60, 58, 56, 54
] as const;
function ReductionBars() {
return <Sparkbar height={74} highlightRange={[0, 12]} values={reductionBars} />;
}
function RevenueMetricCard({
layout = "default",
tone = "default"
}: {
layout?: "default" | "split";
tone?: "default" | "subtle" | "accent" | "inverse" | "hero";
}) {
const mediaTone = tone === "hero" ? "hero" : tone === "inverse" ? "inverse" : tone;
const footerTextClassName =
tone === "hero" || tone === "inverse" ? "text-white/68" : "text-[var(--color-muted-foreground)]";
return (
<MetricCard className="w-full max-w-[760px]" interactive layout={layout} tone={tone}>
<MetricCardHeader>
<MetricCardLeading>
<MetricCardEyebrow>Margin watch</MetricCardEyebrow>
<MetricCardLabel>Operational cost reduction</MetricCardLabel>
</MetricCardLeading>
<MetricCardAside>
<MetricCardDelta tone="primary">42%</MetricCardDelta>
</MetricCardAside>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>$38,250</MetricCardValue>
<MetricCardDelta tone="success">+78</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription>
Assisted routing and tighter triage are keeping servicing overhead inside the target
window.
</MetricCardDescription>
<MetricCardMedia tone={mediaTone}>
<ReductionBars />
</MetricCardMedia>
<MetricCardFooter>
<div className={`flex items-center justify-between gap-4 text-sm ${footerTextClassName}`}>
<span>Quarter target</span>
<span>$101,820</span>
</div>
<Progress tone="subtle" value={42} variant="success" />
<p className={`text-xs leading-5 ${footerTextClassName}`}>
Routing efficiency has stayed inside the guided operating band for six consecutive
cycles.
</p>
</MetricCardFooter>
<MetricCardActions>
<Button variant="secondary">Review forecast</Button>
<Button variant="ghost">Open board</Button>
</MetricCardActions>
</MetricCard>
);
}
const meta = {
title: "Components/MetricCard",
component: RevenueMetricCard,
parameters: {
docs: {
description: {
component:
"MetricCard extends the same KPI language as StatCard but adds structured regions for media, footer detail, and next-step actions. Use it for analytics panels that still need a clear headline metric instead of turning into unstructured custom cards."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof RevenueMetricCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover the card directly in the canvas. MetricCard should feel richer than StatCard, but the motion still needs hierarchy: the root slab lifts first, the media surface follows with a softer delayed response, and the actions tray trails last."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
The richer KPI panel should stage its hover response.
</h3>
<p className="max-w-[44rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
MetricCard carries more structure, so the hover response can have a little more depth.
The media region and actions tray should follow the card lift, not compete with it.
</p>
</div>
<div className="grid gap-4">
<RevenueMetricCard layout="split" />
<RevenueMetricCard tone="hero" />
</div>
</div>
)
};
export const Layouts: Story = {
render: () => (
<div className="grid w-[1040px] gap-4">
<RevenueMetricCard layout="default" />
<RevenueMetricCard layout="split" tone="accent" />
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[820px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Metric card anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
MetricCard reuses the same KPI slots as StatCard and adds dedicated regions for media,
footer detail, and action groups. That keeps dashboard panels consistent even when they
need richer structure.
</p>
</div>
<RevenueMetricCard layout="split" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="eyebrow"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="label"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="value"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="delta"</code> stay
aligned with StatCard so analytics panels and compact KPI cards share one core contract.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="leading"</code> and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="aside"</code> keep
header layouts structured, so icons, badges, or next-state chips stop leaking into
story-local flex wrappers.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="media"</code> owns the
chart or visual evidence, while <code className="text-[var(--color-foreground)]">data-slot="footer"</code>{" "}
and <code className="text-[var(--color-foreground)]">data-slot="actions"</code> cover
the follow-up details that should not be improvised ad hoc.
</p>
</div>
</div>
</div>
)
};
export const Composition: Story = {
parameters: {
docs: {
description: {
story:
"Reach for StatCard when the panel is just one KPI and a sentence. Move to MetricCard when the same KPI needs supporting evidence, target progress, or explicit next actions."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]">
<StatCard className="w-full">
<StatCardHeader>
<StatCardLabel>Forecast confidence</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="primary">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
The signal is healthy enough for planning, but still worth watching week to week.
</StatCardDescription>
</StatCard>
<RevenueMetricCard layout="split" />
</div>
)
};
export const EmphasisTones: Story = {
parameters: {
docs: {
description: {
story:
"Use `inverse` for dark tonal KPI panels and `hero` when the card needs to carry the visual emphasis for a dashboard row without falling back to a raw `Card`."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-2">
<RevenueMetricCard tone="inverse" />
<RevenueMetricCard tone="hero" />
</div>
)
};
@@ -0,0 +1,175 @@
import { useState } from "react";
import { SegmentedControl, SegmentedControlItem } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type RevenueLens = "revenue" | "support" | "forecast";
type RevenueLensControlProps = {
includeDisabled?: boolean;
orientation?: "horizontal" | "vertical";
};
const lensContent: Record<
RevenueLens,
{
body: string;
eyebrow: string;
title: string;
}
> = {
revenue: {
body:
"Lead scoring, sequence timing, and route suggestions are lifting close quality without making the top-line motion feel synthetic.",
eyebrow: "Revenue lens",
title: "Commercial coverage stays compact while the active segment changes the read."
},
support: {
body:
"Classification, summaries, and next-step hints are reducing queue drag while still keeping team-lead review visible at the handoff edge.",
eyebrow: "Support lens",
title: "Use the segmented switch when the layout already owns the content panel."
},
forecast: {
body:
"A lighter control works well for near-peer views, especially when the panel chrome and surrounding card structure already exist outside the switcher.",
eyebrow: "Forecast lens",
title: "Choose Tabs instead when the control needs to own a tabpanel relationship."
}
};
function RevenueLensControl({
includeDisabled = false,
orientation = "horizontal"
}: RevenueLensControlProps) {
const [value, setValue] = useState<RevenueLens>("revenue");
const activeContent = lensContent[value];
return (
<div className="grid w-[720px] gap-5">
<div className={orientation === "vertical" ? "flex items-start" : undefined}>
<SegmentedControl
aria-label="Revenue dashboard lens"
orientation={orientation}
onValueChange={(nextValue) => setValue(nextValue as RevenueLens)}
value={value}
>
<SegmentedControlItem value="revenue">Revenue</SegmentedControlItem>
<SegmentedControlItem value="support">Support</SegmentedControlItem>
<SegmentedControlItem value="forecast">Forecast</SegmentedControlItem>
{includeDisabled ? (
<SegmentedControlItem disabled value="handoff">
Handoff
</SegmentedControlItem>
) : null}
</SegmentedControl>
</div>
<div className="rounded-[1.75rem] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_88%,white_12%),color-mix(in_oklch,var(--color-primary-container)_18%,var(--color-card)))] p-5 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{activeContent.eyebrow}
</p>
<h3 className="mt-3 text-xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
{activeContent.title}
</h3>
<p className="mt-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
{activeContent.body}
</p>
</div>
</div>
);
}
const meta = {
title: "Components/SegmentedControl",
component: SegmentedControl,
parameters: {
docs: {
description: {
component:
"SegmentedControl is the compact single-select switch for lightweight dashboard lenses, filters, and mode changes when the surrounding layout already owns the content surface. Use Tabs instead when the control needs to own tab panels and the full in-place content relationship. The checked state now rides on a shared pill so the control reads as one moving slab rather than separate button fills."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof SegmentedControl>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <RevenueLensControl />
};
export const States: Story = {
render: () => <RevenueLensControl includeDisabled />
};
export const Anatomy: Story = {
render: () => (
<div className="w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
SegmentedControl anatomy
</p>
<RevenueLensControl includeDisabled />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="root"</code> carries{" "}
<code className="text-[var(--color-foreground)]">data-orientation</code> so the
segment stack can switch between horizontal and vertical layouts without changing the
item API.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="control"</code> exposes
each segment, while <code className="text-[var(--color-foreground)]">data-state="checked"</code>{" "}
and <code className="text-[var(--color-foreground)]">data-disabled</code> stay stable
for styling and tests.
</p>
</div>
</div>
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"The checked segment now carries a shared indicator that glides between items with the same calm timing as the rest of the system. The control should feel tactile and cohesive, not snappy or game-like."
}
}
},
render: () => <RevenueLensControl includeDisabled />
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Keep labels brief, use segmented controls for single-select peer views, and avoid using them as page navigation. If the control needs a persistent tabpanel relationship, choose Tabs instead."
}
}
},
render: () => (
<div className="grid w-[760px] gap-4 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)]">
Accessibility notes
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Use it for one active choice, not for multi-select filters.</p>
<p>Keep labels short so the active segment remains easy to scan at a glance.</p>
<p>Do not use it as a substitute for top-level page navigation.</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<RevenueLensControl orientation="vertical" />
</div>
</div>
)
};
@@ -0,0 +1,232 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Sparkbar
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const revenueTrend = [18, 24, 30, 22, 15, 12, 20, 28, 38, 46, 58, 70, 62, 48] as const;
const costTrend = [52, 55, 58, 60, 64, 66, 68, 72, 74, 72, 70, 71, 68, 66, 64, 62, 60, 58] as const;
const steadyTrend = [28, 32, 31, 34, 38, 36, 39, 41, 42, 44, 46, 45] as const;
const meta = {
title: "Components/Sparkbar",
component: Sparkbar,
args: {
height: 72,
size: "md",
tone: "default",
values: steadyTrend,
variant: "default"
},
argTypes: {
className: {
control: false
},
height: {
control: {
type: "range",
min: 40,
max: 120,
step: 2
}
},
highlightRange: {
control: false
},
maxValue: {
control: {
type: "number",
min: 1
}
},
size: {
control: "radio",
options: ["sm", "md", "lg"]
},
tone: {
control: "radio",
options: ["default", "subtle", "contrast"]
},
values: {
control: false
},
variant: {
control: "radio",
options: ["default", "success", "warning", "destructive"]
}
},
parameters: {
docs: {
description: {
component:
"Sparkbar is Cadence UI's micro-bar primitive for compact trend evidence inside KPI cards and dashboard tiles. Use it when the surface needs a small embedded bar trend with minimal configuration, not axes, hover interaction, or full chart chrome."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof Sparkbar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div className="w-[460px]">
<Card className="overflow-hidden border-transparent bg-[radial-gradient(circle_at_top,color-mix(in_oklch,var(--color-tertiary-container)_58%,transparent),transparent_40%),linear-gradient(145deg,color-mix(in_oklch,var(--color-foreground)_90%,black_10%),color-mix(in_oklch,var(--color-foreground)_78%,var(--color-primary)_22%))] text-white shadow-[0_28px_72px_color-mix(in_oklch,var(--color-foreground)_22%,transparent)]">
<CardHeader className="gap-3 pb-3">
<CardTitle className="text-white">Sales growth</CardTitle>
<CardDescription className="max-w-sm text-white/62">
Use Sparkbar when a card needs compact directional evidence without graduating into a
full chart.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Sparkbar
{...args}
className="rounded-[1.35rem] bg-white/6 px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.09)]"
highlightRange={[8, 12]}
tone="contrast"
values={revenueTrend}
variant="success"
/>
</CardContent>
</Card>
</div>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle>All bars active</CardTitle>
<CardDescription>
The default case is a fully active sparkbar with no highlighted sub-range.
</CardDescription>
</CardHeader>
<CardContent>
<Sparkbar height={68} values={steadyTrend} />
</CardContent>
</Card>
<Card className="border-transparent bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-foreground)_88%,black_12%),color-mix(in_oklch,var(--color-foreground)_76%,var(--color-primary)_24%))] text-white">
<CardHeader className="pb-3">
<CardTitle className="text-white">Highlighted range</CardTitle>
<CardDescription className="text-white/62">
Emphasize a contiguous run when only the recent lift should read as active.
</CardDescription>
</CardHeader>
<CardContent>
<Sparkbar
className="rounded-[1.2rem] bg-white/6 px-4 py-4"
height={72}
highlightRange={[8, 12]}
tone="contrast"
values={revenueTrend}
variant="success"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>Muted tail</CardTitle>
<CardDescription>
Prefix emphasis works for KPI cards where the active run is the planned or completed
slice.
</CardDescription>
</CardHeader>
<CardContent>
<Sparkbar height={74} highlightRange={[0, 11]} values={costTrend} />
</CardContent>
</Card>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[820px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Sparkbar anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Sparkbar keeps the public structure intentionally tiny: one root, repeated bar slots,
and an optional highlighted run for contiguous emphasis.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<Sparkbar height={74} highlightRange={[0, 11]} values={costTrend} />
</div>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="root"</code> owns the
compact layout and size/tone/variant state for the whole micro-visual.
</p>
<p>
Each repeated <code className="text-[var(--color-foreground)]">data-slot="bar"</code>{" "}
exposes its index, and highlighted bars opt into{" "}
<code className="text-[var(--color-foreground)]">data-active</code> so tests and theme
overrides can target the emphasized run without custom DOM.
</p>
</div>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Sparkbar defaults to decorative rendering because most uses sit beside visible KPI labels and values. If the sparkbar itself needs an accessible meaning, provide an explicit accessible name so it upgrades to an image-like contract."
}
}
},
render: () => (
<div className="grid w-[920px] gap-4 lg:grid-cols-2">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Decorative in a card
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
When the surrounding card already says what the metric means, keep Sparkbar decorative so
assistive tech does not hear duplicated context.
</p>
<div className="mt-5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-5">
<Sparkbar height={68} values={steadyTrend} />
</div>
</article>
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Named standalone visual
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
If the sparkbar stands on its own, give it an accessible name so it can be announced as
a self-contained visual summary.
</p>
<div className="mt-5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-5">
<Sparkbar
aria-label="Operational cost reduction trend over the current quarter"
height={74}
highlightRange={[0, 11]}
values={costTrend}
/>
</div>
</article>
</div>
)
};
@@ -0,0 +1,206 @@
import {
StatCard,
StatCardDelta,
StatCardDescription,
StatCardEyebrow,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function RevenueStatCard({
interactive = true,
tone = "default"
}: {
interactive?: boolean;
tone?: "default" | "subtle" | "accent";
}) {
return (
<StatCard className="w-[360px]" interactive={interactive} tone={tone}>
<StatCardHeader>
<StatCardEyebrow>Revenue pulse</StatCardEyebrow>
<StatCardLabel>Monthly recurring revenue</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$101,820</StatCardValue>
<StatCardDelta tone="success">+8.4%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
Assisted follow-up and steadier route timing are lifting close quality this month.
</StatCardDescription>
</StatCard>
);
}
const meta = {
title: "Components/StatCard",
component: RevenueStatCard,
parameters: {
docs: {
description: {
component:
"StatCard is the compact KPI surface for one headline metric, one supporting delta, and one short line of context. Use it when a dashboard needs a readable value slab without the extra regions that belong to a richer analytics panel. The default treatment stays lightly hover-ready so high-value summaries feel like lit objects instead of flat admin tiles."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof RevenueStatCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover the interactive cards directly in the canvas. StatCard motion should stay compact: the slab lifts as one object, the value sharpens slightly, and the delta chip follows with a softer secondary response instead of turning the panel into a busy dashboard tile."
}
}
},
render: () => (
<div className="grid w-[840px] gap-4 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Keep the KPI slab buoyant, not theatrical.
</h3>
<p className="max-w-[40rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
The card should read like one lifted object. The headline value and delta chip can echo
the lift, but the motion should still feel quieter than the richer choreography used by
MetricCard.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<RevenueStatCard />
<StatCard className="w-full" interactive tone="accent">
<StatCardHeader>
<StatCardEyebrow>Pipeline pulse</StatCardEyebrow>
<StatCardLabel>Qualified expansion forecast</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="primary">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
The uplift signal is strong enough to justify a wider follow-up wave this week.
</StatCardDescription>
</StatCard>
</div>
</div>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[820px] gap-4 md:grid-cols-2 xl:grid-cols-3">
<RevenueStatCard />
<StatCard className="w-full" interactive={false} tone="subtle">
<StatCardHeader>
<StatCardEyebrow>Risk watch</StatCardEyebrow>
<StatCardLabel>Qualified pipeline</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$82,450</StatCardValue>
<StatCardDelta tone="warning">-3.1%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
Mid-market deal spread is still wider than the board would like.
</StatCardDescription>
</StatCard>
<StatCard className="w-full" interactive tone="accent">
<StatCardHeader>
<StatCardEyebrow>AI influence</StatCardEyebrow>
<StatCardLabel>Forecast confidence</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="primary">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
Commercial planning is stable enough to expand the next follow-up wave.
</StatCardDescription>
</StatCard>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Stat card anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The contract stays intentionally small: framing copy, one metric group, and one line of
context. If the panel needs media, actions, or footer detail, move up to
<code className="ml-1 text-[var(--color-foreground)]">MetricCard</code>.
</p>
</div>
<RevenueStatCard />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code> groups
the eyebrow and label.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="metric"</code> holds
the headline value and its delta badge.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="value"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="delta"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="description"</code>
keep KPI styling hooks stable for docs and consumers.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-interactive</code> keeps the
hover-ready treatment explicit when the stat should feel more lifted than surrounding
utility panels.
</p>
</div>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Keep stat-card labels concrete, keep delta color from being the only signal, and reserve the description for the reason the number matters right now rather than repeating the value."
}
}
},
render: () => (
<div className="grid w-[840px] gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Review guidance
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Use a label that names the metric, not just a vague business area.</p>
<p>Delta should still be readable as text such as <code>+8.4%</code> or <code>-3.1%</code>.</p>
<p>Use the chip tone and shape as reinforcement, not as the only signal that the number moved.</p>
<p>The description should explain the current signal, not restate the number.</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<RevenueStatCard />
</div>
</div>
)
};
@@ -0,0 +1,172 @@
import {
Button,
Field,
FieldControl,
FieldDescription,
FieldError,
Label,
ValueField,
ValueFieldPrefix,
ValueFieldSuffix,
ValueFieldValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function CopyGlyph() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<rect height="8" rx="1.5" stroke="currentColor" strokeWidth="1.4" width="6.5" x="6.25" y="3.75" />
<path
d="M4.25 10.25H3.5A1.75 1.75 0 0 1 1.75 8.5v-5A1.75 1.75 0 0 1 3.5 1.75h5A1.75 1.75 0 0 1 10.25 3.5v.75"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.4"
/>
</svg>
);
}
const meta = {
title: "Components/ValueField",
component: ValueField,
args: {
size: "md"
},
argTypes: {
className: {
control: false
},
disabled: {
control: "boolean"
},
invalid: {
control: "boolean"
},
readOnly: {
control: "boolean"
},
required: {
control: "boolean"
},
size: {
control: "select",
options: ["sm", "md", "lg"]
}
},
parameters: {
docs: {
description: {
component:
"Use ValueField when product needs to show a single-line read-only value that still belongs to form-like field anatomy, such as backup codes, generated ids, or immutable environment labels. Prefer it over `Input readOnly` when the user is not editing the value and the surface should read as display-first instead of text-entry."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof ValueField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<Field className="w-[420px]">
<Label>Manual backup code</Label>
<FieldControl>
<ValueField {...args}>
<ValueFieldValue className="font-mono font-semibold uppercase tracking-[0.18em]">
ORBT-7X92-KLL9-001P
</ValueFieldValue>
<ValueFieldSuffix>
<Button aria-label="Copy backup code" size="icon" type="button" variant="ghost">
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
<FieldDescription>Use this code if you cannot scan the QR graphic.</FieldDescription>
</FieldControl>
</Field>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[780px] gap-4 sm:grid-cols-2">
<ValueField>
<ValueFieldValue>prod-eu-central-1</ValueFieldValue>
</ValueField>
<ValueField disabled>
<ValueFieldValue>launch-window-locked</ValueFieldValue>
</ValueField>
<ValueField invalid>
<ValueFieldPrefix aria-hidden="true">#</ValueFieldPrefix>
<ValueFieldValue className="font-mono">release</ValueFieldValue>
<ValueFieldSuffix>
<span className="text-xs font-medium text-[var(--color-destructive)]">duplicate</span>
</ValueFieldSuffix>
</ValueField>
<ValueField size="lg">
<ValueFieldValue className="font-mono uppercase tracking-[0.18em]">
ORBT-7X92-KLL9-001P
</ValueFieldValue>
<ValueFieldSuffix>
<Button aria-label="Copy code" size="icon" type="button" variant="ghost">
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<ValueField className="w-[420px]">
<ValueFieldPrefix>prefix</ValueFieldPrefix>
<ValueFieldValue>data-slot="value"</ValueFieldValue>
<ValueFieldSuffix>suffix</ValueFieldSuffix>
</ValueField>
),
parameters: {
docs: {
description: {
story:
"ValueField keeps the display surface on the root, exposes the actual displayed string through `data-slot=\"value\"`, and uses optional prefix/suffix slots for supporting inline affordances such as icons or copy actions."
}
}
}
};
export const Accessibility: Story = {
render: () => (
<Field invalid className="w-[440px]">
<Label>Deployment target</Label>
<FieldControl>
<ValueField>
<ValueFieldValue>prod-eu-central-1</ValueFieldValue>
<ValueFieldSuffix>
<Button aria-label="Copy deployment target" size="icon" type="button" variant="ghost">
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
<FieldDescription>
Keep the value readable and label-connected even when copy actions are present.
</FieldDescription>
<FieldError>This value is stale and needs regeneration.</FieldError>
</FieldControl>
</Field>
),
parameters: {
docs: {
description: {
story:
"Use ValueField inside `Field` when labels, descriptions, and validation messaging still matter, but the displayed value itself is not editable. This keeps the value surface descriptive without pretending to be an input."
}
}
}
};