dashboardcn
blockbalance-chart-cardsource: ok · refreshed
Balance Chart Card
A balance or price card with a big value, delta, stat row, and a line chart with reference line and peak marker.
npx shadcn add "https://dashboardcn.com/r/balance-chart-card.json"free
Where it goes
pricingstats
Interaction
animated-counter
Use cases
saas-landing
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumchartsrecharts
iconslucide-react
Registry dependencies
cardcharttooltip
Files5 · source cached 10/8/2026, 4:55:51 AM
registry/dashboardcn/blocks/balance-chart-card.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatNumber, type NumberFormat } from "@/registry/dashboardcn/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ComposedChart, type ReferenceLineSpec } from "@/registry/dashboardcn/ui/composed-chart"
import { DeltaBadge } from "@/registry/dashboardcn/ui/delta-badge"
import { MetricValue } from "@/registry/dashboardcn/ui/metric-value"
export interface BalanceStat {
label: string
value: number | string
delta?: number
format?: NumberFormat
/** Any CSS color for the value, e.g. emerald for a high. */
color?: string
}
export interface BalanceChartCardProps extends React.ComponentProps<typeof Card> {
title: string
value: number
delta?: number
deltaLabel?: string
format?: NumberFormat
currency?: string
stats?: BalanceStat[]
data: Record<string, unknown>[]
xKey: string
yKey: string
seriesLabel?: string
/** Horizontal reference, e.g. previous close. */
referenceValue?: number
referenceLabel?: string
/** Mark the highest point. */
highlightMax?: boolean
chartClassName?: string
}
/** A balance or price card with a big value, stat row, and line chart. */
function BalanceChartCard({
title,
value,
delta,
deltaLabel,
format = "currency",
currency,
stats = [],
data,
xKey,
yKey,
seriesLabel = "Balance",
referenceValue,
referenceLabel,
highlightMax = true,
chartClassName,
className,
...props
}: BalanceChartCardProps) {
const yFormatter = React.useCallback(
(v: number) => formatNumber(v, { format, currency, maximumFractionDigits: 0 }),
[format, currency]
)
const referenceLines: ReferenceLineSpec[] =
referenceValue !== undefined
? [{ y: referenceValue, label: referenceLabel, dashed: true }]
: []
return (
<Card data-slot="balance-chart-card" className={cn("gap-4", className)} {...props}>
<CardHeader>
<CardDescription>{title}</CardDescription>
<CardTitle className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-3xl font-semibold tabular-nums tracking-tight">
<MetricValue value={value} format={format} currency={currency} />
{delta !== undefined ? (
<span className="flex items-center gap-1.5 text-sm font-normal">
<DeltaBadge delta={delta} variant="text" />
{deltaLabel ? (
<span className="text-muted-foreground">{deltaLabel}</span>
) : null}
</span>
) : null}
</CardTitle>
</CardHeader>
{stats.length ? (
<CardContent className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
{stats.map((stat) => (
<div key={stat.label} className="flex items-center gap-1.5">
<span className="text-muted-foreground">{stat.label}</span>
<MetricValue
value={stat.value}
format={stat.format ?? format}
currency={currency}
className="font-medium"
style={{ color: stat.color }}
/>
{stat.delta !== undefined ? (
<DeltaBadge delta={stat.delta} variant="text" showIcon={false} />
) : null}
</div>
))}
</CardContent>
) : null}
<CardContent>
<ComposedChart
data={data}
xKey={xKey}
series={[{ key: yKey, label: seriesLabel, type: "line", highlightMax }]}
referenceLines={referenceLines}
yFormatter={yFormatter}
yDomain="auto"
showYAxis
className={cn("h-56", chartClassName)}
/>
</CardContent>
</Card>
)
}
export { BalanceChartCard }
registry/dashboardcn/ui/composed-chart.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<React.ComponentProps<typeof ChartContainer>, "config" | "children"> {
data: Record<string, unknown>[]
/** 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
/** Animate series after hydration and when their data changes. Respects reduced motion. */
animate?: boolean
animationDuration?: number
/** "zero" starts the y-axis at 0; "auto" fits it to the data. */
yDomain?: "zero" | "auto" | [number, number]
}
const reducedMotionQuery = "(prefers-reduced-motion: reduce)"
function subscribeToReducedMotion(onChange: () => void) {
const query = window.matchMedia(reducedMotionQuery)
query.addEventListener("change", onChange)
return () => query.removeEventListener("change", onChange)
}
function getReducedMotionSnapshot() {
return window.matchMedia(reducedMotionQuery).matches
}
/** The server snapshot disables motion so hydration starts from identical markup. */
function getReducedMotionServerSnapshot() {
return true
}
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,
animate = true,
animationDuration = 500,
yDomain = "zero",
className,
...props
}: ComposedChartProps) {
const id = React.useId()
const reducedMotion = React.useSyncExternalStore(
subscribeToReducedMotion,
getReducedMotionSnapshot,
getReducedMotionServerSnapshot
)
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<typeof p> => p !== null)
return (
<ChartContainer
data-slot="composed-chart"
config={config}
className={cn("aspect-auto h-64 w-full", className)}
{...props}
>
<RechartsComposedChart
data={data}
margin={{ top: 8, right: hasRightAxis ? 0 : 8, bottom: 0, left: showYAxis ? 0 : 8 }}
>
<defs>
{series.map((s) => (
<React.Fragment key={s.key}>
<linearGradient id={`${id}-fill-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={`var(--color-${s.key})`} stopOpacity={0.5} />
<stop offset="95%" stopColor={`var(--color-${s.key})`} stopOpacity={0.05} />
</linearGradient>
<pattern
id={`${id}-hatch-${s.key}`}
patternUnits="userSpaceOnUse"
width={6}
height={6}
patternTransform="rotate(45)"
>
<rect width={6} height={6} fill={`var(--color-${s.key})`} fillOpacity={0.15} />
<rect width={2} height={6} fill={`var(--color-${s.key})`} />
</pattern>
</React.Fragment>
))}
</defs>
{showGrid ? <CartesianGrid vertical={false} /> : null}
<XAxis
dataKey={xKey}
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={xFormatter}
/>
<YAxis
yAxisId="left"
domain={domain}
hide={!showYAxis}
tickLine={false}
axisLine={false}
tickMargin={8}
width={44}
tickFormatter={yFormatter}
/>
{hasRightAxis ? (
<YAxis
yAxisId="right"
orientation="right"
domain={domain}
hide={!showYAxis}
tickLine={false}
axisLine={false}
tickMargin={8}
width={44}
tickFormatter={rightYFormatter ?? yFormatter}
/>
) : null}
{showTooltip ? (
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
indicator="dot"
labelFormatter={(value) => xFormatter(value)}
formatter={(value, name, item) => {
const s = series.find((entry) => entry.key === name)
const fmt = s?.axis === "right" ? (rightYFormatter ?? yFormatter) : yFormatter
return (
<div className="flex w-full items-center gap-2">
<span
className="size-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
<span className="text-muted-foreground flex-1">
{config[name as string]?.label ?? name}
</span>
<span className="font-mono font-medium tabular-nums">
{fmt ? fmt(Number(value)) : String(value)}
</span>
</div>
)
}}
/>
}
/>
) : null}
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
{series.map((s) => {
const color = `var(--color-${s.key})`
const common = {
dataKey: s.key,
yAxisId: s.axis ?? "left",
stackId: s.stackId,
isAnimationActive: animate && !reducedMotion,
animationDuration,
animationEasing: "ease-out" as const,
}
if (s.type === "bar") {
return (
<Bar
key={s.key}
{...common}
fill={s.pattern === "hatched" ? `url(#${id}-hatch-${s.key})` : color}
stroke={s.pattern === "hatched" ? color : undefined}
strokeWidth={s.pattern === "hatched" ? 1 : 0}
radius={s.stackId ? 0 : barRadius}
/>
)
}
if (s.type === "line") {
return (
<Line
key={s.key}
{...common}
type={s.curve ?? "monotone"}
stroke={color}
strokeWidth={2}
strokeDasharray={s.dashed ? "4 4" : undefined}
dot={s.dots ? { r: 3, fill: color, strokeWidth: 0 } : false}
activeDot={{ r: 4 }}
/>
)
}
return (
<Area
key={s.key}
{...common}
type={s.curve ?? "monotone"}
stroke={color}
strokeWidth={2}
strokeDasharray={s.dashed ? "4 4" : undefined}
fill={`url(#${id}-fill-${s.key})`}
dot={false}
/>
)
})}
{referenceLines.map((line, index) => (
<ReferenceLine
key={index}
y={line.y}
x={line.x}
yAxisId={line.axis ?? "left"}
stroke={line.color ?? "var(--muted-foreground)"}
strokeDasharray={line.dashed === false ? undefined : "4 4"}
label={
line.label
? {
value: line.label,
position: line.y !== undefined ? "insideTopRight" : "insideTopLeft",
fill: line.color ?? "var(--muted-foreground)",
fontSize: 11,
}
: undefined
}
/>
))}
{maxPoints.map((point) => (
<ReferenceDot
key={point.key}
x={point.x as string | number}
y={point.y}
yAxisId={point.axis}
r={4}
fill={`var(--color-${point.key})`}
stroke="var(--background)"
strokeWidth={2}
/>
))}
</RechartsComposedChart>
</ChartContainer>
)
}
export { ComposedChart }
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)
}