dashboardcn
blockbreakdown-cardsource: ok · hit
Breakdown Card
A total with delta and a list of contributors, each with a value and a hatched bar showing its share.
npx shadcn add "https://dashboardcn.com/r/breakdown-card.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
cardtooltip
Files4 · source cached 10/15/2026, 8:17:50 AM
registry/dashboardcn/blocks/breakdown-card.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { type NumberFormat } from "@/registry/dashboardcn/lib/format"
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { DeltaBadge } from "@/registry/dashboardcn/ui/delta-badge"
import { MetricValue } from "@/registry/dashboardcn/ui/metric-value"
export interface BreakdownCardItem {
name: string
value: number
/** Any CSS color for the bar. Defaults to chart-1 through chart-5 in order. */
color?: string
}
export interface BreakdownCardProps extends React.ComponentProps<typeof Card> {
title: string
/** Slot in the top-right corner, e.g. a menu button. */
action?: React.ReactNode
items: BreakdownCardItem[]
/** Headline total. Defaults to the sum of the items. */
total?: number
/** Fractional change vs the prior period, e.g. 0.15 for +15%. */
delta?: number
/** Treat a decrease as good. */
invertDelta?: boolean
format?: NumberFormat
currency?: string
/** Bars fill relative to this. Defaults to the total. */
max?: number
/** Fill the bars with diagonal stripes or a solid color. */
pattern?: "hatched" | "solid"
}
const defaultColors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
]
/** A total with delta and a list of contributors, each with a value and a hatched bar showing its share. */
function BreakdownCard({
title,
action,
items,
total,
delta,
invertDelta,
format = "currency",
currency = "USD",
max,
pattern = "hatched",
className,
...props
}: BreakdownCardProps) {
const sum = total ?? items.reduce((acc, item) => acc + item.value, 0)
const scale = max ?? sum
return (
<Card data-slot="breakdown-card" className={cn("gap-5", className)} {...props}>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
{action ? <CardAction>{action}</CardAction> : null}
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="flex flex-wrap items-center gap-3">
<MetricValue
value={sum}
format={format}
currency={currency}
maximumFractionDigits={0}
className="text-4xl font-semibold tracking-tight"
/>
{delta !== undefined ? (
<DeltaBadge delta={delta} invert={invertDelta} variant="soft" className="rounded-full px-2 py-1" />
) : null}
</div>
<ul className="flex flex-col gap-6 border-t pt-5">
{items.map((item, index) => {
const color = item.color ?? defaultColors[index % defaultColors.length]
const fraction = Math.min(1, Math.max(0, item.value / (scale || 1)))
return (
<li key={item.name} data-slot="breakdown-card-item" className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-4 text-sm">
<span className="text-muted-foreground min-w-0 truncate">{item.name}</span>
<MetricValue
value={item.value}
format={format}
currency={currency}
maximumFractionDigits={0}
className="shrink-0 font-semibold"
/>
</div>
<div
role="progressbar"
aria-label={item.name}
aria-valuemin={0}
aria-valuemax={scale}
aria-valuenow={item.value}
className="bg-muted h-2.5 w-full overflow-hidden rounded-full"
>
<div
data-pattern={pattern}
className="h-full rounded-full transition-[width]"
style={{
width: `${fraction * 100}%`,
backgroundColor:
pattern === "hatched"
? `color-mix(in oklab, ${color} 35%, transparent)`
: color,
backgroundImage:
pattern === "hatched"
? `repeating-linear-gradient(135deg, ${color} 0 2px, transparent 2px 5px)`
: undefined,
}}
/>
</div>
</li>
)
})}
</ul>
</CardContent>
</Card>
)
}
export { BreakdownCard }
registry/dashboardcn/ui/delta-badge.tsx
import * as React from "react"
import { Minus, TrendingDown, TrendingUp } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatDelta } from "@/registry/dashboardcn/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<DeltaDirection, React.ElementType> = {
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 (
<span
data-slot="delta-badge"
data-direction={direction}
data-positive={positive}
className={cn(
"inline-flex items-center gap-1 text-xs font-medium tabular-nums",
variant === "outline" && "rounded-md border px-1.5 py-0.5",
variant === "soft" && "rounded-md px-1.5 py-0.5",
variant === "soft" && positive === true && "bg-emerald-500/10",
variant === "soft" && positive === false && "bg-red-500/10",
variant === "soft" && positive === null && "bg-muted",
positive === true && "text-emerald-600 dark:text-emerald-400",
positive === false && "text-red-600 dark:text-red-400",
positive === null && "text-muted-foreground",
className
)}
{...props}
>
{showIcon ? <Icon className="size-3" aria-hidden="true" /> : null}
{children ?? formatDelta(delta)}
</span>
)
}
export { DeltaBadge }
registry/dashboardcn/ui/metric-value.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatNumber, type NumberFormat } from "@/registry/dashboardcn/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<React.ComponentProps<"span">, "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 (
<span data-slot="metric-value" className={classes} {...props}>
{value}
{suffix}
</span>
)
}
const full = formatNumber(value, { format, currency, maximumFractionDigits })
const abbreviate = format !== "percent" && Math.abs(value) >= compactFrom
if (!abbreviate) {
return (
<span data-slot="metric-value" className={classes} {...props}>
{full}
{suffix}
</span>
)
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
data-slot="metric-value"
data-compact=""
className={cn(classes, "cursor-default")}
{...props}
>
{formatNumber(value, { format, currency, compact: true })}
{suffix}
</span>
</TooltipTrigger>
<TooltipContent className="tabular-nums">
{full}
{suffix}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export { MetricValue }
registry/dashboardcn/lib/format.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<string, Intl.NumberFormat>()
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)
}