Stat cards
Five KPI cards: a plain metric, a trend with a direction badge, a sparkline drawn as inline SVG, a two-column compare and an icon stat. All numbers are placeholder data and the sparkline reads a fixed series, so every render is identical.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/card-statDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/card-stat.tsx"use client"
import * as React from "react"
import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react"
import { cn } from "@/lib/utils"
/* Stat card family: 5 cards showing a metric or KPI. The figures are fixed placeholder data (no randomness and no time source), and the sparkline is drawn as an inline SVG polyline from a deterministic series. Colour comes ONLY from semantic tokens, transparency through color-mix. size = the padding plus radius scale (the same as card-styled). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const radii: Record<StyledSize, string> = {
sm: "rounded-lg",
md: "rounded-xl",
lg: "rounded-2xl",
xl: "rounded-2xl",
}
const pads: Record<StyledSize, string> = {
sm: "p-4",
md: "p-5",
lg: "p-6",
xl: "p-8",
}
const values: Record<StyledSize, string> = {
sm: "text-xl",
md: "text-2xl",
lg: "text-3xl",
xl: "text-4xl",
}
type Props = React.ComponentProps<"div"> & { size?: StyledSize }
const shell = "relative w-56 border border-border bg-card text-card-foreground"
const label = "text-xs font-medium uppercase tracking-wide text-muted-foreground"
/* Sabit, deterministik seri. Rastgelelik yok. */
const series = [8, 14, 11, 18, 16, 24, 21, 30, 27, 36]
/* Seriyi 100x32 viewBox icinde polyline noktalarina cevirir. */
function toPoints(data: number[]) {
const min = Math.min(...data)
const max = Math.max(...data)
const span = max - min || 1
return data
.map((v, i) => {
const x = (i / (data.length - 1)) * 100
const y = 30 - ((v - min) / span) * 26
return `${x.toFixed(2)},${y.toFixed(2)}`
})
.join(" ")
}
/* Metric: label plus a large value plus a short description. The base of the family. */
export function MetricCard({ className, size = "md", children, ...props }: Props) {
return (
<div
data-slot="styled-card"
className={cn(shell, radii[size], pads[size], className)}
{...props}
>
{children ?? (
<div className="flex flex-col gap-1">
<span className={label}>Metric label</span>
<span className={cn("font-semibold tabular-nums text-foreground", values[size])}>
1,248
</span>
<span className="text-xs text-muted-foreground">Placeholder value</span>
</div>
)}
</div>
)
}
/* Trend: value plus a direction badge. Up uses the success token, down the danger one. */
export function TrendCard({ className, size = "md", children, ...props }: Props) {
return (
<div
data-slot="styled-card"
className={cn(shell, radii[size], pads[size], className)}
{...props}
>
{children ?? (
<div className="flex flex-col gap-2">
<span className={label}>Trend label</span>
<div className="flex items-baseline gap-2">
<span className={cn("font-semibold tabular-nums text-foreground", values[size])}>
4,096
</span>
<span className="inline-flex items-center gap-0.5 rounded-full bg-success-soft px-2 py-0.5 text-xs font-medium text-success-soft-foreground [&>svg]:size-3 [&>i]:text-xs [&>i]:leading-none">
<ArrowUpRight />
12.4%
</span>
</div>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground [&>svg]:size-3 [&>i]:text-xs [&>i]:leading-none">
<ArrowDownRight />
Compared with the previous period
</span>
</div>
)}
</div>
)
}
/* Sparkline: value plus an inline SVG polyline drawn from a fixed series. */
export function SparklineCard({ className, size = "md", children, ...props }: Props) {
const points = React.useMemo(() => toPoints(series), [])
return (
<div
data-slot="styled-card"
className={cn(shell, radii[size], pads[size], className)}
{...props}
>
{children ?? (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className={label}>Sparkline label</span>
<span className={cn("font-semibold tabular-nums text-foreground", values[size])}>
862
</span>
</div>
<svg
aria-hidden="true"
viewBox="0 0 100 32"
preserveAspectRatio="none"
className="h-10 w-full"
>
<polygon
points={`0,32 ${points} 100,32`}
className="fill-[color-mix(in_oklab,var(--color-primary)_16%,transparent)]"
/>
<polyline
points={points}
fill="none"
className="stroke-primary"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
</div>
)}
</div>
)
}
/* Compare: iki metrik yan yana, ortada token ayirici. */
export function CompareCard({ className, size = "md", children, ...props }: Props) {
return (
<div
data-slot="styled-card"
className={cn(shell, "w-64", radii[size], pads[size], className)}
{...props}
>
{children ?? (
<div className="flex items-stretch gap-4">
<div className="flex flex-1 flex-col gap-1">
<span className={label}>This period</span>
<span className={cn("font-semibold tabular-nums text-foreground", values[size])}>
720
</span>
</div>
<span aria-hidden="true" className="w-px shrink-0 bg-border" />
<div className="flex flex-1 flex-col gap-1">
<span className={label}>Last period</span>
<span
className={cn("font-semibold tabular-nums text-muted-foreground", values[size])}
>
640
</span>
</div>
</div>
)}
</div>
)
}
/* IconStat: a token icon square on the left, label plus value on the right. */
export function IconStatCard({ className, size = "md", children, ...props }: Props) {
return (
<div
data-slot="styled-card"
className={cn(shell, radii[size], pads[size], className)}
{...props}
>
{children ?? (
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-info-soft text-info-soft-foreground [&>svg]:size-5 [&>i]:text-xl [&>i]:leading-none"
>
<Activity />
</span>
<div className="flex flex-col gap-0.5">
<span className={label}>Icon stat</span>
<span className={cn("font-semibold tabular-nums text-foreground", values[size])}>
312
</span>
</div>
</div>
)}
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Metric
A label, a large value and a short caption.
import { MetricCard } from "@/components/ui/card-stat"
<MetricCard />Trend
A value with a direction badge on the success tone.
import { TrendCard } from "@/components/ui/card-stat"
<TrendCard />Sparkline
A value with an inline SVG sparkline from a fixed series.
import { SparklineCard } from "@/components/ui/card-stat"
<SparklineCard />Compare
Two metrics side by side with a token divider.
import { CompareCard } from "@/components/ui/card-stat"
<CompareCard />Icon stat
An icon tile next to the label and value.
import { IconStatCard } from "@/components/ui/card-stat"
<IconStatCard />ai2 Stat cards: 5 styled variations on the token system
The ai2 Stat cards are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around metric and KPI layouts with token-driven tones. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: the layouts are static, so no animation runs by default. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the cards look and behave exactly the same.
What is in the ai2 Stat cards?
5 exports in one file: Metric, Trend, Sparkline, Compare and Icon stat. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: the layouts are static, so no animation runs by default.
- Reduced-motion aware: Under prefers-reduced-motion, the cards look and behave exactly the same.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Stat cards sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.