# dashboardcn > Dashboard and analytics components for shadcn/ui. KPI cards, charts, funnels, tables, and the pieces around them. Installed with the shadcn CLI, so you own the code. # Introduction Dashboard and analytics components for shadcn/ui, distributed through a shadcn-compatible registry. ## What it is dashboardcn is a set of components for the parts of a product that shadcn/ui leaves to you: KPI cards, time series charts, funnels, ranked lists, calendar heatmaps, and data tables. It is not a component library you install from npm. Each component is copied into your project with the shadcn CLI, exactly like shadcn/ui itself. You get the source, the styling lives in your Tailwind theme, and there is nothing to upgrade against. ## Why This project started from a gap. When building a product dashboard, there was no obvious place to find components for presenting data well: KPI tiles, trend charts, funnels, ranked lists, and the cards that combine them. Where such collections existed, they were sold behind a license. User interface code should be free. shadcn/ui set that expectation for the base primitives, and dashboardcn extends it to the data-heavy parts of a product. Everything here is MIT licensed, copied into your project as source, and yours to change. ## Foundations - Tailwind CSS v4 and the shadcn/ui theme variables. - shadcn/ui primitives such as Card, Table, and Chart. - recharts for charts, through shadcn's chart wrapper. - TanStack Table v9 for data tables. ## Radix or Base UI Components that render only HTML and CSS work with either the Radix or Base UI flavor of shadcn/ui. Components that depend on shadcn primitives pull the flavor your project already uses. Continue to [Installation](https://dashboardcn.com/docs/installation.md). # Installation Add dashboardcn components to any project that has shadcn/ui set up. ## Prerequisites You need a project with shadcn/ui initialized. If you do not have one yet, run: ```bash npx shadcn@latest init ``` ## Add a component by URL Every component page shows its install command. The CLI downloads the files, installs any npm dependencies, and pulls in the shadcn/ui components it needs. ```bash npx shadcn@latest add https://dashboardcn.com/r/kpi-card.json ``` The URL form is `https://dashboardcn.com/r/.json`. ## Add the registry namespace To use the shorter `@dashboardcn/` form, register the namespace once in your `components.json`: ```json { "registries": { "@dashboardcn": "https://dashboardcn.com/r/{name}.json" } } ``` Then install by name: ```bash npx shadcn@latest add @dashboardcn/kpi-card ``` ## Agents Every docs page is available as Markdown by appending `.md` to its URL, and [llms.txt](https://dashboardcn.com/llms.txt) indexes them all. A skill teaches a coding agent how to pick, install, and compose the components. With the namespace registered, install it into `.claude/skills` with the shadcn CLI: ```bash npx shadcn@latest add @dashboardcn/skill ``` Or for any agent, with the skills CLI: ```bash npx skills add NoahGdev/dashboardcn ``` shadcn's MCP server reads every registry in `components.json`, so once the namespace is registered it can search and install from this registry too: ```bash npx shadcn@latest mcp init --client claude ``` # Components The primitives. Each one installs with a single command. # Activity Heatmap A calendar heatmap of daily activity, in the style of a contribution graph. A calendar heatmap for shadcn/ui in the style of GitHub's contribution graph. Give it dated values and it lays out the year in weeks, with a tooltip per day and intensity steps drawn from your theme color. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/activity-heatmap.json ``` Also installs `tooltip` from shadcn/ui if missing. ## Usage ```tsx import { ActivityHeatmap } from "@/components/ui/activity-heatmap" ``` ## Examples ### Default ```tsx import { ActivityHeatmap } from "@/components/ui/activity-heatmap" // Deterministic sample data so the demo is stable between renders. function sampleData(days: number, end: Date) { let seed = 42 const random = () => { seed = (seed * 1664525 + 1013904223) % 4294967296 return seed / 4294967296 } return Array.from({ length: days }, (_, i) => { const date = new Date(end) date.setDate(end.getDate() - (days - 1 - i)) const weekend = date.getDay() === 0 || date.getDay() === 6 const base = weekend ? 2 : 10 const value = random() < 0.25 ? 0 : Math.round(random() * base * 3) const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}` return { date: key, value } }) } const end = new Date(2026, 8, 2) const data = sampleData(365, end) export default function ActivityHeatmapDemo() { return (
) } ``` ## Source ### components/ui/activity-heatmap.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" export interface ActivityDatum { /** ISO date, YYYY-MM-DD. */ date: string value: number } export interface ActivityHeatmapProps extends React.ComponentProps<"div"> { data: ActivityDatum[] /** Last day shown. Defaults to today. */ endDate?: Date /** Number of week columns. Ignored when `startDate` is set. */ weeks?: number /** First day shown. Overrides `weeks`. */ startDate?: Date /** Any CSS color. Levels are mixed from this and `--muted`. Defaults to chart-1. */ color?: string /** * Explicit color per level, from empty to most active. * Overrides `color`. Length sets the number of levels. */ colors?: string[] /** How values map to levels. `sqrt` spreads out small values. */ scale?: "linear" | "sqrt" /** Cell size in px. */ cellSize?: number /** Gap between cells in px. */ gap?: number valueFormatter?: (value: number) => string /** Label for the value in the tooltip, e.g. "events". */ unit?: string showMonthLabels?: boolean /** Which weekday labels to show along the left edge. */ weekdayLabels?: "mwf" | "all" | "none" showLegend?: boolean /** Scroll to the most recent weeks when the grid overflows. */ scrollToEnd?: boolean /** Replace the tooltip body. Return `null` to hide it for that cell. */ renderTooltip?: (datum: { date: Date; value: number }) => React.ReactNode onCellClick?: (datum: { date: Date; value: number }) => void } const DAY = 24 * 60 * 60 * 1000 const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] function toKey(date: Date) { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, "0") const d = String(date.getDate()).padStart(2, "0") return `${y}-${m}-${d}` } function startOfDay(date: Date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()) } function addDays(date: Date, days: number) { return new Date(date.getTime() + days * DAY) } function levelFor( value: number, max: number, levels: number, scale: "linear" | "sqrt" ) { if (value <= 0 || max <= 0) return 0 let t = value / max if (scale === "sqrt") t = Math.sqrt(t) return Math.min(levels - 1, Math.max(1, Math.ceil(t * (levels - 1)))) } function mixedColors(color: string, levels: number) { return Array.from({ length: levels }, (_, level) => { if (level === 0) return "var(--muted)" const pct = Math.round(25 + ((level - 1) / (levels - 2)) * 75) return `color-mix(in oklab, ${color} ${pct}%, var(--muted))` }) } function ActivityHeatmap({ data, endDate, weeks = 52, startDate, color = "var(--chart-1)", colors, scale = "linear", cellSize = 12, gap = 3, valueFormatter = (value) => formatNumber(value), unit = "events", showMonthLabels = true, weekdayLabels = "mwf", showLegend = true, scrollToEnd = true, renderTooltip, onCellClick, className, ...props }: ActivityHeatmapProps) { const endTime = startOfDay(endDate ?? new Date()).getTime() const startTime = startDate ? startOfDay(startDate).getTime() : endTime - (weeks * 7 - 1) * DAY const values = React.useMemo( () => new Map(data.map((d) => [d.date, d.value])), [data] ) const max = React.useMemo( () => data.reduce((acc, d) => Math.max(acc, d.value), 0), [data] ) // Columns are full weeks, Sunday first. Days before `start` in the first // week and after `end` in the last are left empty rather than filled in. const columns = React.useMemo(() => { const start = new Date(startTime) const end = new Date(endTime) const result: (Date | null)[][] = [] let cursor = addDays(start, -start.getDay()) while (cursor <= end) { const week: (Date | null)[] = [] for (let i = 0; i < 7; i++) { const day = addDays(cursor, i) week.push(day < start || day > end ? null : day) } result.push(week) cursor = addDays(cursor, 7) } return result }, [startTime, endTime]) // Month labels span the weeks whose first shown day falls in that month. const months = React.useMemo(() => { const groups: { label: string; from: number; to: number }[] = [] const formatter = new Intl.DateTimeFormat("en-US", { month: "short" }) columns.forEach((week, index) => { const first = week.find((d): d is Date => d !== null) if (!first) return const key = `${first.getFullYear()}-${first.getMonth()}` const last = groups[groups.length - 1] if (last && last.label === key) { last.to = index } else { groups.push({ label: key, from: index, to: index }) } }) return groups .filter((g) => g.to - g.from >= 1) .map((g) => ({ ...g, label: formatter.format(columns[g.from]!.find(Boolean) as Date), })) }, [columns]) const palette = colors && colors.length > 1 ? colors : mixedColors(color, 5) const levels = palette.length const dateFormatter = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric", }) const scrollRef = React.useRef(null) React.useLayoutEffect(() => { const node = scrollRef.current if (!node || !scrollToEnd) return node.scrollLeft = node.scrollWidth }, [scrollToEnd, columns.length, cellSize, gap]) const hasHeader = showMonthLabels const hasWeekdays = weekdayLabels !== "none" const fontSize = Math.max(10, Math.min(12, cellSize)) return (
{hasWeekdays ? (
{WEEKDAYS.map((day, index) => (
{weekdayLabels === "all" || index % 2 === 1 ? day : ""}
))}
) : null}
{hasHeader ? months.map((month) => (
{month.label}
)) : null} {columns.map((week, weekIndex) => week.map((day, dayIndex) => { const style = { gridColumn: weekIndex + 1, gridRow: dayIndex + 2, } if (!day) { return
} const value = values.get(toKey(day)) ?? 0 const level = levelFor(value, max, levels, scale) const datum = { date: day, value } const body = renderTooltip ? ( renderTooltip(datum) ) : ( <> {valueFormatter(value)} {unit} {dateFormatter.format(day)} ) const label = `${valueFormatter(value)} ${unit} on ${dateFormatter.format(day)}` const cell = (
{showLegend ? (
Less {palette.map((fill, level) => ( ))} More
) : null}
) } export { ActivityHeatmap } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Activity Rings Concentric progress rings, one per goal, in the style of a fitness watch. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/activity-rings.json ``` ## Usage ```tsx import { ActivityRings } from "@/components/ui/activity-rings" 82% of goals ``` ## Examples ### Default ```tsx import { ActivityRings } from "@/components/ui/activity-rings" export default function ActivityRingsDemo() { return ( 82% of goals ) } ``` ## Source ### components/ui/activity-rings.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" export interface ActivityRing { label: string /** Progress toward `max`. Values past `max` draw a full ring. */ value: number /** Goal for the ring. Defaults to 100. */ max?: number /** Any CSS color. Defaults to chart-1 through chart-5 in order. */ color?: string } export interface ActivityRingsProps extends Omit, "children"> { /** Outermost ring first. */ rings: ActivityRing[] /** Diameter in pixels. */ size?: number /** Stroke width of each ring in pixels. */ thickness?: number /** Space between rings in pixels. */ gap?: number /** Opacity of the unfilled track, drawn in the ring's color. */ trackOpacity?: number /** Ring drawn at full strength while the rest dim. `null` shows every ring. */ activeIndex?: number | null onActiveIndexChange?: (index: number | null) => void /** Content rendered in the middle. */ children?: React.ReactNode } const defaultColors = [ "var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", ] // Round so server and client render identical attributes. const round = (n: number) => Math.round(n * 1000) / 1000 /** Concentric progress rings, one per goal, in the style of a fitness watch. */ function ActivityRings({ rings, size = 160, thickness = 12, gap = 4, trackOpacity = 0.2, activeIndex: activeProp, onActiveIndexChange, className, children, ...props }: ActivityRingsProps) { const [activeState, setActiveState] = React.useState(null) const active = activeProp === undefined ? activeState : activeProp const setActive = (index: number | null) => { setActiveState(index) onActiveIndexChange?.(index) } const center = size / 2 const label = rings .map((ring) => { const max = ring.max ?? 100 return `${ring.label} ${Math.round((ring.value / (max || 1)) * 100)}%` }) .join(", ") return (
{rings.map((ring, index) => { const max = ring.max ?? 100 const fraction = Math.min(1, Math.max(0, ring.value / (max || 1))) const r = center - thickness / 2 - index * (thickness + gap) if (r <= 0) return null const color = ring.color ?? defaultColors[index % defaultColors.length] const dimmed = active !== null && active !== index return ( setActive(index)} onMouseLeave={() => setActive(null)} className="transition-opacity duration-200" style={{ opacity: dimmed ? 0.35 : 1 }} > {fraction > 0 ? ( ) : null} ) })} {children ? (
{children}
) : null}
) } export { ActivityRings } ``` # Bar Chart A single-series bar chart with gradient or striped fills, highlighted bars, and a hover marker. A single-series bar chart for shadcn/ui with gradient or striped fills, a highlighted subset of bars, and a hover marker. Use it for spend by week, signups by day, or anything where one series and a highlighted period tell the story. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/bar-chart.json ``` Also installs `chart` from shadcn/ui if missing. npm dependencies: `recharts`. ## Usage ```tsx import { BarChart } from "@/components/ui/bar-chart" row.month === "Mar"} yFormatter={(value) => formatNumber(value, { format: "currency" })} /> ``` ## Examples ### Default ```tsx "use client" import { BarChart } from "@/components/ui/bar-chart" import { formatNumber } from "@/lib/format" const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] const data = months.flatMap((month, m) => Array.from({ length: 4 }, (_, w) => ({ week: `${month} week ${w + 1}`, month, spend: month === "Mar" ? 1_900 + w * 470 : 900 + Math.round(Math.abs(Math.sin(m * 4 + w)) * 500), })) ) export default function BarChartDemo() { return ( row.month === "Mar"} yFormatter={(value) => formatNumber(value, { format: "currency" })} tooltipLabel={(row) => String(row.week).replace(/^\w+ /, "")} /> ) } ``` ### Striped Set variant to "striped" for diagonal bands. mutedColor defaults to gray; pass the bar color for a tint of the same hue. ```tsx "use client" import { BarChart } from "@/components/ui/bar-chart" const data = Array.from({ length: 14 }, (_, i) => { const day = i + 1 const weekend = new Date(2026, 6, day).getDay() % 6 === 0 return { date: `2026-07-${String(day).padStart(2, "0")}`, day, sessions: weekend ? 40 + i * 2 : 120 + Math.round(Math.abs(Math.cos(i)) * 90), weekend, } }) export default function BarChartStripedDemo() { return ( !row.weekend} xFormatter={(value) => String(value).slice(-2).replace(/^0/, "")} yFormatter={(value) => `${value} sessions`} /> ) } ``` ## Source ### components/ui/bar-chart.tsx ```tsx "use client" import * as React from "react" import { Bar, BarChart as RechartsBarChart, CartesianGrid, Cell, Rectangle, ReferenceLine, XAxis, YAxis, } from "recharts" import { cn } from "@/lib/utils" import { ChartContainer, ChartTooltip } from "@/components/ui/chart" export type BarRow = Record export interface BarReferenceLine { /** Horizontal line at this y value. */ y: number label?: string /** Any CSS color. Defaults to the muted foreground. */ color?: string /** Defaults to dashed. */ dashed?: boolean } export interface BarChartProps extends Omit, "config" | "children"> { data: BarRow[] /** Key of the x-axis value in each row. */ xKey: string /** Key of the bar value in each row. */ yKey: string /** * Key of a coarser label, e.g. "month" for weekly rows. When set, the x-axis * shows one tick per group centered under its bars instead of a tick per bar. */ groupKey?: string /** "gradient" fades each bar toward the bottom; "striped" fills it with diagonal bands. */ variant?: "gradient" | "striped" | "solid" /** Any CSS color for highlighted bars. Defaults to chart-1. */ color?: string /** Any CSS color for bars that are not highlighted. Defaults to the muted foreground. */ mutedColor?: string /** Return true for bars drawn in color. Every bar is highlighted when omitted. */ highlight?: (row: BarRow, index: number) => boolean grid?: "dashed" | "solid" | "none" showYAxis?: boolean showTooltip?: boolean /** Draw a hollow ring on top of the hovered bar. */ showActiveMarker?: boolean barRadius?: number /** Horizontal lines, e.g. a goal or an average. The y-axis extends to fit them. */ referenceLines?: BarReferenceLine[] xFormatter?: (value: unknown) => string yFormatter?: (value: number) => string /** Secondary line of the tooltip. Defaults to the formatted x value. */ tooltipLabel?: (row: BarRow, index: number) => React.ReactNode onBarClick?: (row: BarRow, index: number) => void } function defaultXFormatter(value: unknown) { if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { const [y, m, d] = value.split("-").map(Number) return new Date(y!, m! - 1, d).toLocaleDateString("en-US", { month: "short", day: "numeric", }) } return String(value) } /** One tick per group, placed under the middle bar of the group. */ function groupTicks(data: BarRow[], groupKey: string) { const labels: string[] = Array.from({ length: data.length }, () => "") let start = 0 for (let i = 1; i <= data.length; i++) { if (i === data.length || data[i]![groupKey] !== data[start]![groupKey]) { labels[Math.floor((start + i - 1) / 2)] = String(data[start]![groupKey] ?? "") start = i } } return labels } interface ShapeProps { x?: number y?: number width?: number height?: number index?: number } /** A single-series bar chart with gradient or striped fills, highlighted bars, and a hover marker. */ function BarChart({ data, xKey, yKey, groupKey, variant = "gradient", color = "var(--chart-1)", mutedColor = "var(--muted-foreground)", highlight, grid = "dashed", showYAxis = false, showTooltip = true, showActiveMarker = true, barRadius = 6, referenceLines = [], xFormatter = defaultXFormatter, yFormatter, tooltipLabel, onBarClick, className, ...props }: BarChartProps) { const id = React.useId() const isHighlighted = React.useCallback( (index: number) => (highlight ? highlight(data[index]!, index) : true), [data, highlight] ) const tickLabels = React.useMemo( () => (groupKey ? groupTicks(data, groupKey) : null), [data, groupKey] ) const fillFor = (index: number) => `url(#${id}-${isHighlighted(index) ? "on" : "off"})` const activeBar = (shape: ShapeProps) => { const { x = 0, y = 0, width = 0, height = 0, index = 0 } = shape const on = isHighlighted(index) return ( {showActiveMarker && height > 0 ? ( ) : null} ) } return ( {variant === "gradient" ? ( <> ) : variant === "striped" ? ( <> {(["on", "off"] as const).map((state) => { const fill = state === "on" ? color : mutedColor const opacity = state === "on" ? 1 : 0.3 return ( ) })} ) : ( <> )} {grid !== "none" ? ( ) : null} tickLabels[index] ?? "" : (v) => xFormatter(v) } /> {showTooltip ? ( { const entry = payload?.[0] if (!active || !entry) return null const row = entry.payload as BarRow const index = data.indexOf(row) const value = Number(row[yKey]) return (
{yFormatter ? yFormatter(value) : String(value)}
{tooltipLabel ? tooltipLabel(row, index) : xFormatter(row[xKey])}
) }} /> ) : null} onBarClick(data[index]!, index) : undefined } className={onBarClick ? "cursor-pointer" : undefined} > {data.map((_, index) => ( ))} {referenceLines.map((line, index) => ( ))}
) } export { BarChart } ``` # Bar List A ranked list with proportional bars, for top pages, referrers, or countries. A ranked list with a proportional bar behind each row, the pattern analytics tools use for top pages, referrers, and countries. Rows are plain elements, so they can be links, and the bars pick up your shadcn theme color. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/bar-list.json ``` ## Usage ```tsx import { BarList } from "@/components/ui/bar-list" ``` ## Examples ### Default ```tsx import { BarList } from "@/components/ui/bar-list" const pages = [ { name: "/", value: 48_210, href: "#" }, { name: "/pricing", value: 21_480, href: "#" }, { name: "/docs", value: 18_930, href: "#" }, { name: "/blog/launch-week", value: 9_120, href: "#" }, { name: "/changelog", value: 4_305, href: "#" }, { name: "/careers", value: 1_870, href: "#" }, ] export default function BarListDemo() { return (
Page Visitors
) } ``` ## Source ### components/ui/bar-list.tsx ```tsx import * as React from "react" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" export interface BarListItem { name: string value: number /** Renders the label as a link. */ href?: string icon?: React.ReactNode /** Stable key when names can repeat. */ key?: string } export interface BarListProps extends React.ComponentProps<"div"> { data: BarListItem[] valueFormatter?: (value: number) => string sortOrder?: "descending" | "ascending" | "none" /** Any CSS color. Defaults to chart-1. */ color?: string /** Show each row's share of the total next to its value. */ showPercentage?: boolean onItemClick?: (item: BarListItem) => void } function BarList({ data, valueFormatter = (value) => formatNumber(value, { format: "compact" }), sortOrder = "descending", color = "var(--chart-1)", showPercentage = false, onItemClick, className, ...props }: BarListProps) { const items = React.useMemo(() => { if (sortOrder === "none") return data const sorted = [...data].sort((a, b) => a.value - b.value) return sortOrder === "descending" ? sorted.reverse() : sorted }, [data, sortOrder]) const max = Math.max(...items.map((item) => item.value), 0) const total = items.reduce((sum, item) => sum + item.value, 0) return (
{items.map((item) => { const width = max > 0 ? (item.value / max) * 100 : 0 const share = total > 0 ? item.value / total : 0 const interactive = Boolean(item.href || onItemClick) const Label = item.href ? "a" : onItemClick ? "button" : "div" return (
{valueFormatter(item.value)} {showPercentage ? ( {formatNumber(share, { format: "percent" })} ) : null}
) })}
) } export { BarList } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Composed Chart Mix areas, lines, and bars in one chart with dual axes, reference lines, peak markers, and hatched bars. A composed chart for shadcn/ui that mixes areas, lines, and bars in one plot, with a second y-axis for series on different scales, reference lines, peak markers, and hatched bars for forecast or projected values. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/composed-chart.json ``` Also installs `chart` from shadcn/ui if missing. npm dependencies: `recharts`. ## Usage ```tsx import { ComposedChart } from "@/components/ui/composed-chart" ``` ## Examples ### Default ```tsx "use client" import { ComposedChart } from "@/components/ui/composed-chart" import { formatNumber } from "@/lib/format" const data = [ { month: "Jan", sales: 42_000, goal: 40_000 }, { month: "Feb", sales: 38_500, goal: 41_000 }, { month: "Mar", sales: 47_200, goal: 42_000 }, { month: "Apr", sales: 51_800, goal: 43_000 }, { month: "May", sales: 49_100, goal: 44_000 }, { month: "Jun", sales: 56_400, goal: 45_000 }, { month: "Jul", sales: 61_900, goal: 46_000 }, { month: "Aug", sales: 58_300, goal: 47_000 }, ] export default function ComposedChartDemo() { return ( formatNumber(v, { format: "compact" })} showYAxis showLegend /> ) } ``` ### Dual axis Give a series axis: "right" to plot it against a second y-axis. ```tsx "use client" import { ComposedChart } from "@/components/ui/composed-chart" import { formatNumber } from "@/lib/format" const data = Array.from({ length: 14 }, (_, i) => ({ date: `2026-08-${String(i + 4).padStart(2, "0")}`, views: 12_000 + Math.round(Math.sin(i / 2) * 3_000 + i * 400), sales: 180 + Math.round(Math.cos(i / 3) * 40 + i * 12), })) export default function ComposedChartDualAxisDemo() { return ( formatNumber(v, { format: "compact" })} rightYFormatter={(v) => formatNumber(v)} showYAxis showLegend /> ) } ``` ### Grouped and hatched bars Bars group side by side unless they share a stackId. pattern: "hatched" fills a bar with diagonal lines. ```tsx "use client" import { ComposedChart } from "@/components/ui/composed-chart" import { formatNumber } from "@/lib/format" const data = [ { region: "NA", air: 420, sea: 1_280 }, { region: "EU", air: 380, sea: 1_040 }, { region: "APAC", air: 610, sea: 1_720 }, { region: "LATAM", air: 140, sea: 460 }, { region: "MEA", air: 90, sea: 310 }, ] export default function ComposedChartHatchedDemo() { return ( `${formatNumber(v)} t`} showYAxis showLegend /> ) } ``` ## Source ### components/ui/composed-chart.tsx ```tsx "use client" import * as React from "react" import { Area, Bar, CartesianGrid, ComposedChart as RechartsComposedChart, Line, ReferenceDot, ReferenceLine, XAxis, YAxis, } from "recharts" import { cn } from "@/lib/utils" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart" export interface ComposedSeries { /** Key in each data row. */ key: string label: string type?: "area" | "line" | "bar" /** Any CSS color. Defaults to chart-1 through chart-5 in order. */ color?: string /** Plot against the right axis. */ axis?: "left" | "right" /** Series with the same stackId stack on top of each other. */ stackId?: string /** Bars only: fill with a diagonal hatch instead of a solid color. */ pattern?: "solid" | "hatched" /** Lines and areas only. */ dashed?: boolean curve?: "monotone" | "linear" | "step" /** Lines only: draw a dot on every point. */ dots?: boolean /** Mark the highest point of this series with a dot. */ highlightMax?: boolean } export interface ReferenceLineSpec { /** Horizontal line at this y value. */ y?: number /** Vertical line at this x value. */ x?: string | number label?: string /** Any CSS color. Defaults to the muted foreground. */ color?: string dashed?: boolean axis?: "left" | "right" } export interface ComposedChartProps extends Omit, "config" | "children"> { data: Record[] /** Key of the x-axis value in each row. */ xKey: string series: ComposedSeries[] referenceLines?: ReferenceLineSpec[] showGrid?: boolean showYAxis?: boolean showLegend?: boolean showTooltip?: boolean xFormatter?: (value: unknown) => string yFormatter?: (value: number) => string rightYFormatter?: (value: number) => string barRadius?: number /** "zero" starts the y-axis at 0; "auto" fits it to the data. */ yDomain?: "zero" | "auto" | [number, number] } function defaultXFormatter(value: unknown) { if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { const [y, m, d] = value.split("-").map(Number) return new Date(y!, m! - 1, d).toLocaleDateString("en-US", { month: "short", day: "numeric", }) } return String(value) } function ComposedChart({ data, xKey, series, referenceLines = [], showGrid = true, showYAxis = false, showLegend = false, showTooltip = true, xFormatter = defaultXFormatter, yFormatter, rightYFormatter, barRadius = 4, yDomain = "zero", className, ...props }: ComposedChartProps) { const id = React.useId() const config = Object.fromEntries( series.map((s, index) => [ s.key, { label: s.label, color: s.color ?? `var(--chart-${(index % 5) + 1})` }, ]) ) satisfies ChartConfig const hasRightAxis = series.some((s) => s.axis === "right") const domain = yDomain === "auto" ? (["auto", "auto"] as const) : yDomain === "zero" ? undefined : yDomain const maxPoints = series .filter((s) => s.highlightMax) .map((s) => { let best: { x: unknown; y: number } | null = null for (const row of data) { const y = Number(row[s.key]) if (Number.isFinite(y) && (!best || y > best.y)) best = { x: row[xKey], y } } return best ? { ...best, key: s.key, axis: s.axis ?? "left" } : null }) .filter((p): p is NonNullable => p !== null) return ( {series.map((s) => ( ))} {showGrid ? : null} {hasRightAxis ? ( ) : null} {showTooltip ? ( xFormatter(value)} formatter={(value, name, item) => { const s = series.find((entry) => entry.key === name) const fmt = s?.axis === "right" ? (rightYFormatter ?? yFormatter) : yFormatter return (
{config[name as string]?.label ?? name} {fmt ? fmt(Number(value)) : String(value)}
) }} /> } /> ) : null} {showLegend ? } /> : null} {series.map((s) => { const color = `var(--color-${s.key})` const common = { dataKey: s.key, yAxisId: s.axis ?? "left", stackId: s.stackId, isAnimationActive: false, } if (s.type === "bar") { return ( ) } if (s.type === "line") { return ( ) } return ( ) })} {referenceLines.map((line, index) => ( ))} {maxPoints.map((point) => ( ))}
) } export { ComposedChart } ``` # Data Table A sortable, filterable, paginated table with column visibility, row selection, sticky and reorderable columns, and loading states, built on TanStack Table v9. A complete data table for shadcn/ui on TanStack Table v9: sorting, filtering, pagination, column visibility, row selection, sticky and reorderable columns, and loading states. It uses the shadcn Table primitives and is the table you would build yourself after the shadcn docs example, finished. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/data-table.json ``` Also installs `table`, `button`, `input`, `dropdown-menu`, `checkbox`, `skeleton` from shadcn/ui if missing. npm dependencies: `@tanstack/react-table`, `lucide-react`. ## Usage ```tsx import { DataTable, DataTableColumnHeader, createDataTableColumnHelper, } from "@/components/ui/data-table" const helper = createDataTableColumnHelper() const columns = helper.columns([ helper.accessor("path", { header: ({ column }) => , }), helper.accessor("views", { header: ({ column }) => ( ), cell: ({ row }) =>
{row.original.views}
, }), ]) // Or own the instance and lay the parts out yourself. const table = useDataTable({ columns, data: rows, pageSize: 5 }) ``` ## Examples ### Default ```tsx "use client" import { Badge } from "@/components/ui/badge" import { formatNumber } from "@/lib/format" import { DataTable, DataTableColumnHeader, createDataTableColumnHelper, } from "@/components/ui/data-table" type PageRow = { path: string views: number visitors: number bounceRate: number status: "indexed" | "noindex" | "redirect" } const pages: PageRow[] = [ { path: "/", views: 48_210, visitors: 31_400, bounceRate: 0.42, status: "indexed" }, { path: "/pricing", views: 21_480, visitors: 16_200, bounceRate: 0.38, status: "indexed" }, { path: "/docs", views: 18_930, visitors: 9_800, bounceRate: 0.21, status: "indexed" }, { path: "/docs/installation", views: 12_310, visitors: 7_900, bounceRate: 0.19, status: "indexed" }, { path: "/blog/launch-week", views: 9_120, visitors: 8_400, bounceRate: 0.61, status: "indexed" }, { path: "/changelog", views: 4_305, visitors: 2_900, bounceRate: 0.33, status: "indexed" }, { path: "/careers", views: 1_870, visitors: 1_600, bounceRate: 0.55, status: "indexed" }, { path: "/legacy/signup", views: 940, visitors: 910, bounceRate: 0.9, status: "redirect" }, { path: "/internal/status", views: 610, visitors: 40, bounceRate: 0.12, status: "noindex" }, { path: "/blog/hiring", views: 430, visitors: 400, bounceRate: 0.7, status: "indexed" }, { path: "/terms", views: 220, visitors: 210, bounceRate: 0.81, status: "indexed" }, { path: "/privacy", views: 190, visitors: 185, bounceRate: 0.84, status: "indexed" }, ] const helper = createDataTableColumnHelper() const columns = helper.columns([ helper.accessor("path", { header: ({ column }) => , cell: ({ row }) => ( {row.original.path} ), }), helper.accessor("status", { header: "Status", cell: ({ row }) => ( {row.original.status} ), }), helper.accessor("views", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.views)}
), }), helper.accessor("visitors", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.visitors)}
), }), helper.accessor("bounceRate", { id: "bounce", header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.bounceRate, { format: "percent", maximumFractionDigits: 0 })}
), }), ]) export default function DataTableDemo() { return ( ) } ``` ### Loading and refreshing loading swaps the rows for a skeleton. pending keeps the rows and dims them, which is what a filter change wants — the table reports it is busy without collapsing and jumping. ```tsx "use client" import * as React from "react" import { RefreshCw } from "lucide-react" import { Button } from "@/components/ui/button" import { formatNumber } from "@/lib/format" import { DataTable, DataTableColumnHeader, createDataTableColumnHelper, } from "@/components/ui/data-table" import { PeriodTabs } from "@/components/ui/period-tabs" type ChannelRow = { channel: string sessions: number signups: number revenue: number } type Period = "week" | "month" | "year" const byPeriod: Record = { week: [ { channel: "Organic search", sessions: 18_420, signups: 412, revenue: 24_180 }, { channel: "Paid search", sessions: 9_310, signups: 288, revenue: 19_640 }, { channel: "Direct", sessions: 7_860, signups: 154, revenue: 11_200 }, { channel: "Referral", sessions: 3_240, signups: 96, revenue: 7_480 }, { channel: "Email", sessions: 2_180, signups: 141, revenue: 9_320 }, ], month: [ { channel: "Organic search", sessions: 74_910, signups: 1_684, revenue: 98_400 }, { channel: "Paid search", sessions: 38_260, signups: 1_192, revenue: 81_050 }, { channel: "Direct", sessions: 31_540, signups: 623, revenue: 45_900 }, { channel: "Referral", sessions: 13_080, signups: 388, revenue: 30_120 }, { channel: "Email", sessions: 8_940, signups: 574, revenue: 38_760 }, ], year: [ { channel: "Organic search", sessions: 892_300, signups: 20_140, revenue: 1_184_000 }, { channel: "Paid search", sessions: 461_800, signups: 14_320, revenue: 972_500 }, { channel: "Direct", sessions: 379_400, signups: 7_460, revenue: 551_200 }, { channel: "Referral", sessions: 156_700, signups: 4_610, revenue: 361_400 }, { channel: "Email", sessions: 107_200, signups: 6_890, revenue: 465_300 }, ], } const helper = createDataTableColumnHelper() const columns = helper.columns([ helper.accessor("channel", { header: ({ column }) => ( ), }), helper.accessor("sessions", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.sessions)}
), }), helper.accessor("signups", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.signups)}
), }), helper.accessor("revenue", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.revenue, { format: "currency", compact: true })}
), }), ]) export default function DataTableLoadingDemo() { const [period, setPeriod] = React.useState("month") const [data, setData] = React.useState(byPeriod.month) const [loading, setLoading] = React.useState(false) const [pending, setPending] = React.useState(false) // Stand-in for a fetch. A first load has no rows to show, so it swaps in the // skeleton; a filter change already has rows, so it dims them instead. const load = (next: Period, mode: "loading" | "pending") => { const setBusy = mode === "loading" ? setLoading : setPending setPeriod(next) setBusy(true) window.setTimeout(() => { setData(byPeriod[next]) setBusy(false) }, 900) } return ( load(next as Period, "pending")} size="sm" /> } className="w-full" /> ) } ``` ### Row selection Put createSelectionColumn() first in the column list and set enableRowSelection. A bar with your actions appears while rows are selected. Pass getRowId so a selection survives sorting and paging, and shift-click a checkbox to take a range. ```tsx "use client" import * as React from "react" import { Download, Trash2 } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { formatNumber } from "@/lib/format" import { DataTable, DataTableColumnHeader, createDataTableColumnHelper, createSelectionColumn, } from "@/components/ui/data-table" type InvoiceRow = { id: string customer: string status: "paid" | "open" | "overdue" issued: string amount: number } const invoices: InvoiceRow[] = [ { id: "INV-2841", customer: "Northwind Traders", status: "paid", issued: "2026-08-02", amount: 12_400 }, { id: "INV-2842", customer: "Contoso", status: "open", issued: "2026-08-04", amount: 3_180 }, { id: "INV-2843", customer: "Fabrikam", status: "overdue", issued: "2026-07-11", amount: 8_960 }, { id: "INV-2844", customer: "Tailspin Toys", status: "paid", issued: "2026-08-09", amount: 1_240 }, { id: "INV-2845", customer: "Adventure Works", status: "open", issued: "2026-08-12", amount: 22_500 }, { id: "INV-2846", customer: "Proseware", status: "paid", issued: "2026-08-14", amount: 640 }, { id: "INV-2847", customer: "Wide World Importers", status: "overdue", issued: "2026-06-28", amount: 15_820 }, { id: "INV-2848", customer: "Lucerne Publishing", status: "open", issued: "2026-08-19", amount: 4_075 }, ] const helper = createDataTableColumnHelper() const columns = helper.columns([ createSelectionColumn(), helper.accessor("id", { header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.id} ), }), helper.accessor("customer", { header: ({ column }) => ( ), }), helper.accessor("status", { header: "Status", cell: ({ row }) => ( {row.original.status} ), }), helper.accessor("issued", { header: ({ column }) => ( ), sortFn: "datetime", cell: ({ row }) => ( {row.original.issued} ), }), helper.accessor("amount", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.amount, { format: "currency" })}
), }), ]) export default function DataTableSelectionDemo() { const [selected, setSelected] = React.useState([]) return (
row.id} onRowSelectionChange={(selection) => setSelected(Object.keys(selection)) } selectionActions={ <> } pageSize={0} showPagination={false} />

Shift-click a checkbox to select a range. Selected:{" "} {selected.length ? selected.join(", ") : "none"}

) } ``` ### Row actions A trailing column with a ⋯ menu. Right-clicking anywhere in a row offers the same actions through shadcn's ContextMenu — not a table feature: renderRow wraps the row in a trigger, and one array of actions feeds both menus. Drop it if you do not want it. ```tsx "use client" import * as React from "react" import { MoreHorizontal } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { DataTable, DataTableColumnHeader, createDataTableColumnHelper, type DataTableRow, } from "@/components/ui/data-table" type KeyRow = { name: string scope: "read" | "write" | "admin" lastUsed: string } const keys: KeyRow[] = [ { name: "Production server", scope: "admin", lastUsed: "2 minutes ago" }, { name: "Staging server", scope: "write", lastUsed: "1 hour ago" }, { name: "Analytics export", scope: "read", lastUsed: "Yesterday" }, { name: "CI pipeline", scope: "write", lastUsed: "3 days ago" }, { name: "Partner sandbox", scope: "read", lastUsed: "2 weeks ago" }, { name: "Legacy webhook", scope: "read", lastUsed: "Never" }, ] /** One list of actions, rendered by whichever menu asked for it. */ const actions = [ { label: "Copy key id" }, { label: "Edit scope" }, { label: "Revoke", separated: true, destructive: true }, ] function RowMenu({ row }: { row: DataTableRow }) { return ( {actions.map((action) => ( {action.separated ? : null} {action.label} ))} ) } const helper = createDataTableColumnHelper() const columns = helper.columns([ helper.accessor("name", { header: ({ column }) => ( ), cell: ({ row }) => {row.original.name}, }), helper.accessor("scope", { header: "Scope", cell: ({ row }) => ( {row.original.scope} ), }), helper.accessor("lastUsed", { header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.lastUsed} ), }), helper.display({ id: "actions", size: 48, enableHiding: false, header: () => Actions, cell: ({ row }) => (
), }), ]) export default function DataTableRowActionsDemo() { return (
( {element} {actions.map((action) => ( {action.separated ? : null} {action.label} ))} )} />

Right-click a row for the same actions as its ⋯ button.

) } ``` ### Sticky columns pinnedColumns holds columns against either edge while the rest scroll sideways, so the actions menu stays reachable at any scroll position. stickyHeader and maxHeight do the same vertically. Pinned columns need a size on the column def, because the sticky offsets are measured from it. The right-click menu from the previous example is in here too — it is written in the example, not a table feature, so take it or leave it. ```tsx "use client" import { MoreHorizontal } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { formatNumber } from "@/lib/format" import { DataTable, DataTableColumnHeader, createDataTableColumnHelper, type DataTableRow, } from "@/components/ui/data-table" type CampaignRow = { campaign: string channel: string status: "live" | "paused" | "draft" impressions: number clicks: number ctr: number cpc: number spend: number conversions: number cpa: number roas: number } const campaigns: CampaignRow[] = [ { campaign: "Spring launch — brand", channel: "Search", status: "live", impressions: 1_284_000, clicks: 38_400, ctr: 0.0299, cpc: 1.42, spend: 54_528, conversions: 1_842, cpa: 29.6, roas: 4.1 }, { campaign: "Spring launch — retarget", channel: "Display", status: "live", impressions: 2_940_000, clicks: 21_600, ctr: 0.0073, cpc: 0.68, spend: 14_688, conversions: 612, cpa: 24, roas: 3.4 }, { campaign: "Always-on — competitor", channel: "Search", status: "live", impressions: 486_000, clicks: 19_200, ctr: 0.0395, cpc: 2.86, spend: 54_912, conversions: 908, cpa: 60.5, roas: 2.2 }, { campaign: "Lifecycle — winback", channel: "Email", status: "live", impressions: 312_000, clicks: 41_800, ctr: 0.134, cpc: 0.04, spend: 1_672, conversions: 1_204, cpa: 1.4, roas: 18.9 }, { campaign: "Creator program", channel: "Social", status: "paused", impressions: 1_820_000, clicks: 47_300, ctr: 0.026, cpc: 0.91, spend: 43_043, conversions: 1_390, cpa: 31, roas: 3.8 }, { campaign: "Podcast — Q1 flight", channel: "Audio", status: "paused", impressions: 940_000, clicks: 8_200, ctr: 0.0087, cpc: 3.4, spend: 27_880, conversions: 318, cpa: 87.7, roas: 1.6 }, { campaign: "Marketplace listings", channel: "Partner", status: "live", impressions: 128_000, clicks: 6_400, ctr: 0.05, cpc: 1.15, spend: 7_360, conversions: 486, cpa: 15.1, roas: 6.7 }, { campaign: "Docs — long tail", channel: "Search", status: "live", impressions: 74_000, clicks: 9_100, ctr: 0.123, cpc: 0.52, spend: 4_732, conversions: 402, cpa: 11.8, roas: 8.4 }, { campaign: "Enterprise ABM", channel: "Display", status: "draft", impressions: 0, clicks: 0, ctr: 0, cpc: 0, spend: 0, conversions: 0, cpa: 0, roas: 0 }, { campaign: "Conference — booth QR", channel: "Offline", status: "paused", impressions: 12_000, clicks: 1_900, ctr: 0.158, cpc: 0, spend: 18_000, conversions: 96, cpa: 187.5, roas: 0.9 }, { campaign: "Newsletter sponsorships", channel: "Email", status: "live", impressions: 620_000, clicks: 14_400, ctr: 0.0232, cpc: 1.04, spend: 14_976, conversions: 508, cpa: 29.5, roas: 4.6 }, { campaign: "App install — iOS", channel: "Social", status: "live", impressions: 3_410_000, clicks: 88_200, ctr: 0.0259, cpc: 0.47, spend: 41_454, conversions: 3_120, cpa: 13.3, roas: 5.2 }, ] /** One list of actions, rendered by whichever menu asked for it. */ const actions = [ { label: "Open report" }, { label: "Duplicate" }, { label: "Archive", destructive: true }, ] function RowActions({ row }: { row: DataTableRow }) { return ( {actions.map((action) => ( {action.label} ))} ) } const helper = createDataTableColumnHelper() const number = (value: number, options?: Parameters[1]) => (
{formatNumber(value, options)}
) const columns = helper.columns([ helper.accessor("campaign", { enableHiding: false, header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.campaign} ), }), helper.accessor("channel", { header: ({ column }) => ( ), }), helper.accessor("status", { header: "Status", cell: ({ row }) => ( {row.original.status} ), }), helper.accessor("impressions", { header: ({ column }) => ( ), cell: ({ row }) => number(row.original.impressions, { compact: true }), }), helper.accessor("clicks", { header: ({ column }) => ( ), cell: ({ row }) => number(row.original.clicks), }), helper.accessor("ctr", { header: ({ column }) => ( ), cell: ({ row }) => number(row.original.ctr, { format: "percent", maximumFractionDigits: 2 }), }), helper.accessor("cpc", { header: ({ column }) => ( ), cell: ({ row }) => number(row.original.cpc, { format: "currency" }), }), helper.accessor("spend", { header: ({ column }) => ( ), cell: ({ row }) => number(row.original.spend, { format: "currency", compact: true }), }), helper.accessor("conversions", { header: ({ column }) => ( ), cell: ({ row }) => number(row.original.conversions), }), helper.accessor("cpa", { header: ({ column }) => ( ), cell: ({ row }) => number(row.original.cpa, { format: "currency" }), }), helper.accessor("roas", { header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.roas.toFixed(1)}×
), }), helper.display({ id: "actions", // Pinned columns need a size: the sticky offsets are measured from it. size: 48, enableHiding: false, header: () => Actions, cell: ({ row }) => (
), }), ]) export default function DataTableStickyDemo() { return ( ( {element} {actions.map((action) => ( {action.label} ))} )} className="w-full" /> ) } ``` ### Reorderable columns reorderable puts a handle on each header. Drag one header onto another to move it, or focus a handle and use the arrow keys. Pass an array of column ids to limit which columns move. ```tsx "use client" import { Badge } from "@/components/ui/badge" import { formatNumber } from "@/lib/format" import { DataTable, DataTableColumnHeader, createDataTableColumnHelper, } from "@/components/ui/data-table" type RegionRow = { region: string tier: "enterprise" | "growth" | "starter" accounts: number seats: number arr: number churn: number } const regions: RegionRow[] = [ { region: "North America", tier: "enterprise", accounts: 184, seats: 24_310, arr: 8_420_000, churn: 0.041 }, { region: "EMEA", tier: "enterprise", accounts: 141, seats: 17_820, arr: 6_180_000, churn: 0.052 }, { region: "APAC", tier: "growth", accounts: 96, seats: 8_940, arr: 2_740_000, churn: 0.068 }, { region: "LATAM", tier: "growth", accounts: 62, seats: 4_120, arr: 1_180_000, churn: 0.081 }, { region: "Nordics", tier: "starter", accounts: 48, seats: 2_060, arr: 486_000, churn: 0.094 }, { region: "Benelux", tier: "starter", accounts: 39, seats: 1_540, arr: 361_000, churn: 0.077 }, { region: "ANZ", tier: "growth", accounts: 34, seats: 2_880, arr: 812_000, churn: 0.058 }, ] const helper = createDataTableColumnHelper() const columns = helper.columns([ helper.accessor("region", { header: ({ column }) => ( ), cell: ({ row }) => {row.original.region}, }), helper.accessor("tier", { header: "Tier", cell: ({ row }) => ( {row.original.tier} ), }), helper.accessor("accounts", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.accounts)}
), }), helper.accessor("seats", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.seats)}
), }), helper.accessor("arr", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.arr, { format: "currency", compact: true })}
), }), helper.accessor("churn", { header: ({ column }) => ( ), cell: ({ row }) => (
{formatNumber(row.original.churn, { format: "percent", maximumFractionDigits: 1, })}
), }), ]) export default function DataTableReorderDemo() { return ( ) } ``` ### Compact density="compact" tightens the rows. With the toolbar and pagination off the table is only rows, and onRowClick with rowClassName makes them behave like a list. ```tsx "use client" import * as React from "react" import { formatNumber } from "@/lib/format" import { DeltaBadge } from "@/components/ui/delta-badge" import { DataTable, createDataTableColumnHelper, } from "@/components/ui/data-table" type QueryRow = { query: string clicks: number position: number delta: number } const queries: QueryRow[] = [ { query: "dashboard components", clicks: 4_120, position: 1.4, delta: 0.18 }, { query: "shadcn kpi card", clicks: 3_480, position: 2.1, delta: 0.32 }, { query: "react data table", clicks: 2_910, position: 4.8, delta: -0.06 }, { query: "tanstack table example", clicks: 2_140, position: 3.2, delta: 0.09 }, { query: "funnel chart react", clicks: 1_880, position: 5.6, delta: 0.21 }, { query: "analytics ui kit", clicks: 1_240, position: 8.1, delta: -0.14 }, { query: "sparkline component", clicks: 960, position: 6.4, delta: 0.04 }, { query: "activity heatmap react", clicks: 720, position: 9.2, delta: 0.41 }, ] const helper = createDataTableColumnHelper() const columns = helper.columns([ helper.accessor("query", { header: "Query", cell: ({ row }) => {row.original.query}, }), helper.accessor("clicks", { header: () =>
Clicks
, cell: ({ row }) => (
{formatNumber(row.original.clicks)}
), }), helper.accessor("position", { header: () =>
Position
, cell: ({ row }) => (
{row.original.position.toFixed(1)}
), }), helper.accessor("delta", { header: () =>
Change
, cell: ({ row }) => (
), }), ]) export default function DataTableCompactDemo() { const [active, setActive] = React.useState(null) return ( setActive(row.original.query)} rowClassName={(row) => row.original.query === active ? "bg-muted/60" : undefined } className="w-full" /> ) } ``` ### Composed DataTable is a preset over parts that are all exported. Call useDataTable yourself and place the toolbar, search, view options, content, and pagination wherever the design puts them. ```tsx "use client" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card" import { DataTableColumnHeader, DataTableContent, DataTablePagination, DataTableSearch, DataTableToolbar, DataTableViewOptions, createDataTableColumnHelper, useDataTable, } from "@/components/ui/data-table" type TicketRow = { subject: string requester: string status: "open" | "waiting" | "closed" priority: "urgent" | "high" | "normal" age: string } const tickets: TicketRow[] = [ { subject: "Billing address will not save", requester: "R. Okafor", status: "open", priority: "high", age: "2h" }, { subject: "SAML login loop after rotation", requester: "M. Lindqvist", status: "open", priority: "urgent", age: "4h" }, { subject: "Export misses the last row", requester: "J. Alvarez", status: "waiting", priority: "normal", age: "1d" }, { subject: "Webhook retries fire twice", requester: "P. Nakamura", status: "open", priority: "high", age: "1d" }, { subject: "Invite email lands in spam", requester: "S. Dube", status: "closed", priority: "normal", age: "3d" }, { subject: "Chart tooltip clipped on mobile", requester: "A. Fontaine", status: "waiting", priority: "normal", age: "3d" }, { subject: "Seat count off by one", requester: "K. Brennan", status: "closed", priority: "high", age: "5d" }, { subject: "API key scopes not enforced", requester: "T. Iqbal", status: "open", priority: "urgent", age: "6d" }, { subject: "Timezone wrong in digest", requester: "L. Moreau", status: "closed", priority: "normal", age: "8d" }, ] const helper = createDataTableColumnHelper() const columns = helper.columns([ helper.accessor("subject", { header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.subject} ), }), helper.accessor("requester", { header: "Requester", cell: ({ row }) => ( {row.original.requester} ), }), helper.accessor("status", { header: "Status", cell: ({ row }) => ( {row.original.status} ), }), helper.accessor("priority", { header: "Priority", cell: ({ row }) => ( {row.original.priority} ), }), helper.accessor("age", { header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.age}
), }), ]) const statuses = ["open", "waiting", "closed"] export default function DataTableComposedDemo() { // Own the instance, then lay the pieces out however the design calls for. const table = useDataTable({ columns, data: tickets, pageSize: 5 }) const status = table.getColumn("status") const active = (status?.getFilterValue() as string) ?? "" return ( Support queue Unresolved tickets across all plans.
{statuses.map((value) => ( ))}
) } ``` ## Source ### components/ui/data-table.tsx ```tsx "use client" import * as React from "react" import { columnFilteringFeature, columnOrderingFeature, columnPinningFeature, columnSizingFeature, columnVisibilityFeature, createColumnHelper, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel, filterFn_includesString, flexRender, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, sortFn_alphanumeric, sortFn_basic, sortFn_datetime, sortFn_text, tableFeatures, useTable, type Column, type ColumnDef, type ColumnFiltersState, type ColumnOrderState, type ColumnPinningState, type ColumnVisibilityState, type ReactTable, type Row, type RowData, type RowSelectionState, type SortingState, } from "@tanstack/react-table" import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, GripVertical, Settings2, X, } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" /** * TanStack Table v9 is feature-gated: only what is registered here ships in * the bundle. Sorting, filtering, pagination, visibility, ordering, pinning, * and row selection cover the usual dashboard table. */ const dataTableFeatures = tableFeatures({ columnFilteringFeature, columnOrderingFeature, columnPinningFeature, columnSizingFeature, columnVisibilityFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, filteredRowModel: createFilteredRowModel(), paginatedRowModel: createPaginatedRowModel(), sortedRowModel: createSortedRowModel(), filterFns: { includesString: filterFn_includesString }, sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic, datetime: sortFn_datetime, text: sortFn_text, }, }) type DataTableFeatures = typeof dataTableFeatures type DataTableColumnDef = ColumnDef< DataTableFeatures, TData, // eslint-disable-next-line @typescript-eslint/no-explicit-any any > /** The table instance returned by `useDataTable`. */ type DataTableInstance = ReactTable< DataTableFeatures, TData > type DataTableColumn = Column< DataTableFeatures, TData, unknown > type DataTableRow = Row /** Typed column helper bound to the data table's feature set. */ function createDataTableColumnHelper() { return createColumnHelper() } /** Row density presets. Only the cell padding changes. */ type DataTableDensity = "compact" | "default" | "relaxed" const densityClasses: Record = { compact: "[&_[data-slot=table-head]]:h-8 [&_[data-slot=table-cell]]:py-1 [&_[data-slot=table-cell]]:text-[0.8125rem]", default: "", relaxed: "[&_[data-slot=table-head]]:h-12 [&_[data-slot=table-cell]]:py-3.5", } /* -------------------------------------------------------------------------- */ /* hook */ /* -------------------------------------------------------------------------- */ export interface UseDataTableOptions { columns: ReadonlyArray> data: TData[] /** Rows per page. Pass 0 to keep every row on a single page. */ pageSize?: number initialSorting?: SortingState initialColumnVisibility?: ColumnVisibilityState /** * Column ids held against the leading and trailing edge. Read once, on * mount; use `table.setColumnPinning` to change it afterwards. Pinned * columns should declare a `size` so the sticky offsets line up. */ pinnedColumns?: Partial /** Turn on checkbox selection, or decide per row. */ enableRowSelection?: boolean | ((row: DataTableRow) => boolean) /** Stable row ids. Selection survives sorting and paging with this set. */ getRowId?: (row: TData, index: number) => string onRowSelectionChange?: (selection: RowSelectionState) => void } /** * Wires the table state the components below read. Call it when you want to * lay the pieces out yourself; `DataTable` calls it for you. */ function useDataTable({ columns, data, pageSize = 10, initialSorting, initialColumnVisibility, pinnedColumns, enableRowSelection, getRowId, onRowSelectionChange, }: UseDataTableOptions): DataTableInstance { const [sorting, setSorting] = React.useState( initialSorting ?? [] ) const [columnFilters, setColumnFilters] = React.useState( [] ) const [columnVisibility, setColumnVisibility] = React.useState(initialColumnVisibility ?? {}) const [columnOrder, setColumnOrder] = React.useState([]) const [columnPinning, setColumnPinning] = React.useState( () => ({ start: pinnedColumns?.start ?? [], end: pinnedColumns?.end ?? [] }) ) const [rowSelection, setRowSelection] = React.useState({}) // Report the selection from an effect, not from inside the state updater: // the table can call the updater while it renders. const notify = React.useRef(onRowSelectionChange) const mounted = React.useRef(false) React.useEffect(() => { notify.current = onRowSelectionChange }) React.useEffect(() => { if (!mounted.current) { mounted.current = true return } notify.current?.(rowSelection) }, [rowSelection]) return useTable({ features: dataTableFeatures, data, columns: columns as DataTableColumnDef[], initialState: { pagination: { pageIndex: 0, pageSize: pageSize > 0 ? pageSize : Number.MAX_SAFE_INTEGER, }, }, getRowId, enableRowSelection, // Shift-click extends the selection from the last row you touched. isRowRangeSelectionEvent: (event) => Boolean((event as { shiftKey?: boolean }).shiftKey), onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, onColumnOrderChange: setColumnOrder, onColumnPinningChange: setColumnPinning, onRowSelectionChange: setRowSelection, state: { sorting, columnFilters, columnVisibility, columnOrder, columnPinning, rowSelection, }, }) } /* -------------------------------------------------------------------------- */ /* preset */ /* -------------------------------------------------------------------------- */ export interface DataTableProps extends Omit, "onSelect">, UseDataTableOptions { /** Column id to filter with the search input. Omit to hide the input. */ searchKey?: string searchPlaceholder?: string /** Extra toolbar content, rendered between the search input and view options. */ toolbar?: React.ReactNode showViewOptions?: boolean showPagination?: boolean pageSizeOptions?: number[] emptyMessage?: React.ReactNode /** Swap the rows for a skeleton, e.g. on first load. */ loading?: boolean /** Keep the rows but dim them, e.g. while a filter change is in flight. */ pending?: boolean skeletonRows?: number stickyHeader?: boolean /** Caps the scroll area, e.g. 420 or "60vh". Pair it with stickyHeader. */ maxHeight?: number | string /** Let columns be dragged into a new order. Pass ids to limit which ones. */ reorderable?: boolean | string[] density?: DataTableDensity onRowClick?: (row: DataTableRow) => void rowClassName?: (row: DataTableRow) => string | undefined /** Anything else to put on a row, e.g. a data attribute or a handler. */ rowProps?: (row: DataTableRow) => React.ComponentProps<"tr"> /** Wrap the row element, e.g. in a context menu trigger. */ renderRow?: ( row: DataTableRow, element: React.ReactElement ) => React.ReactNode /** Actions shown in the bar that appears while rows are selected. */ selectionActions?: React.ReactNode } /** * The batteries-included table: toolbar, rows, and pagination. Every part is * exported on its own, so reach for `useDataTable` and compose them by hand * when this shape is not the one you want. */ function DataTable({ columns, data, pageSize = 10, initialSorting, initialColumnVisibility, pinnedColumns, enableRowSelection, getRowId, onRowSelectionChange, searchKey, searchPlaceholder = "Filter...", toolbar, showViewOptions = true, showPagination = true, pageSizeOptions, emptyMessage = "No results.", loading = false, pending = false, skeletonRows, stickyHeader = false, maxHeight, reorderable = false, density = "default", onRowClick, rowClassName, rowProps, renderRow, selectionActions, className, ...props }: DataTableProps) { const table = useDataTable({ columns, data, pageSize, initialSorting, initialColumnVisibility, pinnedColumns, enableRowSelection, getRowId, onRowSelectionChange, }) const hasToolbar = Boolean(searchKey || toolbar || showViewOptions) const selectedCount = table.getSelectedRowModel().rows.length return (
{hasToolbar ? ( {searchKey ? ( ) : null} {toolbar} {showViewOptions ? ( ) : null} ) : null} {selectedCount > 0 ? ( {selectionActions} ) : null} {showPagination ? ( ) : null}
) } /* -------------------------------------------------------------------------- */ /* toolbar */ /* -------------------------------------------------------------------------- */ /** The row above the table. Anything can go in it. */ function DataTableToolbar({ className, ...props }: React.ComponentProps<"div">) { return (
) } interface DataTableSearchProps extends Omit, "value" | "onChange"> { table: DataTableInstance /** Column id to filter. */ column: string } /** An input bound to one column's filter. */ function DataTableSearch({ table, column, className, ...props }: DataTableSearchProps) { const target = table.getColumn(column) if (!target) return null return ( target.setFilterValue(event.target.value)} className={cn("h-8 max-w-sm", className)} {...props} /> ) } interface DataTableViewOptionsProps extends React.ComponentProps { table: DataTableInstance /** Adds "move left / move right" items for the reorderable columns. */ reorderable?: boolean | string[] } /** Column visibility, and column order when reordering is on. */ function DataTableViewOptions({ table, reorderable = false, className, ...props }: DataTableViewOptionsProps) { const columns = table.getAllColumns().filter((column) => column.getCanHide()) return ( Toggle columns {columns.map((column) => ( column.toggleVisibility(!!value)} > {column.id} ))} {reorderable ? ( <> Column order table.resetColumnOrder()}> Reset to default ) : null} ) } interface DataTableColumnHeaderProps extends React.ComponentProps<"div"> { column: Column title: string align?: "left" | "right" } function DataTableColumnHeader({ column, title, align = "left", className, ...props }: DataTableColumnHeaderProps) { if (!column.getCanSort()) { return (
{title}
) } const sorted = column.getIsSorted() const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown return (
) } /* -------------------------------------------------------------------------- */ /* selection */ /* -------------------------------------------------------------------------- */ /** * A checkbox column. Put it first in your column list and give the table a * `getRowId` so the selection survives sorting and paging. */ function createSelectionColumn( overrides?: Partial> ): DataTableColumnDef { return { id: "select", size: 36, enableSorting: false, enableHiding: false, header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value)} aria-label="Select all rows on this page" /> ), cell: ({ row }) => ( row.getToggleSelectedHandler()({ target: { checked: !row.getIsSelected() }, shiftKey: event.shiftKey, }) } aria-label="Select row" /> ), ...overrides, } as DataTableColumnDef } interface DataTableSelectionBarProps extends React.ComponentProps<"div"> { table: DataTableInstance } /** The bar that appears once rows are selected. Children are the actions. */ function DataTableSelectionBar({ table, children, className, ...props }: DataTableSelectionBarProps) { const count = table.getSelectedRowModel().rows.length return (
{count} {count === 1 ? "row" : "rows"} selected
{children}
) } /* -------------------------------------------------------------------------- */ /* content */ /* -------------------------------------------------------------------------- */ /** Moves `columnId` to the slot `targetId` currently occupies. */ function moveColumn( table: DataTableInstance, columnId: string, targetId: string ) { const order = table.getAllLeafColumns().map((column) => column.id) const from = order.indexOf(columnId) const to = order.indexOf(targetId) if (from < 0 || to < 0 || from === to) return const next = order.slice() next.splice(from, 1) next.splice(to, 0, columnId) table.setColumnOrder(next) } /** Nudges `columnId` one slot left (-1) or right (1). */ function shiftColumn( table: DataTableInstance, columnId: string, delta: number ) { const order = table.getAllLeafColumns().map((column) => column.id) const from = order.indexOf(columnId) const to = from + delta if (from < 0 || to < 0 || to >= order.length) return const next = order.slice() next.splice(from, 1) next.splice(to, 0, columnId) table.setColumnOrder(next) } /** * Sticky offsets and hairlines for a pinned cell. The feature computes the * regions; the renderer owns the CSS. */ function getPinnedStyle( column: DataTableColumn, { stickyHeader = false }: { stickyHeader?: boolean } = {} ): React.CSSProperties | undefined { const pinned = column.getIsPinned() const shadows: string[] = [] if (stickyHeader) shadows.push("inset 0 -1px 0 0 var(--border)") if (pinned === "start" && column.getIsLastColumn("start")) { shadows.push("inset -1px 0 0 0 var(--border)") } if (pinned === "end" && column.getIsFirstColumn("end")) { shadows.push("inset 1px 0 0 0 var(--border)") } if (!pinned && !shadows.length) return undefined // Auto table layout treats width as a suggestion, so pin all three: the // sticky offsets are computed from `size` and have to match what renders. const size = pinned ? column.getSize() : undefined return { insetInlineStart: pinned === "start" ? `${column.getStart("start")}px` : undefined, insetInlineEnd: pinned === "end" ? `${column.getAfter("end")}px` : undefined, width: size, minWidth: size, maxWidth: size, boxShadow: shadows.length ? shadows.join(", ") : undefined, } } /** Grip on a reorderable header. Drag it, or focus it and press an arrow. */ function DataTableDragHandle({ table, column, }: { table: DataTableInstance column: DataTableColumn }) { return ( ) } const skeletonWidths = [72, 44, 58, 36, 64, 48, 52] export interface DataTableContentProps extends React.ComponentProps<"div"> { table: DataTableInstance loading?: boolean pending?: boolean skeletonRows?: number stickyHeader?: boolean maxHeight?: number | string reorderable?: boolean | string[] density?: DataTableDensity emptyMessage?: React.ReactNode onRowClick?: (row: DataTableRow) => void rowClassName?: (row: DataTableRow) => string | undefined /** * Anything else to put on a row: a handler, a data attribute, a title. * Merged after the built-in props, so it wins. */ rowProps?: (row: DataTableRow) => React.ComponentProps<"tr"> /** * Wrap the row element, e.g. in a context menu trigger or a link. Render * the element you are handed somewhere inside what you return. */ renderRow?: ( row: DataTableRow, element: React.ReactElement ) => React.ReactNode } /** The bordered table itself: header, rows, and their loading states. */ function DataTableContent({ table, loading = false, pending = false, skeletonRows = 8, stickyHeader = false, maxHeight, reorderable = false, density = "default", emptyMessage = "No results.", onRowClick, rowClassName, rowProps, renderRow, className, style, ...props }: DataTableContentProps) { const [dragging, setDragging] = React.useState(null) const [dropTarget, setDropTarget] = React.useState(null) const { start, end } = table.state.columnPinning const hasPinned = start.length > 0 || end.length > 0 const visibleColumns = table.getVisibleLeafColumns() const rows = table.getRowModel().rows const canReorder = (column: DataTableColumn) => { if (!reorderable || column.getIsPinned()) return false return Array.isArray(reorderable) ? reorderable.includes(column.id) : column.getCanHide() } /** Native drag and drop, so no drag library is pulled in for this. */ const dragProps = (columnId: string): React.ComponentProps<"th"> => ({ draggable: true, onDragStart: (event) => { setDragging(columnId) event.dataTransfer.effectAllowed = "move" event.dataTransfer.setData("text/plain", columnId) }, onDragOver: (event) => { if (!dragging || dragging === columnId) return event.preventDefault() event.dataTransfer.dropEffect = "move" setDropTarget(columnId) }, onDragLeave: () => setDropTarget((current) => (current === columnId ? null : current)), onDrop: (event) => { event.preventDefault() if (dragging) moveColumn(table, dragging, columnId) setDragging(null) setDropTarget(null) }, onDragEnd: () => { setDragging(null) setDropTarget(null) }, }) // Pinned cells are painted opaque so rows scroll under them, which would // otherwise hide the row's own hover and selected colours. Move both onto // the cells so pinned and unpinned columns stay in step. They have to stay // opaque too: a translucent hover on a pinned cell lets the columns passing // underneath show through, so the row hover is `bg-muted/50` pre-mixed over // the background rather than blended with whatever is behind the cell. const rowClasses = cn( "group/row", hasPinned && "hover:bg-transparent data-[state=selected]:bg-transparent" ) const cellClasses = cn( hasPinned && [ "group-hover/row:bg-[color-mix(in_oklab,var(--muted)_50%,var(--background))]", "group-data-[state=selected]/row:bg-muted", ] ) return (
[data-slot=table-container]]:max-h-[var(--data-table-max-height)]", densityClasses[density], className )} style={ maxHeight !== undefined ? ({ ...style, "--data-table-max-height": typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight, } as React.CSSProperties) : style } {...props} > {pending ? (
) : null} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const column = header.column as DataTableColumn const pinned = column.getIsPinned() const draggable = canReorder(column) return ( {header.isPlaceholder ? null : draggable ? (
{flexRender( column.columnDef.header, header.getContext() )}
) : ( flexRender(column.columnDef.header, header.getContext()) )}
) })}
))}
{loading ? ( ) : rows.length ? ( rows.map((row) => { const extra = rowProps?.(row) const element = ( onRowClick(row) : undefined} {...extra} className={cn( rowClasses, onRowClick && "cursor-pointer", rowClassName?.(row), extra?.className )} > {row.getVisibleCells().map((cell) => { const column = cell.column as DataTableColumn const pinned = column.getIsPinned() return ( {flexRender(column.columnDef.cell, cell.getContext())} ) })} ) return ( {renderRow ? renderRow(row, element) : element} ) }) ) : ( {emptyMessage} )}
) } /* -------------------------------------------------------------------------- */ /* skeletons */ /* -------------------------------------------------------------------------- */ function DataTableSkeletonRows({ columns, rows, }: { columns: number rows: number }) { return ( <> {Array.from({ length: rows }).map((_, rowIndex) => ( {Array.from({ length: columns }).map((__, columnIndex) => ( ))} ))} ) } export interface DataTableSkeletonProps extends React.ComponentProps<"div"> { columns: number rows?: number showToolbar?: boolean showPagination?: boolean } /** * A standalone placeholder for when the columns are not known yet, e.g. a * Suspense fallback. Once you have a table instance, `loading` on * `DataTableContent` renders the same rows under the real header. */ function DataTableSkeleton({ columns, rows = 8, showToolbar = true, showPagination = true, className, ...props }: DataTableSkeletonProps) { return (
{showToolbar ? (
) : null}
{Array.from({ length: columns }).map((_, index) => ( ))}
{showPagination ? (
) : null}
) } /* -------------------------------------------------------------------------- */ /* pagination */ /* -------------------------------------------------------------------------- */ interface DataTablePaginationProps extends React.ComponentProps<"div"> { table: DataTableInstance /** Adds a rows-per-page menu. */ pageSizeOptions?: number[] } function DataTablePagination({ table, pageSizeOptions, className, ...props }: DataTablePaginationProps) { const { pageIndex, pageSize } = table.state.pagination const total = table.getFilteredRowModel().rows.length const from = total === 0 ? 0 : pageIndex * pageSize + 1 const to = Math.min((pageIndex + 1) * pageSize, total) return (

{from}–{to} of {total}

{pageSizeOptions?.length ? ( {pageSizeOptions.map((option) => ( table.setPageSize(option)} className="tabular-nums" > {option} per page ))} ) : null}
Page {pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
) } export { DataTable, DataTableColumnHeader, DataTableContent, DataTablePagination, DataTableSearch, DataTableSelectionBar, DataTableSkeleton, DataTableToolbar, DataTableViewOptions, createDataTableColumnHelper, createSelectionColumn, dataTableFeatures, moveColumn, shiftColumn, useDataTable, type DataTableColumnDef, type DataTableDensity, type DataTableFeatures, type DataTableInstance, type DataTableRow, } ``` # Delta Badge A signed percentage change with a trend icon, colored by whether the change is good. A small badge showing a signed percentage change with a trend icon, colored by whether the change is good. Every card in this registry uses it, and it can be inverted for metrics where a decrease is the good direction. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/delta-badge.json ``` npm dependencies: `lucide-react`. ## Usage ```tsx import { DeltaBadge } from "@/components/ui/delta-badge" ``` ## Examples ### Default ```tsx import { DeltaBadge } from "@/components/ui/delta-badge" export default function DeltaBadgeDemo() { return (
) } ``` ## Source ### components/ui/delta-badge.tsx ```tsx import * as React from "react" import { Minus, TrendingDown, TrendingUp } from "lucide-react" import { cn } from "@/lib/utils" import { formatDelta } from "@/lib/format" export type DeltaDirection = "up" | "down" | "flat" export function getDeltaDirection(delta: number | undefined): DeltaDirection { if (delta === undefined || delta === 0 || !Number.isFinite(delta)) return "flat" return delta > 0 ? "up" : "down" } const directionIcon: Record = { up: TrendingUp, down: TrendingDown, flat: Minus, } export interface DeltaBadgeProps extends React.ComponentProps<"span"> { /** Fractional change, e.g. 0.124 for +12.4%. */ delta: number /** Treat a decrease as good and an increase as bad (churn, latency, errors). */ invert?: boolean variant?: "outline" | "soft" | "text" showIcon?: boolean } function DeltaBadge({ delta, invert = false, variant = "outline", showIcon = true, className, children, ...props }: DeltaBadgeProps) { const direction = getDeltaDirection(delta) const positive = direction === "flat" ? null : (direction === "up") !== invert const Icon = directionIcon[direction] return ( {showIcon ? ) } export { DeltaBadge } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Distribution Bar A single stacked bar showing how a total splits across categories. A single stacked bar that shows how a total splits across categories, with a legend. Use it for storage by type, revenue by plan, or traffic by source when a pie chart would take too much room. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/distribution-bar.json ``` ## Usage ```tsx import { DistributionBar } from "@/components/ui/distribution-bar" ``` ## Examples ### Default ```tsx import { DistributionBar } from "@/components/ui/distribution-bar" const devices = [ { name: "Desktop", value: 61_400 }, { name: "Mobile", value: 34_200 }, { name: "Tablet", value: 4_100 }, { name: "Other", value: 640 }, ] export default function DistributionBarDemo() { return (
) } ``` ## Source ### components/ui/distribution-bar.tsx ```tsx import * as React from "react" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" export interface DistributionSegment { name: string value: number /** Any CSS color. Defaults to chart-1 through chart-5 in order. */ color?: string } export interface DistributionBarProps extends React.ComponentProps<"div"> { segments: DistributionSegment[] valueFormatter?: (value: number) => string showLegend?: boolean /** Show absolute values in the legend in addition to the share. */ showValues?: boolean } const defaultColors = [ "var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", ] function DistributionBar({ segments, valueFormatter = (value) => formatNumber(value, { format: "compact" }), showLegend = true, showValues = false, className, ...props }: DistributionBarProps) { const total = segments.reduce((sum, segment) => sum + segment.value, 0) const resolved = segments.map((segment, index) => ({ ...segment, color: segment.color ?? defaultColors[index % defaultColors.length], share: total > 0 ? segment.value / total : 0, })) return (
`${segment.name} ${formatNumber(segment.share, { format: "percent" })}` ) .join(", ")} className="flex h-2.5 w-full gap-0.5 overflow-hidden rounded-full" > {resolved.map((segment) => segment.share > 0 ? (
) : null )}
{showLegend ? (
    {resolved.map((segment) => (
  • ))}
) : null}
) } export { DistributionBar } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Donut Chart A donut, pie, or half-donut with a center label, tooltip, and legend, on shadcn's chart primitives. A donut, pie, or half-donut chart for shadcn/ui with a center label, tooltip, and legend, built on shadcn's chart primitives. The half-donut form doubles as a simple gauge. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/donut-chart.json ``` Also installs `chart` from shadcn/ui if missing. npm dependencies: `recharts`. ## Usage ```tsx import { DonutChart } from "@/components/ui/donut-chart" ``` ## Examples ### Default ```tsx import { DonutChart } from "@/components/ui/donut-chart" const browsers = [ { name: "Chrome", value: 58_400 }, { name: "Safari", value: 21_300 }, { name: "Firefox", value: 8_900 }, { name: "Edge", value: 6_100 }, { name: "Other", value: 2_400 }, ] export default function DonutChartDemo() { return (
) } ``` ### Half donut Set sweep to 180 and startAngle to 180 for a gauge-like semicircle. ```tsx "use client" import { DonutChart } from "@/components/ui/donut-chart" import { formatNumber } from "@/lib/format" const sources = [ { name: "Institutional", value: 4_200_000, color: "var(--chart-2)" }, { name: "Retail", value: 2_100_000, color: "var(--chart-1)" }, { name: "Treasury", value: 900_000, color: "var(--chart-4)" }, ] export default function DonutChartHalfDemo() { return (
formatNumber(v, { format: "currency", maximumFractionDigits: 0 })} centerValue={formatNumber(7_200_000, { format: "compact" })} showLegend className="max-h-56" />
) } ``` ## Source ### components/ui/donut-chart.tsx ```tsx "use client" import * as React from "react" import { Label, Pie, PieChart } from "recharts" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart" export interface DonutSlice { name: string value: number /** Any CSS color. Defaults to chart-1 through chart-5 in order. */ color?: string } export interface DonutChartProps extends Omit, "config" | "children"> { data: DonutSlice[] /** Inner radius as a fraction of the outer radius. 0 draws a pie. */ innerRadius?: number /** Label shown in the middle, under the value. */ centerLabel?: React.ReactNode /** Value shown in the middle. Defaults to the total. */ centerValue?: React.ReactNode valueFormatter?: (value: number) => string showLegend?: boolean showTooltip?: boolean /** Angle where the first slice starts, in degrees. */ startAngle?: number /** Total sweep in degrees. 180 draws a half donut. */ sweep?: number paddingAngle?: number } function DonutChart({ data, innerRadius = 0.7, centerLabel, centerValue, valueFormatter = (value) => formatNumber(value, { format: "compact" }), showLegend = false, showTooltip = true, startAngle = 90, sweep = 360, paddingAngle = 2, className, ...props }: DonutChartProps) { const total = data.reduce((sum, slice) => sum + slice.value, 0) const slices = data.map((slice, index) => ({ ...slice, key: slice.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), fill: slice.color ?? `var(--chart-${(index % 5) + 1})`, })) const config = Object.fromEntries( slices.map((slice) => [slice.key, { label: slice.name, color: slice.fill }]) ) satisfies ChartConfig const center = centerValue ?? valueFormatter(total) return ( {showTooltip ? ( (
{item.payload.name} {valueFormatter(Number(value))}
)} /> } /> ) : null} {innerRadius > 0 && (center || centerLabel) ? ( {showLegend ? ( } /> ) : null}
) } export { DonutChart } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Dot Plot A distribution drawn as columns of stacked dots, with the peak columns at full strength. A distribution drawn as columns of stacked dots, with the peak columns at full strength. It suits counts per day or per bucket where a bar chart would look too heavy. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/dot-plot.json ``` ## Usage ```tsx import { DotPlot } from "@/components/ui/dot-plot" ``` ## Examples ### Default ```tsx import { DotPlot } from "@/components/ui/dot-plot" const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] const hourly = [1, 1, 2, 1, 2, 4, 6, 4, 2, 1, 2, 1, 1, 1] export default function DotPlotDemo() { return (
) } ``` ## Source ### components/ui/dot-plot.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" export interface DotPlotProps extends React.ComponentProps<"div"> { /** One value per column, in order. */ data: number[] /** Optional label per column, shown in the tooltip and used for the accessible name. */ labels?: string[] /** The value that fills a column. Defaults to the largest value in `data`. */ max?: number /** Number of dots in a full column. */ rows?: number /** Any CSS color. Defaults to chart-1. */ color?: string /** * Columns at or above this fraction of `max` are drawn at full strength and the * rest are faded, so the peak stands out. Set to 0 to draw every column at full strength. */ emphasis?: number /** Opacity of the faded columns. */ fadedOpacity?: number /** Show a tooltip with the column's label and value while it is hovered. */ showTooltip?: boolean /** Formats the value in the tooltip. Defaults to a plain grouped number. */ valueFormatter?: (value: number) => string /** Index of the hovered column, to control it from outside. */ activeIndex?: number | null /** Called when the hovered column changes, with null when the pointer leaves. */ onActiveIndexChange?: (index: number | null) => void } const defaultFormatter = (value: number) => value.toLocaleString("en-US") /** * A distribution drawn as columns of stacked dots, with the peak columns at full strength. * Hovering a column lights it up, fades the others, and shows its value. */ function DotPlot({ data, labels, max, rows = 6, color = "var(--chart-1)", emphasis = 0.5, fadedOpacity = 0.35, showTooltip = true, valueFormatter = defaultFormatter, activeIndex, onActiveIndexChange, className, ...props }: DotPlotProps) { const [internal, setInternal] = React.useState(null) const active = activeIndex === undefined ? internal : activeIndex const setActive = (index: number | null) => { setInternal(index) onActiveIndexChange?.(index) } const top = max ?? Math.max(0, ...data) const peak = data.indexOf(Math.max(...data)) const peakLabel = labels?.[peak] return (
setActive(null)} className={cn( // Dot size and gap are variables so they can be tuned from className, e.g. "[--dot-size:0.75rem]". "flex w-full justify-center [--dot-gap:0.25rem] [--dot-size:0.625rem]", className )} {...props} > {data.map((value, index) => { const fraction = Math.min(1, Math.max(0, value / (top || 1))) // Any non-zero value shows at least one dot so small values still register. const count = fraction === 0 ? 0 : Math.max(1, Math.round(fraction * rows)) const strong = emphasis <= 0 || fraction >= emphasis // While a column is hovered it alone is lit; otherwise the emphasized columns are. const lit = active === null ? strong : active === index const label = labels?.[index] return (
setActive(index)} // Columns stretch to the full height and carry the gap as padding, so the hover target has no dead zones. // They share the width equally and cap at the dot size, so dots shrink in narrow cards instead of overflowing. className="relative flex min-w-0 flex-1 cursor-default flex-col-reverse justify-start gap-(--dot-gap) px-[calc(var(--dot-gap)/2)] max-w-[calc(var(--dot-size)+var(--dot-gap))]" > {Array.from({ length: count }, (_, dot) => ( ))} {showTooltip && active === index ? ( {label !== undefined ? ( {label} ) : null} {valueFormatter(value)} ) : null}
) })}
) } export { DotPlot } ``` # Funnel Chart Step-by-step conversion, as stacked bars with drop-off or as a flow of tapering stages with a tile per step. A conversion funnel for shadcn/ui: each step as a bar, the drop-off between steps, and overall conversion at the end. It is plain HTML rather than SVG, which keeps it easy to restyle and accessible by default. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/funnel-chart.json ``` ## Usage ```tsx import { FunnelChart } from "@/components/ui/funnel-chart" ``` ## Examples ### Default ```tsx import { FunnelChart } from "@/components/ui/funnel-chart" const steps = [ { name: "Visited pricing", value: 12_480 }, { name: "Started signup", value: 4_920 }, { name: "Verified email", value: 3_610 }, { name: "Created workspace", value: 2_140 }, { name: "Upgraded to Pro", value: 412 }, ] export default function FunnelChartDemo() { return (
) } ``` ### Flow Set variant to "flow" to run the steps left to right, each sized to its share of the first and tapering into the next. Give each step a color, or none for the chart palette. Hover a stage to focus it. ```tsx import { FunnelChart } from "@/components/ui/funnel-chart" const steps = [ { name: "Link opened", value: 197, color: "var(--color-lime-400)" }, { name: "Started", value: 110, color: "var(--color-blue-500)" }, { name: "Completed", value: 77, color: "var(--color-violet-500)" }, { name: "Converted", value: 38, color: "var(--color-pink-500)" }, ] export default function FunnelChartFlowDemo() { return (
) } ``` ### Sharp and single color shape="sharp" draws straight trapezoids. A single color fades from stage to stage, so pass the foreground for a one-ink funnel. height sets the stage height in pixels. ```tsx import { FunnelChart } from "@/components/ui/funnel-chart" const steps = [ { name: "Applied", value: 1240 }, { name: "Screened", value: 420 }, { name: "Phone", value: 96 }, { name: "Offer", value: 18 }, { name: "Hired", value: 11 }, ] export default function FunnelChartSharpDemo() { return (
) } ``` ## Source ### components/ui/funnel-chart.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" export interface FunnelStep { name: string value: number /** Any CSS color for this step in the flow variant. Defaults to chart-1 through chart-5 in order. */ color?: string } export interface FunnelChartProps extends React.ComponentProps<"div"> { steps: FunnelStep[] valueFormatter?: (value: number) => string /** * Any CSS color. "bars" fades it step by step. "flow" paints every stage in * it, in place of the per-stage palette. */ color?: string /** Show the drop-off between consecutive steps. Bars only. */ showDropoff?: boolean /** "bars" stacks one bar per step; "flow" runs the steps left to right, each tapering into the next. */ variant?: "bars" | "flow" /** Flow only. "eased" curves each neck; "sharp" draws straight trapezoids. */ shape?: "eased" | "sharp" /** Flow only. Height of the stages in pixels. */ height?: number /** Flow only. Width of the neck between stages in pixels. */ neckWidth?: number /** Flow only. Show the share of the first step as a pill on each stage. */ showPercentages?: boolean /** Flow only. Show a tile with the name and value under each stage. */ showLabels?: boolean /** Flow only. Stage drawn at full strength while the rest dim. */ activeIndex?: number | null onActiveIndexChange?: (index: number | null) => void } const defaultColors = [ "var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", ] // Round so server and client render identical path strings. const round = (n: number) => Math.round(n * 1000) / 1000 /** Path for the neck between two stages, in a 1x1 box stretched to fit. */ function neckPath(from: number, to: number, shape: "eased" | "sharp") { const t0 = round((1 - from) / 2) const b0 = round(1 - t0) const t1 = round((1 - to) / 2) const b1 = round(1 - t1) if (shape === "sharp") return `M0,${t0} L1,${t1} L1,${b1} L0,${b0} Z` return `M0,${t0} C0.5,${t0} 0.5,${t1} 1,${t1} L1,${b1} C0.5,${b1} 0.5,${b0} 0,${b0} Z` } function FunnelBars({ steps, valueFormatter, color, showDropoff, }: { steps: FunnelStep[] valueFormatter: (value: number) => string color: string showDropoff: boolean }) { const first = steps[0]?.value ?? 0 const last = steps[steps.length - 1]?.value ?? 0 const overall = first > 0 ? last / first : 0 return ( <> {steps.map((step, index) => { const previous = index > 0 ? steps[index - 1]!.value : step.value const ofFirst = first > 0 ? step.value / first : 0 const ofPrevious = previous > 0 ? step.value / previous : 0 const dropoff = 1 - ofPrevious return ( {showDropoff && index > 0 ? (
) : null}
{index + 1} {step.name} {valueFormatter(step.value)} {formatNumber(ofFirst, { format: "percent" })}
) })} {steps.length > 1 ? (
Overall conversion {formatNumber(overall, { format: "percent" })}
) : null} ) } function FunnelFlow({ steps, valueFormatter, color, shape, height, neckWidth, showPercentages, showLabels, activeIndex, onActiveIndexChange, }: { steps: FunnelStep[] valueFormatter: (value: number) => string color?: string shape: "eased" | "sharp" height: number neckWidth: number showPercentages: boolean showLabels: boolean activeIndex?: number | null onActiveIndexChange?: (index: number | null) => void }) { const id = React.useId() const [activeState, setActiveState] = React.useState(null) const active = activeIndex === undefined ? activeState : activeIndex const setActive = (index: number | null) => { setActiveState(index) onActiveIndexChange?.(index) } const first = steps[0]?.value ?? 0 const stages = steps.map((step, index) => ({ ...step, share: first > 0 ? Math.min(1, Math.max(0, step.value / first)) : 0, fill: step.color ?? color ?? defaultColors[index % defaultColors.length], // A single color fades toward the end of the funnel so stages stay distinct. opacity: color && !step.color ? 1 - index * (0.6 / Math.max(steps.length - 1, 1)) : 1, })) return ( <>
setActive(null)} > {stages.map((stage, index) => { const next = stages[index + 1] const dimmed = active !== null && active !== index const neckDimmed = active !== null && active !== index && active !== index + 1 const gradient = `${id}-${index}` return (
setActive(index)} className="relative flex min-w-0 flex-1 items-center transition-opacity duration-200" style={{ opacity: dimmed ? 0.35 : 1 }} >
{showPercentages ? ( {formatNumber(stage.share, { format: "percent", maximumFractionDigits: 0 })} ) : null}
{next ? ( ) : null} ) })}
{showLabels ? (
{stages.map((stage, index) => (
setActive(index)} onMouseLeave={() => setActive(null)} className={cn( "flex min-w-0 flex-col gap-0.5 rounded-lg border px-2.5 py-2 transition-[opacity,border-color] duration-200", active !== null && active !== index && "opacity-50", active === index && "border-foreground/20" )} > {valueFormatter(stage.value)}
))}
) : null} ) } /** Step-by-step conversion, as stacked bars with drop-off or as a flow of tapering stages. */ function FunnelChart({ steps, valueFormatter = (value) => formatNumber(value), color, showDropoff = true, variant = "bars", shape = "eased", height = 160, neckWidth = 24, showPercentages = true, showLabels = true, activeIndex, onActiveIndexChange, className, ...props }: FunnelChartProps) { return (
{variant === "flow" ? ( ) : ( )}
) } export { FunnelChart } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Heatmap Chart A matrix heatmap with a row per line and a column per label, e.g. weekday by hour, with hover highlighting and tooltips. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/heatmap-chart.json ``` Also installs `tooltip` from shadcn/ui if missing. ## Usage ```tsx import { HeatmapChart } from "@/components/ui/heatmap-chart" ``` ## Examples ### Default ```tsx import { HeatmapChart } from "@/components/ui/heatmap-chart" const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] const hours = Array.from({ length: 12 }, (_, i) => String(i * 2).padStart(2, "0")) // Deterministic sample data so the demo is stable between renders: a working-hours // peak on weekdays, a quieter and later curve at the weekend, plus some noise. function sampleRows() { let seed = 7 const random = () => { seed = (seed * 1664525 + 1013904223) % 4294967296 return seed / 4294967296 } return days.map((label, d) => { const weekend = d >= 5 return { label, values: hours.map((_, h) => { const hour = h * 2 + 1 const peak = weekend ? 15 : 14 const shape = Math.exp(-((hour - peak) ** 2) / (weekend ? 40 : 22)) const base = (weekend ? 90 : 320) * shape + (weekend ? 6 : 12) return Math.round(base * (0.75 + random() * 0.5)) }), } }) } const rows = sampleRows() export default function HeatmapChartDemo() { return (
) } ``` ### Regions by month Any two axes work. Set color for the accent and scale to "sqrt" to spread out small values. ```tsx "use client" import { HeatmapChart } from "@/components/ui/heatmap-chart" const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] // Orders per region and month, in thousands. Each region has its own size and // seasonal shape so the rows read differently instead of as one gradient. const regions: { label: string; size: number; peak: number }[] = [ { label: "North America", size: 48, peak: 11 }, { label: "Europe", size: 36, peak: 10 }, { label: "Asia Pacific", size: 42, peak: 5 }, { label: "Latin America", size: 14, peak: 7 }, { label: "Middle East", size: 9, peak: 2 }, { label: "Africa", size: 5, peak: 8 }, ] function sampleRows() { let seed = 19 const random = () => { seed = (seed * 1664525 + 1013904223) % 4294967296 return seed / 4294967296 } return regions.map((region) => ({ label: region.label, values: months.map((_, m) => { const distance = Math.min(Math.abs(m - region.peak), 12 - Math.abs(m - region.peak)) const season = 0.45 + 0.55 * Math.cos((distance / 6) * Math.PI) ** 2 return Math.round(region.size * season * (0.85 + random() * 0.3) * 10) / 10 }), })) } const rows = sampleRows() export default function HeatmapChartRegionsDemo() { return (
value.toFixed(1)} />
) } ``` ## Source ### components/ui/heatmap-chart.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" export interface HeatmapRow { label: string /** One value per column, in column order. Missing entries read as 0. */ values: number[] } /** A cell's position by row and column index. */ export interface HeatmapCell { row: number column: number } export interface HeatmapDatum extends HeatmapCell { rowLabel: string columnLabel: string value: number } export interface HeatmapChartProps extends React.ComponentProps<"div"> { /** One row per line of the matrix, top to bottom. */ rows: HeatmapRow[] /** Column labels, left to right. Sets the number of columns. */ columns: string[] /** Any CSS color. Cells mix this into `--muted` by value. Defaults to chart-1. */ color?: string /** Value drawn fully saturated. Defaults to the largest value. */ max?: number /** How values map to color. `sqrt` spreads out small values. */ scale?: "linear" | "sqrt" /** Quantize the fill into this many steps instead of a continuous ramp. */ levels?: number /** Gap between cells in px. Cells themselves stretch to fill the container. */ gap?: number valueFormatter?: (value: number) => string /** Label for the value in the tooltip, e.g. "sessions". */ unit?: string showLegend?: boolean showRowLabels?: boolean showColumnLabels?: boolean /** Show every nth column label. Defaults to whatever keeps about 12 labels. */ columnLabelEvery?: number /** Replace the tooltip body. Return `null` to hide it for that cell. */ renderTooltip?: (datum: HeatmapDatum) => React.ReactNode onCellClick?: (datum: HeatmapDatum) => void /** The hovered cell, to control it from outside. */ activeCell?: HeatmapCell | null /** Called when the hovered cell changes, with null when the pointer leaves. */ onActiveCellChange?: (cell: HeatmapCell | null) => void } /** Where a value sits between the muted track (0) and the accent (1). */ function fractionFor( value: number, max: number, scale: "linear" | "sqrt", levels?: number ) { if (value <= 0 || max <= 0) return 0 let t = Math.min(1, value / max) if (scale === "sqrt") t = Math.sqrt(t) if (levels && levels > 1) { t = Math.max(1, Math.ceil(t * (levels - 1))) / (levels - 1) } return t } function fillFor(color: string, fraction: number) { if (fraction <= 0) return "var(--muted)" // Floor the mix so the smallest non-zero values still read against the track. const pct = Math.round(12 + fraction * 88) return `color-mix(in oklab, ${color} ${pct}%, var(--muted))` } /** * A matrix heatmap with a row per line and a column per label, e.g. weekday by hour. * Cells stretch to the container width. Hovering a cell rings it, lights up its row * and column labels, and shows its value. */ function HeatmapChart({ rows, columns, color = "var(--chart-1)", max, scale = "linear", levels, gap = 4, valueFormatter = (value) => formatNumber(value), unit, showLegend = true, showRowLabels = true, showColumnLabels = true, columnLabelEvery, renderTooltip, onCellClick, activeCell, onActiveCellChange, className, ...props }: HeatmapChartProps) { const [internal, setInternal] = React.useState(null) const active = activeCell === undefined ? internal : activeCell const setActive = (cell: HeatmapCell | null) => { setInternal(cell) onActiveCellChange?.(cell) } const top = React.useMemo( () => max ?? rows.reduce( (acc, row) => row.values.reduce((m, v) => Math.max(m, v), acc), 0 ), [rows, max] ) const every = columnLabelEvery ?? Math.max(1, Math.ceil(columns.length / 12)) const steps = levels && levels > 1 ? levels : 5 const swatches = Array.from({ length: steps }, (_, i) => fillFor(color, i / (steps - 1)) ) const withUnit = (value: number) => unit ? `${valueFormatter(value)} ${unit}` : valueFormatter(value) return (
setActive(null)} > {rows.map((row, r) => ( {showRowLabels ? (
setActive(null)} className="text-muted-foreground data-[active=true]:text-foreground flex items-center pr-1.5 leading-none whitespace-nowrap transition-colors" > {row.label}
) : null} {columns.map((column, c) => { const value = row.values[c] ?? 0 const datum: HeatmapDatum = { row: r, column: c, rowLabel: row.label, columnLabel: column, value, } const isActive = active?.row === r && active?.column === c const body = renderTooltip ? ( renderTooltip(datum) ) : ( <> {withUnit(value)} {row.label} · {column} ) const cell = (
) } export { HeatmapChart } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # KPI Card A metric card with value, period-over-period delta, and an optional sparkline. The KPI card is the stat card at the top of most dashboards: a label, a formatted value, a delta badge for the change since last period, and an optional sparkline. It is built from the shadcn/ui Card, so it matches the rest of your app, and it knows that a decrease is good for metrics like churn or latency. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/kpi-card.json ``` Also installs `card`, `tooltip` from shadcn/ui if missing. npm dependencies: `recharts`, `lucide-react`. ## Usage ```tsx import { KpiCard } from "@/components/ui/kpi-card" ``` ## Examples ### Default ```tsx import { KpiCard } from "@/components/ui/kpi-card" const revenue = [42, 48, 45, 52, 58, 55, 61, 67, 64, 72, 78, 84] const users = [1200, 1260, 1250, 1300, 1310, 1290, 1280, 1300, 1295, 1310, 1300, 1305] const churn = [3.2, 3.1, 3.3, 2.9, 2.8, 2.9, 2.6, 2.5, 2.4, 2.4, 2.2, 2.1] export default function KpiCardDemo() { return (
) } ``` ## Source ### components/ui/kpi-card.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" import { type NumberFormat } from "@/lib/format" import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card" import { DeltaBadge, getDeltaDirection } from "@/components/ui/delta-badge" import { Sparkline } from "@/components/ui/sparkline" import { MetricValue } from "@/components/ui/metric-value" export interface KpiCardProps extends Omit< React.ComponentProps, "children" > { /** Metric name, e.g. "Revenue". */ label: string /** Current value. Numbers are formatted with `format`; strings render as-is. */ value: number | string /** Fractional change vs. the previous period, e.g. 0.124 for +12.4%. */ delta?: number /** Context for the delta, e.g. "vs. last 30 days". */ deltaLabel?: string /** Series for the sparkline. Rendered when it has two or more points. */ trend?: number[] format?: NumberFormat /** ISO 4217 code, used when `format` is "currency". */ currency?: string /** Treat a decrease as good and an increase as bad (churn, latency, errors). */ invertDelta?: boolean /** Optional icon shown before the label. */ icon?: React.ReactNode children?: React.ReactNode } function KpiCard({ label, value, delta, deltaLabel, trend, format = "number", currency, invertDelta = false, icon, className, children, ...props }: KpiCardProps) { const direction = getDeltaDirection(delta) const isPositive = direction === "flat" ? null : (direction === "up") !== invertDelta const trendColor = isPositive === true ? "var(--color-emerald-500)" : isPositive === false ? "var(--color-red-500)" : "var(--primary)" return ( {icon} {label} {delta !== undefined ? ( ) : null} {trend && trend.length > 1 ? ( ) : null} {deltaLabel || children ? ( {deltaLabel ? {deltaLabel} : null} {children} ) : null} ) } export { KpiCard } ``` ### components/ui/delta-badge.tsx ```tsx import * as React from "react" import { Minus, TrendingDown, TrendingUp } from "lucide-react" import { cn } from "@/lib/utils" import { formatDelta } from "@/lib/format" export type DeltaDirection = "up" | "down" | "flat" export function getDeltaDirection(delta: number | undefined): DeltaDirection { if (delta === undefined || delta === 0 || !Number.isFinite(delta)) return "flat" return delta > 0 ? "up" : "down" } const directionIcon: Record = { up: TrendingUp, down: TrendingDown, flat: Minus, } export interface DeltaBadgeProps extends React.ComponentProps<"span"> { /** Fractional change, e.g. 0.124 for +12.4%. */ delta: number /** Treat a decrease as good and an increase as bad (churn, latency, errors). */ invert?: boolean variant?: "outline" | "soft" | "text" showIcon?: boolean } function DeltaBadge({ delta, invert = false, variant = "outline", showIcon = true, className, children, ...props }: DeltaBadgeProps) { const direction = getDeltaDirection(delta) const positive = direction === "flat" ? null : (direction === "up") !== invert const Icon = directionIcon[direction] return ( {showIcon ? ) } export { DeltaBadge } ``` ### components/ui/sparkline.tsx ```tsx "use client" import * as React from "react" import { Area, AreaChart, AreaRevealShape, Line, LineChart, ResponsiveContainer, type AreaRevealShapeProps, } from "recharts" import { cn } from "@/lib/utils" export interface SparklineProps extends React.ComponentProps<"div"> { /** Series to plot, oldest first. */ data: number[] variant?: "area" | "line" /** Area fill: a plain gradient, or a dot grid that fades out toward the line. */ fill?: "gradient" | "dots" /** Any CSS color. Defaults to the theme primary. */ color?: string curve?: "monotone" | "linear" | "step" strokeWidth?: number } /** Area shape that masks the fill to a dot grid while keeping the stroke solid. */ function DotGridAreaShape({ maskId, ...props }: AreaRevealShapeProps & { maskId: string }) { return ( <> ) } function Sparkline({ data, variant = "area", fill = "gradient", color = "var(--primary)", curve = "monotone", strokeWidth = 1.5, className, ...props }: SparklineProps) { const id = React.useId() const dots = variant === "area" && fill === "dots" const points = React.useMemo(() => data.map((y, i) => ({ i, y })), [data]) const margin = { top: 2, right: 0, bottom: 0, left: 0 } return ( ) } export { Sparkline } ``` ### components/ui/metric-value.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" import { formatNumber, type NumberFormat } from "@/lib/format" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" /** Values at or above this are abbreviated by default. */ export const DEFAULT_COMPACT_FROM = 100_000 export interface MetricValueProps extends Omit, "children"> { /** Strings render as-is. */ value: number | string format?: NumberFormat currency?: string maximumFractionDigits?: number /** Values at or above this are abbreviated (e.g. $158K) with the full value in a tooltip. Set to Infinity to always show the full value. */ compactFrom?: number /** Text after the number, e.g. a unit. */ suffix?: React.ReactNode } /** A formatted number. Large values are abbreviated and reveal the full value on hover. */ function MetricValue({ value, format, currency, maximumFractionDigits, compactFrom = DEFAULT_COMPACT_FROM, suffix, className, ...props }: MetricValueProps) { const classes = cn("tabular-nums", className) if (typeof value !== "number") { return ( {value} {suffix} ) } const full = formatNumber(value, { format, currency, maximumFractionDigits }) const abbreviate = format !== "percent" && Math.abs(value) >= compactFrom if (!abbreviate) { return ( {full} {suffix} ) } return ( {formatNumber(value, { format, currency, compact: true })} {suffix} {full} {suffix} ) } export { MetricValue } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Metric List Compact rows of label, sparkline, value, and delta. Compact rows of label, sparkline, value, and delta, for listing several metrics in the space of one card. It uses the same formatting and delta conventions as the KPI card. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/metric-list.json ``` Also installs `tooltip` from shadcn/ui if missing. npm dependencies: `recharts`, `lucide-react`. ## Usage ```tsx import { MetricList } from "@/components/ui/metric-list" ``` ## Examples ### Default ```tsx import { MetricList } from "@/components/ui/metric-list" const items = [ { label: "Orders", value: 2_865, delta: 0.18, trend: [12, 14, 13, 18, 22, 21, 26] }, { label: "Response time", value: "135 ms", delta: 0.14, invertDelta: true, trend: [140, 130, 128, 135, 150, 142, 135] }, { label: "Revenue", value: 8_670, format: "currency" as const, delta: 0.15, trend: [5, 6, 6.5, 6, 7.2, 8.1, 8.6] }, { label: "Users", value: 1_425, delta: 0.15, trend: [900, 950, 1_010, 1_100, 1_180, 1_320, 1_425] }, { label: "Refunds", value: 42, delta: -0.06, invertDelta: true, trend: [50, 48, 51, 46, 44, 45, 42] }, ] export default function MetricListDemo() { return (
) } ``` ## Source ### components/ui/metric-list.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" import { type NumberFormat } from "@/lib/format" import { DeltaBadge, getDeltaDirection } from "@/components/ui/delta-badge" import { Sparkline } from "@/components/ui/sparkline" import { MetricValue } from "@/components/ui/metric-value" export interface MetricListItem { label: string value: number | string delta?: number trend?: number[] format?: NumberFormat currency?: string invertDelta?: boolean icon?: React.ReactNode key?: string } export interface MetricListProps extends React.ComponentProps<"div"> { items: MetricListItem[] /** Sparkline variant. */ variant?: "area" | "line" /** Sparkline area fill. */ fill?: "gradient" | "dots" showDivider?: boolean } /** Compact rows of label, sparkline, value, and delta. */ function MetricList({ items, variant = "line", fill, showDivider = true, className, ...props }: MetricListProps) { return (
{items.map((item) => { const direction = getDeltaDirection(item.delta) const positive = direction === "flat" ? null : (direction === "up") !== Boolean(item.invertDelta) const color = positive === true ? "var(--color-emerald-500)" : positive === false ? "var(--color-red-500)" : "var(--muted-foreground)" return (
{item.icon ? ( {item.icon} ) : null} {item.label}
{item.trend && item.trend.length > 1 ? ( ) : null}
{item.delta !== undefined ? ( ) : null}
) })}
) } export { MetricList } ``` ### components/ui/delta-badge.tsx ```tsx import * as React from "react" import { Minus, TrendingDown, TrendingUp } from "lucide-react" import { cn } from "@/lib/utils" import { formatDelta } from "@/lib/format" export type DeltaDirection = "up" | "down" | "flat" export function getDeltaDirection(delta: number | undefined): DeltaDirection { if (delta === undefined || delta === 0 || !Number.isFinite(delta)) return "flat" return delta > 0 ? "up" : "down" } const directionIcon: Record = { up: TrendingUp, down: TrendingDown, flat: Minus, } export interface DeltaBadgeProps extends React.ComponentProps<"span"> { /** Fractional change, e.g. 0.124 for +12.4%. */ delta: number /** Treat a decrease as good and an increase as bad (churn, latency, errors). */ invert?: boolean variant?: "outline" | "soft" | "text" showIcon?: boolean } function DeltaBadge({ delta, invert = false, variant = "outline", showIcon = true, className, children, ...props }: DeltaBadgeProps) { const direction = getDeltaDirection(delta) const positive = direction === "flat" ? null : (direction === "up") !== invert const Icon = directionIcon[direction] return ( {showIcon ? ) } export { DeltaBadge } ``` ### components/ui/sparkline.tsx ```tsx "use client" import * as React from "react" import { Area, AreaChart, AreaRevealShape, Line, LineChart, ResponsiveContainer, type AreaRevealShapeProps, } from "recharts" import { cn } from "@/lib/utils" export interface SparklineProps extends React.ComponentProps<"div"> { /** Series to plot, oldest first. */ data: number[] variant?: "area" | "line" /** Area fill: a plain gradient, or a dot grid that fades out toward the line. */ fill?: "gradient" | "dots" /** Any CSS color. Defaults to the theme primary. */ color?: string curve?: "monotone" | "linear" | "step" strokeWidth?: number } /** Area shape that masks the fill to a dot grid while keeping the stroke solid. */ function DotGridAreaShape({ maskId, ...props }: AreaRevealShapeProps & { maskId: string }) { return ( <> ) } function Sparkline({ data, variant = "area", fill = "gradient", color = "var(--primary)", curve = "monotone", strokeWidth = 1.5, className, ...props }: SparklineProps) { const id = React.useId() const dots = variant === "area" && fill === "dots" const points = React.useMemo(() => data.map((y, i) => ({ i, y })), [data]) const margin = { top: 2, right: 0, bottom: 0, left: 0 } return ( ) } export { Sparkline } ``` ### components/ui/metric-value.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" import { formatNumber, type NumberFormat } from "@/lib/format" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" /** Values at or above this are abbreviated by default. */ export const DEFAULT_COMPACT_FROM = 100_000 export interface MetricValueProps extends Omit, "children"> { /** Strings render as-is. */ value: number | string format?: NumberFormat currency?: string maximumFractionDigits?: number /** Values at or above this are abbreviated (e.g. $158K) with the full value in a tooltip. Set to Infinity to always show the full value. */ compactFrom?: number /** Text after the number, e.g. a unit. */ suffix?: React.ReactNode } /** A formatted number. Large values are abbreviated and reveal the full value on hover. */ function MetricValue({ value, format, currency, maximumFractionDigits, compactFrom = DEFAULT_COMPACT_FROM, suffix, className, ...props }: MetricValueProps) { const classes = cn("tabular-nums", className) if (typeof value !== "number") { return ( {value} {suffix} ) } const full = formatNumber(value, { format, currency, maximumFractionDigits }) const abbreviate = format !== "percent" && Math.abs(value) >= compactFrom if (!abbreviate) { return ( {full} {suffix} ) } return ( {formatNumber(value, { format, currency, compact: true })} {suffix} {full} {suffix} ) } export { MetricValue } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Metric Value A formatted number that abbreviates large values (e.g. $158K) and shows the full value in a tooltip on hover. Every card in this registry renders its numbers through it. The number formatter used by every card here. It renders currency, percent, compact, and plain numbers, abbreviates large values to K, M, and B, and shows the full value in a tooltip on hover. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/metric-value.json ``` Also installs `tooltip` from shadcn/ui if missing. ## Usage ```tsx import { MetricValue } from "@/components/ui/metric-value" ``` ## Examples ### Default ```tsx import { MetricValue } from "@/components/ui/metric-value" export default function MetricValueDemo() { return (

Values from 100,000 up are abbreviated. Hover one to see the full value, or set compactFrom to change the threshold.

) } ``` ## Source ### components/ui/metric-value.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" import { formatNumber, type NumberFormat } from "@/lib/format" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" /** Values at or above this are abbreviated by default. */ export const DEFAULT_COMPACT_FROM = 100_000 export interface MetricValueProps extends Omit, "children"> { /** Strings render as-is. */ value: number | string format?: NumberFormat currency?: string maximumFractionDigits?: number /** Values at or above this are abbreviated (e.g. $158K) with the full value in a tooltip. Set to Infinity to always show the full value. */ compactFrom?: number /** Text after the number, e.g. a unit. */ suffix?: React.ReactNode } /** A formatted number. Large values are abbreviated and reveal the full value on hover. */ function MetricValue({ value, format, currency, maximumFractionDigits, compactFrom = DEFAULT_COMPACT_FROM, suffix, className, ...props }: MetricValueProps) { const classes = cn("tabular-nums", className) if (typeof value !== "number") { return ( {value} {suffix} ) } const full = formatNumber(value, { format, currency, maximumFractionDigits }) const abbreviate = format !== "percent" && Math.abs(value) >= compactFrom if (!abbreviate) { return ( {full} {suffix} ) } return ( {formatNumber(value, { format, currency, compact: true })} {suffix} {full} {suffix} ) } export { MetricValue } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Period Tabs A small segmented control for switching a chart between week, month, and year. A small segmented control for switching a chart or card between periods like week, month, and year. It is a thin layer over shadcn's Tabs, with an optional animated pill. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/period-tabs.json ``` Also installs `tabs` from shadcn/ui if missing. ## Usage ```tsx import { PeriodTabs } from "@/components/ui/period-tabs" const [period, setPeriod] = React.useState("month") ``` ## Examples ### Default ```tsx "use client" import * as React from "react" import { PeriodTabs } from "@/components/ui/period-tabs" export default function PeriodTabsDemo() { const [period, setPeriod] = React.useState("month") return (
Showing: {period}
) } ``` ### Animated Set animated to slide a single pill between tabs instead of swapping backgrounds. ```tsx "use client" import * as React from "react" import { PeriodTabs } from "@/components/ui/period-tabs" export default function PeriodTabsAnimatedDemo() { const [period, setPeriod] = React.useState("month") return (
Showing: {period}
) } ``` ## Source ### components/ui/period-tabs.tsx ```tsx "use client" import * as React from "react" import { cn } from "@/lib/utils" import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" export interface PeriodOption { value: string label: string } export const defaultPeriods: PeriodOption[] = [ { value: "week", label: "Week" }, { value: "month", label: "Month" }, { value: "year", label: "Year" }, ] export interface PeriodTabsProps extends Omit, "children"> { options?: PeriodOption[] size?: "sm" | "default" /** Slide a single pill between tabs instead of swapping backgrounds. */ animated?: boolean } interface IndicatorRect { left: number top: number width: number height: number } /** Small segmented control for switching a chart's time range. */ function PeriodTabs({ options = defaultPeriods, size = "sm", animated = false, className, value, defaultValue, onValueChange, ...props }: PeriodTabsProps) { const listRef = React.useRef(null) const [internalValue, setInternalValue] = React.useState( defaultValue ?? options[0]?.value ) const active = value ?? internalValue const [rect, setRect] = React.useState(null) const measure = React.useCallback(() => { const list = listRef.current if (!list) return const trigger = list.querySelector('[data-state="active"]') if (!trigger) return setRect({ left: trigger.offsetLeft, top: trigger.offsetTop, width: trigger.offsetWidth, height: trigger.offsetHeight, }) }, []) React.useLayoutEffect(() => { if (!animated) return measure() }, [animated, active, options, size, measure]) React.useEffect(() => { if (!animated || !listRef.current) return const observer = new ResizeObserver(measure) observer.observe(listRef.current) return () => observer.disconnect() }, [animated, measure]) return ( { setInternalValue(next) onValueChange?.(next) }} {...props} > {animated && rect ? ( ) : null} {options.map((option) => ( {option.label} ))} ) } export { PeriodTabs } ``` # Radar Chart A filled, outlined, or dotted radar chart with polygon or circle grids, for scores and multi-series comparisons. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/radar-chart.json ``` Also installs `chart` from shadcn/ui if missing. npm dependencies: `recharts`. ## Usage ```tsx import { RadarChart } from "@/components/ui/radar-chart" ``` ## Examples ### Default ```tsx "use client" import { RadarChart } from "@/components/ui/radar-chart" const data = [ { month: "January", visitors: 186_000 }, { month: "February", visitors: 305_000 }, { month: "March", visitors: 237_000 }, { month: "April", visitors: 273_000 }, { month: "May", visitors: 209_000 }, { month: "June", visitors: 214_000 }, ] export default function RadarChartDemo() { return (
) } ``` ### Multiple series Each series draws its own polygon. variant="line" drops the fill so overlaps stay readable. ```tsx "use client" import { RadarChart } from "@/components/ui/radar-chart" const data = [ { month: "January", desktop: 186_000, mobile: 80_000 }, { month: "February", desktop: 305_000, mobile: 200_000 }, { month: "March", desktop: 237_000, mobile: 120_000 }, { month: "April", desktop: 73_000, mobile: 190_000 }, { month: "May", desktop: 209_000, mobile: 130_000 }, { month: "June", desktop: 214_000, mobile: 140_000 }, ] export default function RadarChartMultiDemo() { return (
) } ``` ### Score radar variant="dots" marks every vertex. Set grid to "circle", a fixed domain, and showRadiusAxis for a scorecard. ```tsx "use client" import { RadarChart } from "@/components/ui/radar-chart" import { formatNumber } from "@/lib/format" const data = [ { skill: "Speed", score: 82 }, { skill: "Reliability", score: 91 }, { skill: "Design", score: 68 }, { skill: "Docs", score: 74 }, { skill: "Support", score: 88 }, ] export default function RadarChartDotsDemo() { return (
formatNumber(v)} />
) } ``` ## Source ### components/ui/radar-chart.tsx ```tsx "use client" import * as React from "react" import { PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart as RechartsRadarChart, } from "recharts" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart" export interface RadarSeries { /** Key in each data row. */ key: string label: string /** Any CSS color. Defaults to chart-1 through chart-5 in order. */ color?: string } export interface RadarChartProps extends Omit, "config" | "children"> { data: Record[] /** Key of the category label in each row, one per spoke. */ angleKey: string series: RadarSeries[] /** "filled" shades the area, "line" draws only the outline, "dots" adds a dot at every vertex. */ variant?: "filled" | "line" | "dots" /** Shape of the background grid rings. */ grid?: "polygon" | "circle" | "none" /** Show the category label at the end of each spoke. */ showAngleLabels?: boolean /** Show value ticks along the vertical spoke. */ showRadiusAxis?: boolean showLegend?: boolean showTooltip?: boolean /** Recharts domain for the value axis, e.g. [0, 100]. Defaults to [0, "auto"]. */ domain?: React.ComponentProps["domain"] valueFormatter?: (value: number) => string } /** A radar chart on shadcn's chart primitives: filled, outlined, or dotted, with one polygon per series. */ function RadarChart({ data, angleKey, series, variant = "filled", grid = "polygon", showAngleLabels = true, showRadiusAxis = false, showLegend = false, showTooltip = true, domain, valueFormatter = (value) => formatNumber(value, { format: "compact" }), className, ...props }: RadarChartProps) { const config = Object.fromEntries( series.map((s, index) => [ s.key, { label: s.label, color: s.color ?? `var(--chart-${(index % 5) + 1})` }, ]) ) satisfies ChartConfig return ( {grid !== "none" ? : null} {showTooltip ? ( (
{config[name as string]?.label ?? name} {valueFormatter(Number(value))}
)} /> } /> ) : null} {showLegend ? } /> : null} {series.map((s) => { const color = `var(--color-${s.key})` return ( ) })}
) } export { RadarChart } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Radial Gauge A semicircular or ring gauge, continuous or segmented, with content in the middle. A semicircular or ring gauge for a value out of a whole, continuous or segmented, with room for content in the middle. Use it for capacity, health scores, or progress toward a target. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/radial-gauge.json ``` ## Usage ```tsx import { RadialGauge } from "@/components/ui/radial-gauge" 99.7% Uptime ``` ## Examples ### Default ```tsx import { RadialGauge } from "@/components/ui/radial-gauge" export default function RadialGaugeDemo() { return (
99.7% Uptime 48% Coverage 72
) } ``` ### Inline in a KPI card A small segmented ring next to a value. ```tsx import { Card, CardContent } from "@/components/ui/card" import { DeltaBadge } from "@/components/ui/delta-badge" import { RadialGauge } from "@/components/ui/radial-gauge" const metrics = [ { label: "API response time", value: "132 ms", pct: 66, delta: -0.108, invert: true }, { label: "Error rate", value: "1.4 %", pct: 28, delta: 0.05, invert: true }, { label: "Throughput", value: "4.3k req/s", pct: 86, delta: 0.1 }, ] export default function RadialGaugeInlineDemo() { return (
{metrics.map((m) => ( {m.label}
{m.value}
))}
) } ``` ## Source ### components/ui/radial-gauge.tsx ```tsx import * as React from "react" import { cn } from "@/lib/utils" export interface RadialGaugeProps extends React.ComponentProps<"div"> { value: number min?: number max?: number /** Diameter in pixels. */ size?: number /** Stroke width in pixels. */ thickness?: number /** Number of arc segments. 0 draws a continuous arc. */ segments?: number /** Gap between segments in degrees. */ gap?: number /** Angle where the arc starts, in degrees clockwise from 12 o'clock. */ startAngle?: number /** Total sweep of the arc in degrees. 180 is a semicircle, 360 a ring. */ sweep?: number /** Any CSS color. Defaults to chart-1. */ color?: string trackColor?: string /** Content rendered in the middle of the gauge. */ children?: React.ReactNode } // Round so server and client render identical path strings. const round = (n: number) => Math.round(n * 1000) / 1000 function polar(cx: number, cy: number, r: number, angleDeg: number) { const rad = ((angleDeg - 90) * Math.PI) / 180 return { x: round(cx + r * Math.cos(rad)), y: round(cy + r * Math.sin(rad)) } } function arcPath(cx: number, cy: number, r: number, from: number, to: number) { const start = polar(cx, cy, r, from) const end = polar(cx, cy, r, to) const largeArc = to - from > 180 ? 1 : 0 return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}` } function RadialGauge({ value, min = 0, max = 100, size = 160, thickness = 10, segments = 0, gap = 2, startAngle, sweep = 180, color = "var(--chart-1)", trackColor = "var(--muted)", className, children, ...props }: RadialGaugeProps) { const fraction = Math.min(1, Math.max(0, (value - min) / (max - min || 1))) const start = startAngle ?? (sweep >= 360 ? 0 : -sweep / 2) const r = size / 2 - thickness / 2 const cx = size / 2 const cy = size / 2 // Trim the box to the arc's vertical extent so a semicircle does not // reserve a full circle of space. const angles = Array.from({ length: 64 }, (_, i) => start + (sweep * i) / 63) const ys = angles.map((a) => polar(cx, cy, r, a).y) const top = Math.max(0, Math.min(...ys) - thickness / 2) const bottom = Math.min(size, Math.max(...ys) + thickness / 2) const height = bottom - top const arcs: { from: number; to: number; filled: boolean }[] = [] if (segments > 0) { const step = sweep / segments for (let i = 0; i < segments; i++) { const from = start + i * step + gap / 2 const to = start + (i + 1) * step - gap / 2 const mid = (i + 0.5) / segments arcs.push({ from, to, filled: mid <= fraction }) } } return (
{children ? (
200 ? "inset-y-0" : "bottom-0" )} > {children}
) : null}
) } export { RadialGauge } ``` # Sankey Chart A flow diagram with tinted links, colored node bars, and share labels, on shadcn's chart primitives. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/sankey-chart.json ``` Also installs `chart` from shadcn/ui if missing. npm dependencies: `recharts`. ## Usage ```tsx import { SankeyChart } from "@/components/ui/sankey-chart" `${value}h`} /> ``` ## Examples ### Default ```tsx "use client" import { SankeyChart } from "@/components/ui/sankey-chart" // Sources carry a color; the activities on the right inherit it from their largest inflow. const nodes = [ { name: "Focus", color: "var(--color-violet-500)" }, { name: "Meetings", color: "var(--color-sky-500)" }, { name: "Breaks", color: "var(--color-lime-500)" }, { name: "Admin", color: "var(--color-amber-500)" }, { name: "Learning", color: "var(--color-pink-500)" }, { name: "Browsing" }, { name: "Writing" }, { name: "Coding" }, { name: "Calls" }, { name: "Email" }, { name: "Reading" }, { name: "Planning" }, ] // Hours per week, sources on the left flowing into activities on the right. const links = [ { source: "Focus", target: "Coding", value: 8 }, { source: "Focus", target: "Writing", value: 7 }, { source: "Focus", target: "Browsing", value: 3 }, { source: "Focus", target: "Planning", value: 2 }, { source: "Meetings", target: "Calls", value: 7 }, { source: "Meetings", target: "Planning", value: 2 }, { source: "Meetings", target: "Email", value: 1 }, { source: "Breaks", target: "Browsing", value: 5 }, { source: "Breaks", target: "Reading", value: 1 }, { source: "Admin", target: "Email", value: 5 }, { source: "Admin", target: "Browsing", value: 2 }, { source: "Admin", target: "Writing", value: 1 }, { source: "Learning", target: "Reading", value: 4 }, { source: "Learning", target: "Browsing", value: 1 }, { source: "Learning", target: "Writing", value: 1 }, ] export default function SankeyChartDemo() { return (
`${value}h`} />
) } ``` ### Cash flow Income sources flowing into budget categories, with a currency formatter. ```tsx "use client" import { SankeyChart } from "@/components/ui/sankey-chart" import { formatNumber } from "@/lib/format" const nodes = [ { name: "Salary" }, { name: "Freelance" }, { name: "Dividends" }, { name: "Housing" }, { name: "Savings" }, { name: "Leisure" }, { name: "Groceries" }, { name: "Insurance" }, { name: "Transport" }, { name: "Utilities" }, ] // Monthly cash flow, income on the left and budget categories on the right. const links = [ { source: "Salary", target: "Housing", value: 2400 }, { source: "Salary", target: "Savings", value: 1200 }, { source: "Salary", target: "Groceries", value: 900 }, { source: "Salary", target: "Transport", value: 600 }, { source: "Salary", target: "Utilities", value: 500 }, { source: "Salary", target: "Insurance", value: 600 }, { source: "Freelance", target: "Leisure", value: 1100 }, { source: "Freelance", target: "Savings", value: 500 }, { source: "Freelance", target: "Insurance", value: 200 }, { source: "Dividends", target: "Savings", value: 200 }, { source: "Dividends", target: "Leisure", value: 200 }, ] export default function SankeyChartCashflowDemo() { return (
formatNumber(value, { format: "currency", maximumFractionDigits: 0 }) } nodePadding={12} />
) } ``` ## Source ### components/ui/sankey-chart.tsx ```tsx "use client" import * as React from "react" import { Sankey, type SankeyLinkProps, type SankeyNode as RechartsSankeyNode, type SankeyNodeProps, } from "recharts" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart" export interface SankeyChartNode { name: string /** Any CSS color. Sources default to the palette in order; targets inherit the color of their largest incoming link. */ color?: string } export interface SankeyChartLink { /** Node name or index into `nodes`. */ source: number | string /** Node name or index into `nodes`. */ target: number | string value: number } export interface SankeyChartProps extends Omit, "config" | "children"> { nodes: SankeyChartNode[] links: SankeyChartLink[] valueFormatter?: (value: number) => string /** Append each node's share of the total flow to its label, e.g. "Writing · 18%". */ showShares?: boolean showTooltip?: boolean nodeWidth?: number /** Vertical gap between nodes in the same column. */ nodePadding?: number /** Fill opacity of the links. Hovered links are drawn at double this, up to 1. */ linkOpacity?: number /** "outside" reserves a margin and puts labels left of sources and right of targets; "inside" draws them over the flow. */ labelPosition?: "outside" | "inside" /** Palette cycled through source nodes without a color. Defaults to chart-1 through chart-5. */ colors?: string[] } const DEFAULT_COLORS = [ "var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", ] /** Approximate rendered width of a text-xs string, for reserving label margins. */ const CHAR_WIDTH = 6.4 const LABEL_GAP = 8 interface ResolvedNode { name: string color: string /** Share of the total flow, as a fraction. */ share: number /** Which side of the node bar the label is drawn on. */ side: "left" | "right" /** Label text next to the node, share included. */ label: string shareLabel: string } interface ResolvedLink { source: number target: number value: number } function round(n: number) { return Math.round(n * 1000) / 1000 } function slugify(name: string) { return name.toLowerCase().replace(/[^a-z0-9]+/g, "-") } function SankeyChart({ nodes, links, valueFormatter = (value) => formatNumber(value), showShares = true, showTooltip = true, nodeWidth = 12, nodePadding = 16, linkOpacity = 0.25, labelPosition = "outside", colors = DEFAULT_COLORS, className, ...props }: SankeyChartProps) { const palette = colors.length ? colors : DEFAULT_COLORS const hoverOpacity = Math.min(1, linkOpacity * 2) const { data, margin, config } = React.useMemo(() => { const indexByName = new Map(nodes.map((node, index) => [node.name, index])) const resolveIndex = (ref: number | string) => typeof ref === "number" ? Number.isInteger(ref) && ref >= 0 && ref < nodes.length ? ref : -1 : (indexByName.get(ref) ?? -1) const resolvedLinks: ResolvedLink[] = links .map((link) => ({ source: resolveIndex(link.source), target: resolveIndex(link.target), value: link.value, })) .filter( (link) => link.source >= 0 && link.target >= 0 && link.source !== link.target && Number.isFinite(link.value) && link.value > 0 ) const incoming = nodes.map(() => 0) const outgoing = nodes.map(() => 0) const strongestSource = nodes.map(() => null) const strongestValue = nodes.map(() => 0) for (const link of resolvedLinks) { outgoing[link.source]! += link.value incoming[link.target]! += link.value if (link.value > strongestValue[link.target]!) { strongestValue[link.target] = link.value strongestSource[link.target] = link.source } } const isRoot = nodes.map((_, index) => incoming[index] === 0) const isLeaf = nodes.map((_, index) => outgoing[index] === 0) const values = nodes.map((_, index) => Math.max(incoming[index]!, outgoing[index]!)) const total = values.reduce((sum, value, index) => (isRoot[index] ? sum + value : sum), 0) // Roots cycle through the palette; everything else inherits its strongest source. const resolvedColors: (string | undefined)[] = nodes.map(() => undefined) let paletteIndex = 0 nodes.forEach((node, index) => { if (node.color) resolvedColors[index] = node.color else if (isRoot[index]) resolvedColors[index] = palette[paletteIndex++ % palette.length] }) const colorOf = (index: number, seen: Set): string => { const known = resolvedColors[index] if (known) return known const source = strongestSource[index] ?? null if (source !== null && !seen.has(index)) { seen.add(index) const inherited = colorOf(source, seen) resolvedColors[index] = inherited return inherited } const fallback = palette[paletteIndex++ % palette.length]! resolvedColors[index] = fallback return fallback } const resolvedNodes: ResolvedNode[] = nodes.map((node, index) => { const share = total > 0 ? values[index]! / total : 0 const shareLabel = showShares ? ` · ${formatNumber(share, { format: "percent", maximumFractionDigits: 0 })}` : "" const side: ResolvedNode["side"] = labelPosition === "outside" ? isRoot[index] && !isLeaf[index] ? "left" : "right" : isLeaf[index] && !isRoot[index] ? "left" : "right" return { name: node.name, color: colorOf(index, new Set()), share, side, label: node.name, shareLabel, } }) const labelWidth = (node: ResolvedNode) => Math.ceil((node.label.length + node.shareLabel.length) * CHAR_WIDTH) + LABEL_GAP const widest = (predicate: (index: number) => boolean) => resolvedNodes.reduce( (max, node, index) => predicate(index) && values[index]! > 0 ? Math.max(max, labelWidth(node)) : max, 0 ) const margin = labelPosition === "outside" ? { top: 4, bottom: 4, left: widest((index) => resolvedNodes[index]!.side === "left"), right: widest((index) => resolvedNodes[index]!.side === "right"), } : { top: 4, bottom: 4, left: 4, right: 4 } const config = Object.fromEntries( resolvedNodes.map((node) => [slugify(node.name), { label: node.name, color: node.color }]) ) satisfies ChartConfig return { data: { nodes: resolvedNodes, links: resolvedLinks }, margin, config } }, [nodes, links, palette, showShares, labelPosition]) const renderNode = React.useCallback((props: SankeyNodeProps) => { const { x, y, width, height, payload } = props const node = payload as unknown as RechartsSankeyNode & ResolvedNode if (!(node.value > 0) || !(height > 0)) return null const radius = Math.min(3, width / 2, height / 2) const labelX = node.side === "left" ? x - LABEL_GAP : x + width + LABEL_GAP return ( {node.label} {node.shareLabel ? ( {node.shareLabel} ) : null} ) }, []) const renderLink = React.useCallback( (props: SankeyLinkProps) => { const { sourceX, sourceY, sourceControlX, targetX, targetY, targetControlX, linkWidth, payload, } = props const source = payload.source as RechartsSankeyNode & ResolvedNode const half = Math.max(linkWidth, 0.5) / 2 const top = (yValue: number) => round(yValue - half) const bottom = (yValue: number) => round(yValue + half) const d = [ `M${round(sourceX)},${top(sourceY)}`, `C${round(sourceControlX)},${top(sourceY)} ${round(targetControlX)},${top(targetY)} ${round(targetX)},${top(targetY)}`, `L${round(targetX)},${bottom(targetY)}`, `C${round(targetControlX)},${bottom(targetY)} ${round(sourceControlX)},${bottom(sourceY)} ${round(sourceX)},${bottom(sourceY)}`, "Z", ].join(" ") return ( ) }, [linkOpacity, hoverOpacity] ) return ( {showTooltip ? ( { // Links carry resolved source/target nodes; nodes carry their own meta. const raw = item.payload as Record | undefined const source = raw?.source as (RechartsSankeyNode & ResolvedNode) | undefined const target = raw?.target as (RechartsSankeyNode & ResolvedNode) | undefined const isLink = typeof source === "object" && typeof target === "object" const label = isLink ? `${source.name} → ${target.name}` : String(raw?.name ?? name) const color = isLink ? source.color : (raw?.color as string | undefined) return (
{label} {valueFormatter(Number(value))}
) }} /> } /> ) : null}
) } export { SankeyChart } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Scatter Chart Correlation dots per series that become bubbles when a size key is set, with a per-point tooltip and legend. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/scatter-chart.json ``` Also installs `chart` from shadcn/ui if missing. npm dependencies: `recharts`. ## Usage ```tsx import { ScatterChart } from "@/components/ui/scatter-chart" ``` ## Examples ### Default ```tsx "use client" import { ScatterChart } from "@/components/ui/scatter-chart" import { formatNumber } from "@/lib/format" const free = [ { sessions: 12, revenue: 0 }, { sessions: 28, revenue: 0 }, { sessions: 41, revenue: 19 }, { sessions: 55, revenue: 0 }, { sessions: 63, revenue: 29 }, { sessions: 78, revenue: 49 }, { sessions: 94, revenue: 19 }, { sessions: 110, revenue: 79 }, ] const pro = [ { sessions: 68, revenue: 240 }, { sessions: 95, revenue: 310 }, { sessions: 132, revenue: 420 }, { sessions: 148, revenue: 380 }, { sessions: 176, revenue: 560 }, { sessions: 203, revenue: 610 }, { sessions: 231, revenue: 720 }, { sessions: 258, revenue: 690 }, ] const enterprise = [ { sessions: 210, revenue: 1_450 }, { sessions: 265, revenue: 1_820 }, { sessions: 302, revenue: 1_690 }, { sessions: 348, revenue: 2_310 }, { sessions: 390, revenue: 2_640 }, { sessions: 415, revenue: 2_480 }, ] export default function ScatterChartDemo() { return (
formatNumber(v)} yFormatter={(v) => formatNumber(v, { format: "currency", compact: true })} showLegend />
) } ``` ### Bubbles Set sizeKey to scale each point by a third value, and nameKey to title the tooltip with the point's label. ```tsx "use client" import { ScatterChart } from "@/components/ui/scatter-chart" import { formatNumber } from "@/lib/format" const paid = [ { channel: "Search ads", spend: 18_400, conversions: 620, reach: 410_000 }, { channel: "Social ads", spend: 12_900, conversions: 380, reach: 860_000 }, { channel: "Display", spend: 7_200, conversions: 140, reach: 1_250_000 }, { channel: "Sponsorships", spend: 9_800, conversions: 210, reach: 320_000 }, { channel: "Retargeting", spend: 4_600, conversions: 290, reach: 95_000 }, ] const organic = [ { channel: "SEO", spend: 3_200, conversions: 540, reach: 720_000 }, { channel: "Newsletter", spend: 1_100, conversions: 260, reach: 48_000 }, { channel: "Referrals", spend: 600, conversions: 180, reach: 22_000 }, { channel: "Community", spend: 2_400, conversions: 150, reach: 130_000 }, ] export default function ScatterChartBubblesDemo() { return (
formatNumber(v, { format: "currency", compact: true })} yFormatter={(v) => formatNumber(v)} showLegend />
) } ``` ## Source ### components/ui/scatter-chart.tsx ```tsx "use client" import * as React from "react" import { CartesianGrid, Scatter, ScatterChart as RechartsScatterChart, XAxis, YAxis, ZAxis, } from "recharts" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart" export interface ScatterSeries { /** Stable identifier. Defaults to a slug of the label. */ key?: string label: string /** Rows for this series. Each needs the x and y keys, plus the size and name keys when set. */ data: Record[] /** Any CSS color. Defaults to chart-1 through chart-5 in order. */ color?: string } export interface ScatterChartProps extends Omit, "config" | "children"> { series: ScatterSeries[] /** Key of the x value in each row. */ xKey: string /** Key of the y value in each row. */ yKey: string /** Key of a value that scales each point into a bubble. */ sizeKey?: string /** Key of a per-point label shown as the tooltip title. */ nameKey?: string /** Names for the tooltip rows. Default to the keys. */ xLabel?: string yLabel?: string sizeLabel?: string showGrid?: boolean showXAxis?: boolean showYAxis?: boolean showLegend?: boolean showTooltip?: boolean xFormatter?: (value: number) => string yFormatter?: (value: number) => string sizeFormatter?: (value: number) => string /** Radius of each point in px when sizeKey is not set. */ dotRadius?: number /** Bubble area range in px² when sizeKey is set, smallest to largest. */ sizeRange?: [number, number] /** Recharts domain for the x-axis, e.g. ["auto", "auto"]. Defaults to [0, "auto"]. */ xDomain?: React.ComponentProps["domain"] /** Recharts domain for the y-axis, e.g. ["auto", "auto"]. Defaults to [0, "auto"]. */ yDomain?: React.ComponentProps["domain"] } /** Hidden key stamped onto each row so the tooltip can find the point's series. */ const SERIES_KEY = "__scatterSeries" function slug(label: string) { return label.toLowerCase().replace(/[^a-z0-9]+/g, "-") } interface ScatterTooltipContentProps extends Pick, "active" | "payload"> { config: ChartConfig rows: { key: string; label: string; format: (value: number) => string }[] nameKey?: string } function ScatterTooltipContent({ active, payload, config, rows, nameKey, }: ScatterTooltipContentProps) { const point = payload?.[0]?.payload as Record | undefined if (!active || !point) return null const seriesKey = String(point[SERIES_KEY] ?? "") const seriesLabel = config[seriesKey]?.label ?? seriesKey const name = nameKey ? point[nameKey] : undefined const title = name != null ? String(name) : seriesLabel return (
{title} {name != null ? ( {seriesLabel} ) : null}
{rows.map((row) => (
{row.label} {row.format(Number(point[row.key]))}
))}
) } /** A scatter chart on shadcn's chart primitives: correlation dots per series, or bubbles when a size key is set. */ function ScatterChart({ series, xKey, yKey, sizeKey, nameKey, xLabel, yLabel, sizeLabel, showGrid = true, showXAxis = true, showYAxis = true, showLegend = false, showTooltip = true, xFormatter = (value) => formatNumber(value, { format: "compact" }), yFormatter = (value) => formatNumber(value, { format: "compact" }), sizeFormatter = (value) => formatNumber(value, { format: "compact" }), dotRadius = 4, sizeRange = [40, 400], xDomain, yDomain, className, ...props }: ScatterChartProps) { const resolved = React.useMemo( () => series.map((s, index) => { const key = s.key ?? slug(s.label) return { key, label: s.label, color: s.color ?? `var(--chart-${(index % 5) + 1})`, data: s.data.map((row) => ({ ...row, [SERIES_KEY]: key })), } }), [series] ) const config = Object.fromEntries( resolved.map((s) => [s.key, { label: s.label, color: s.color }]) ) satisfies ChartConfig const rows = [ { key: xKey, label: xLabel ?? xKey, format: xFormatter }, { key: yKey, label: yLabel ?? yKey, format: yFormatter }, ...(sizeKey ? [{ key: sizeKey, label: sizeLabel ?? sizeKey, format: sizeFormatter }] : []), ] // Recharts sizes symbols by area, so a fixed radius becomes a fixed area. const dotArea = Math.round(Math.PI * dotRadius * dotRadius) return ( {showGrid ? : null} {sizeKey ? ( ) : ( )} {showTooltip ? ( } /> ) : null} {showLegend ? } /> : null} {resolved.map((s) => { const color = `var(--color-${s.key})` return ( ) })} ) } export { ScatterChart } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Segmented Meter A zoned bar meter with a marker at the current value, for heart-rate zones, allocations, or thresholds. A bar meter split into zones with a marker at the current value, for heart-rate zones, portfolio allocations, or thresholds where the zone a value falls in matters more than the number. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/segmented-meter.json ``` ## Usage ```tsx import { SegmentedMeter } from "@/components/ui/segmented-meter" ``` ## Examples ### Default ```tsx "use client" import { SegmentedMeter } from "@/components/ui/segmented-meter" const zones = [ { label: "Rest", from: 60, to: 110, color: "var(--color-sky-500)" }, { label: "Fat burn", from: 110, to: 140, color: "var(--color-emerald-500)" }, { label: "Cardio", from: 140, to: 170, color: "var(--color-amber-500)" }, { label: "Peak", from: 170, to: 190, color: "var(--color-red-500)" }, ] export default function SegmentedMeterDemo() { return (
142 bpm
Heart rate zone `${v}bpm`} />
All zones, with labels
) } ``` ## Source ### components/ui/segmented-meter.tsx ```tsx import * as React from "react" import { cn } from "@/lib/utils" import { formatNumber } from "@/lib/format" export interface MeterZone { label?: string from: number to: number /** Any CSS color. Defaults to chart-1. */ color?: string } export interface SegmentedMeterProps extends React.ComponentProps<"div"> { value: number zones: MeterZone[] /** Only color the zone that contains the value; others stay muted. */ highlightActive?: boolean /** Show zone boundary values under the bar. */ showTicks?: boolean /** Show zone labels under the bar. */ showLabels?: boolean /** Show a marker at the current value. */ showMarker?: boolean tickFormatter?: (value: number) => string } function SegmentedMeter({ value, zones, highlightActive = true, showTicks = true, showLabels = false, showMarker = true, tickFormatter = (v) => formatNumber(v), className, ...props }: SegmentedMeterProps) { const min = Math.min(...zones.map((z) => z.from)) const max = Math.max(...zones.map((z) => z.to)) const span = max - min || 1 const position = Math.min(1, Math.max(0, (value - min) / span)) const activeIndex = zones.findIndex( (z, i) => value >= z.from && (value < z.to || (i === zones.length - 1 && value <= z.to)) ) return (
{zones.map((zone, index) => { const active = index === activeIndex const color = zone.color ?? "var(--chart-1)" return (
) })}
{showMarker ? ( {showTicks || showLabels ? (
{showTicks ? [min, ...zones.map((z) => z.to)].map((tick, index, all) => ( 0 && index < all.length - 1 && "-translate-x-1/2" )} style={ index > 0 && index < all.length - 1 ? { left: `${((tick - min) / span) * 100}%` } : undefined } > {tickFormatter(tick)} )) : zones.map((zone, index) => ( {zone.label} ))}
) : null}
) } export { SegmentedMeter } ``` ### lib/format.ts ```ts export type NumberFormat = "number" | "compact" | "currency" | "percent" export interface FormatNumberOptions { format?: NumberFormat /** ISO 4217 code, used when format is "currency". Defaults to USD. */ currency?: string locale?: string maximumFractionDigits?: number /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */ compact?: boolean } const formatterCache = new Map() function getFormatter(locale: string, options: Intl.NumberFormatOptions) { const key = locale + JSON.stringify(options) let formatter = formatterCache.get(key) if (!formatter) { formatter = new Intl.NumberFormat(locale, options) formatterCache.set(key, formatter) } return formatter } /** * Format a metric value for display. * * formatNumber(1234567) -> "1,234,567" * formatNumber(1234567, { format: "compact" }) -> "1.2M" * formatNumber(48.2, { format: "currency" }) -> "$48.20" * formatNumber(158143, { format: "currency", compact: true }) -> "$158K" * formatNumber(0.124, { format: "percent" }) -> "12.4%" */ export function formatNumber( value: number, { format = "number", currency = "USD", locale = "en-US", maximumFractionDigits, compact = false, }: FormatNumberOptions = {} ): string { if (!Number.isFinite(value)) return "—" // Compact values keep three significant digits: $158K, $1.23M, 41.2K. const compactOptions: Intl.NumberFormatOptions = compact ? maximumFractionDigits === undefined ? { notation: "compact", maximumSignificantDigits: 3 } : { notation: "compact", maximumFractionDigits } : {} switch (format) { case "compact": return getFormatter(locale, { notation: "compact", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) case "currency": return getFormatter(locale, { style: "currency", currency, maximumFractionDigits: maximumFractionDigits ?? 2, ...compactOptions, }).format(value) case "percent": return getFormatter(locale, { style: "percent", maximumFractionDigits: maximumFractionDigits ?? 1, }).format(value) default: return getFormatter(locale, { maximumFractionDigits: maximumFractionDigits ?? 0, ...compactOptions, }).format(value) } } /** * Format a fractional change as a signed percentage. * * formatDelta(0.124) -> "+12.4%" * formatDelta(-0.03) -> "-3.0%" * formatDelta(0) -> "0.0%" */ export function formatDelta(delta: number, locale = "en-US"): string { if (!Number.isFinite(delta)) return "—" return getFormatter(locale, { style: "percent", signDisplay: "exceptZero", maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(delta) } /** * Fractional change between two values. Returns 0 when there is no baseline. */ export function computeDelta(current: number, previous: number): number { if (!previous) return 0 return (current - previous) / Math.abs(previous) } ``` # Sparkline A tiny inline area or line chart for showing a trend at a glance. A tiny inline area or line chart with no axes, for showing a trend next to a number. It is what the KPI card and metric list use, and it is small enough to drop into a table cell. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/sparkline.json ``` npm dependencies: `recharts`. ## Usage ```tsx import { Sparkline } from "@/components/ui/sparkline" ``` ## Examples ### Default Set fill to "dots" to render the area as a dot grid that fades out toward the line. ```tsx import { Sparkline } from "@/components/ui/sparkline" const data = [12, 18, 14, 22, 26, 21, 30, 34, 28, 40, 44, 52] export default function SparklineDemo() { return (
Area
Line
Dot grid
Custom color, step curve
) } ``` ## Source ### components/ui/sparkline.tsx ```tsx "use client" import * as React from "react" import { Area, AreaChart, AreaRevealShape, Line, LineChart, ResponsiveContainer, type AreaRevealShapeProps, } from "recharts" import { cn } from "@/lib/utils" export interface SparklineProps extends React.ComponentProps<"div"> { /** Series to plot, oldest first. */ data: number[] variant?: "area" | "line" /** Area fill: a plain gradient, or a dot grid that fades out toward the line. */ fill?: "gradient" | "dots" /** Any CSS color. Defaults to the theme primary. */ color?: string curve?: "monotone" | "linear" | "step" strokeWidth?: number } /** Area shape that masks the fill to a dot grid while keeping the stroke solid. */ function DotGridAreaShape({ maskId, ...props }: AreaRevealShapeProps & { maskId: string }) { return ( <> ) } function Sparkline({ data, variant = "area", fill = "gradient", color = "var(--primary)", curve = "monotone", strokeWidth = 1.5, className, ...props }: SparklineProps) { const id = React.useId() const dots = variant === "area" && fill === "dots" const points = React.useMemo(() => data.map((y, i) => ({ i, y })), [data]) const margin = { top: 2, right: 0, bottom: 0, left: 0 } return ( ) } export { Sparkline } ``` # Tick Bar A progress bar drawn as a row of ticks, lit up to the current value. A progress bar drawn as a row of ticks lit up to the current value, for a more graphic take on progress toward a goal. Used by several of the KPI blocks. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/tick-bar.json ``` ## Usage ```tsx import { TickBar } from "@/components/ui/tick-bar" ``` ## Examples ### Default ```tsx import { TickBar } from "@/components/ui/tick-bar" export default function TickBarDemo() { return (
) } ``` ## Source ### components/ui/tick-bar.tsx ```tsx import * as React from "react" import { cn } from "@/lib/utils" export interface TickBarProps extends React.ComponentProps<"div"> { /** Current value, from 0 to max. */ value: number max?: number /** Number of ticks. */ segments?: number /** Any CSS color for the filled ticks. Defaults to chart-1. */ color?: string /** Any CSS color for the unfilled ticks. Defaults to muted. */ trackColor?: string /** "tick" draws thin bars; "pill" draws wide rounded ones. */ shape?: "tick" | "pill" } /** A progress bar drawn as a row of ticks, lit up to the current value. */ function TickBar({ value, max = 100, segments = 40, color = "var(--chart-1)", trackColor, shape = "tick", className, ...props }: TickBarProps) { const fraction = Math.min(1, Math.max(0, value / (max || 1))) // Any non-zero value lights at least one tick so small values still register. const filled = fraction === 0 ? 0 : Math.max(1, Math.round(fraction * segments)) return (
{Array.from({ length: segments }, (_, index) => { const lit = index < filled return ( ) })}
) } export { TickBar } ``` # Timeline A vertical chronology of events, each with a marker, connector, and free-form content. Use it for audit logs, activity feeds, and version history. A vertical timeline for shadcn/ui: a rail, a marker per event, and free-form content beside it. Use it for audit logs, activity feeds, deployment history, and version lists. ## Installation ```bash npx shadcn@latest add https://dashboardcn.com/r/timeline.json ``` ## Usage ```tsx import { Timeline, TimelineConnector, TimelineContent, TimelineDescription, TimelineHeader, TimelineItem, TimelineMarker, TimelineRail, TimelineTime, TimelineTitle, } from "@/components/ui/timeline" Canary rollout started Canary 09:34 Enabled for 5% of workspaces. ``` ## Examples ### Default ```tsx import { CircleCheck, Clock, Flag, Megaphone, Rocket, ShieldAlert, } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Timeline, TimelineConnector, TimelineContent, TimelineDescription, TimelineHeader, TimelineItem, TimelineMarker, TimelineRail, TimelineTime, TimelineTitle, } from "@/components/ui/timeline" const tones = { neutral: "", blue: "bg-sky-500/10 text-sky-700 dark:text-sky-400", amber: "bg-amber-500/10 text-amber-700 dark:text-amber-400", green: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", } const events = [ { icon: Flag, title: "Feature flag created", badge: "Owner assigned", tone: "neutral", time: "09:12", description: ( <> Created checkout-redesign{" "} for the billing workspace. ), }, { icon: Rocket, title: "Canary rollout started", badge: "Canary", tone: "blue", time: "09:34", description: ( <> Enabled for 5% of workspaces{" "} with session replay sampling on. ), }, { icon: ShieldAlert, title: "Regional guardrail tripped", badge: "Paused", tone: "amber", time: "09:51", description: ( <> Latency climbed in{" "} eu-central-1; rollout is holding while routing warms. ), }, { icon: Megaphone, title: "Customer messaging prepared", badge: "Docs", tone: "neutral", time: "10:05", description: ( <> Support macro and changelog draft are ready in{" "} Launch notes. ), }, { icon: Clock, title: "Launch window scheduled", badge: "Queued", tone: "neutral", time: "10:30", description: "Full rollout waits for the next error-budget sweep.", }, { icon: CircleCheck, title: "Release checklist verified", badge: "Ready", tone: "green", time: "10:42", description: "Rollback owner and dashboard checks are recorded in the release audit.", status: "current" as const, }, ] satisfies Array<{ icon: React.ElementType title: string badge: string tone: keyof typeof tones time: string description: React.ReactNode status?: "current" }> export default function TimelineDemo() { return (
Rollout audit Checkout redesign
{events.map((event) => ( {event.title} {event.badge} {event.time} {event.description} ))}
) } ``` ### Compact log Tighten the rail and content spacing with classes. Markers take any icon and inherit text color. ```tsx import { CircleCheck, CreditCard, FileText, ShieldAlert } from "lucide-react" import { cn } from "@/lib/utils" import { Timeline, TimelineConnector, TimelineContent, TimelineDescription, TimelineHeader, TimelineItem, TimelineMarker, TimelineRail, TimelineTime, TimelineTitle, } from "@/components/ui/timeline" const events = [ { icon: ShieldAlert, title: "Chargeback case opened", time: "Mar 6, 10:34 AM", dateTime: "2026-03-06T10:34", description: "The customer disputed a renewal charge, and the finance team has seven days to submit evidence.", className: "text-amber-600 dark:text-amber-400", }, { icon: CreditCard, title: "Payment captured", time: "Mar 6, 10:21 AM", dateTime: "2026-03-06T10:21", className: "text-emerald-600 dark:text-emerald-400", }, { icon: CircleCheck, title: "Payment authorized", time: "Mar 6, 10:21 AM", dateTime: "2026-03-06T10:21", }, { icon: FileText, title: "Invoice generated", time: "Mar 6, 10:20 AM", dateTime: "2026-03-06T10:20", }, ] export default function TimelineCompactDemo() { return ( {events.map((event) => ( {event.title} {event.time} {event.description ? ( {event.description} ) : null} ))} ) } ``` ### Activity feed Put an avatar in the marker and any content, including cards, inside TimelineContent. ```tsx import { ArrowUpRight } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Timeline, TimelineConnector, TimelineContent, TimelineDescription, TimelineHeader, TimelineItem, TimelineMarker, TimelineRail, TimelineTime, TimelineTitle, } from "@/components/ui/timeline" const people = { mina: { name: "Mina Sol", gradient: "from-sky-400 to-indigo-500" }, orin: { name: "Orin Vale", gradient: "from-amber-400 to-rose-500" }, paz: { name: "Paz Kim", gradient: "from-emerald-400 to-teal-500" }, } function Avatar({ person }: { person: keyof typeof people }) { return ( ) } export default function TimelineActivityDemo() { return (
Studio review Runner launch assets
Product asset uploaded Review Mina Sol{" "} 10:18 AM
Final hero crop is ready for retouch review with the side profile, lace detail, and marketplace thumbnail queued.
PDP hero 4 crops Open board
Lighting pass reviewed Orin Vale{" "} 10:27 AM The side profile reads clearly on the landing page. Keep the outsole shadow soft so the sole texture stays visible. Copy note resolved Paz Kim{" "} 10:43 AM Updated the launch tile copy and aligned the product badge with the approved campaign language. Review package ready Mina Sol{" "} 11:06 AM Exported the approved crops and shared the board with the marketplace team for final sign-off.
) } ``` ### Version history An empty marker renders a plain ring. status="current" highlights the marker and sets aria-current. ```tsx import { X } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Timeline, TimelineConnector, TimelineContent, TimelineDescription, TimelineHeader, TimelineItem, TimelineMarker, TimelineRail, TimelineTime, TimelineTitle, } from "@/components/ui/timeline" const versions = [ { version: "v8", badge: "Draft", badgeClassName: "bg-sky-500/10 text-sky-700 dark:text-sky-400", status: "current" as const, description: "Prepared billing copy, tax preview states, and the final approval checklist.", author: "Maya Chen", gradient: "from-sky-400 to-indigo-500", time: "Current", }, { version: "v7", badge: "Live", badgeClassName: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", markerClassName: "border-emerald-500", description: "Published the pricing table refresh after support macros cleared review.", author: "Nora Vazquez", gradient: "from-emerald-400 to-teal-500", time: "May 22, 2026, 11:18 AM", dateTime: "2026-05-22T11:18", }, { version: "v6", description: "Added regional tax notes and restored the upgrade confirmation banner.", author: "Eli Wong", gradient: "from-amber-400 to-rose-500", time: "May 21, 2026, 04:42 PM", dateTime: "2026-05-21T16:42", }, { version: "v5", description: "Reworked mobile spacing for plan cards and invoice preview rows.", author: "Maya Chen", gradient: "from-sky-400 to-indigo-500", time: "May 20, 2026, 09:06 AM", dateTime: "2026-05-20T09:06", }, ] export default function TimelineVersionsDemo() { return ( Version history {versions.map((item) => ( {item.version} {item.badge ? ( {item.badge} ) : null} {item.description}
))}
) } ``` ## Source ### components/ui/timeline.tsx ```tsx import * as React from "react" import { cn } from "@/lib/utils" export type TimelineStatus = "default" | "current" | "done" /** * Timeline owns only the chronology layout: an ordered list of items, each * with a rail (marker + connector) beside its content. Put your own semantic * content (headings, time, badges, avatars, cards) inside TimelineContent. */ function Timeline({ className, ...props }: React.ComponentProps<"ol">) { return (
    ) } export interface TimelineItemProps extends React.ComponentProps<"li"> { /** * "current" marks the item as the present step (sets aria-current) and * highlights its marker. "done" fills the marker. */ status?: TimelineStatus } function TimelineItem({ status = "default", className, ...props }: TimelineItemProps) { return (
  1. ) } /** The column beside the content that holds the marker and connector. */ function TimelineRail({ className, ...props }: React.ComponentProps<"div">) { return (
    ) } /** * The dot on the rail. Pass an icon, avatar, or image as children; with no * children it renders a plain ring and is hidden from assistive technology. */ function TimelineMarker({ className, children, ...props }: React.ComponentProps<"span">) { const empty = React.Children.count(children) === 0 return ( svg]:size-3 [&>img]:size-full [&>img]:object-cover", "group-data-[status=current]/timeline-item:border-primary group-data-[status=current]/timeline-item:text-primary group-data-[status=current]/timeline-item:ring-primary/20 group-data-[status=current]/timeline-item:ring-2", "group-data-[status=done]/timeline-item:bg-primary group-data-[status=done]/timeline-item:border-primary group-data-[status=done]/timeline-item:text-primary-foreground", className )} {...props} > {children} ) } /** The line between markers. Decorative; hidden on the last item. */ function TimelineConnector({ className, ...props }: React.ComponentProps<"span">) { return (