shadimage
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 · medium
chartsrecharts
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)
}

More from dashboardcn

Browse all components in this library →