shadimage
dashboardcn
blockgoal-chart-cardsource: ok · hit

Goal Chart Card

A card with a total, a period switcher, and a bar chart with a dashed goal line that colors the bars reaching it.

npx shadcn add "https://dashboardcn.com/r/goal-chart-card.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
chartsrecharts
iconslucide-react

Registry dependencies

cardcharttabstooltip

Files6 · source cached 10/9/2026, 8:11:26 AM

registry/dashboardcn/blocks/goal-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,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { BarChart, type BarChartProps, type BarRow } from "@/registry/dashboardcn/ui/bar-chart"
import { DeltaBadge } from "@/registry/dashboardcn/ui/delta-badge"
import { MetricValue } from "@/registry/dashboardcn/ui/metric-value"
import { PeriodTabs, type PeriodOption } from "@/registry/dashboardcn/ui/period-tabs"

export interface GoalChartCardProps extends React.ComponentProps<typeof Card> {
  title: string
  /** Headline number. Defaults to the sum of the rows. */
  value?: number
  format?: NumberFormat
  currency?: string
  /** Caption after the value, e.g. "total steps". */
  unit?: string
  /** Period text, e.g. "This week". Shown in the top-right corner unless periods is set. */
  caption?: string
  data: BarRow[]
  /** Key of the x-axis value in each row. */
  xKey: string
  /** Key of the bar value in each row. */
  yKey: string
  /** Target drawn as a dashed line. Bars at or above it are drawn in color. */
  goal: number
  goalLabel?: string
  /** Any CSS color for bars that reach the goal. Defaults to chart-1. */
  color?: string
  /** Any CSS color for bars below the goal. Defaults to the muted foreground. */
  mutedColor?: string
  variant?: BarChartProps["variant"]
  grid?: BarChartProps["grid"]
  xFormatter?: BarChartProps["xFormatter"]
  yFormatter?: BarChartProps["yFormatter"]
  chartClassName?: string
  /** Fractional change vs the prior period, e.g. 0.052 for +5.2%. */
  delta?: number
  /** Text after the delta, e.g. "vs last week". */
  deltaLabel?: string
  /** Treat a decrease as good. */
  invertDelta?: boolean
  /** Period options for the switcher. Omit to hide it. */
  periods?: PeriodOption[]
  period?: string
  defaultPeriod?: string
  onPeriodChange?: (period: string) => void
}

function sum(rows: BarRow[], key: string) {
  return rows.reduce((total, row) => total + (Number(row[key]) || 0), 0)
}

/** A card with a total, a period switcher, and a bar chart with a dashed goal line that colors the bars reaching it. */
function GoalChartCard({
  title,
  value,
  format = "number",
  currency,
  unit,
  caption,
  data,
  xKey,
  yKey,
  goal,
  goalLabel = "Goal",
  color,
  mutedColor,
  variant = "solid",
  grid = "none",
  xFormatter,
  yFormatter: yFormatterProp,
  chartClassName,
  delta,
  deltaLabel,
  invertDelta,
  periods,
  period,
  defaultPeriod,
  onPeriodChange,
  className,
  ...props
}: GoalChartCardProps) {
  const total = value ?? sum(data, yKey)
  const yFormatter = React.useCallback(
    (v: number) => formatNumber(v, { format, currency }),
    [format, currency]
  )
  const highlight = React.useCallback(
    (row: BarRow) => Number(row[yKey]) >= goal,
    [yKey, goal]
  )
  const showCaptionInline = Boolean(caption && periods)

  return (
    <Card data-slot="goal-chart-card" className={cn("@container/card gap-4", className)} {...props}>
      <CardHeader className="@max-sm/card:[grid-template-columns:minmax(0,1fr)]!">
        <CardDescription>{title}</CardDescription>
        <CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-3xl font-semibold tabular-nums tracking-tight">
          <MetricValue value={total} format={format} currency={currency} />
          {unit ? (
            <span className="text-muted-foreground text-sm font-normal">{unit}</span>
          ) : null}
        </CardTitle>
        {delta !== undefined || showCaptionInline ? (
          <div className="flex flex-wrap items-center gap-1.5 text-sm">
            {delta !== undefined ? (
              <DeltaBadge delta={delta} invert={invertDelta} variant="text" />
            ) : null}
            {deltaLabel || showCaptionInline ? (
              <span className="text-muted-foreground">
                {[deltaLabel, showCaptionInline ? caption : undefined]
                  .filter(Boolean)
                  .join(" · ")}
              </span>
            ) : null}
          </div>
        ) : null}
        {periods ? (
          <CardAction className="@max-sm/card:[grid-area:auto]! @max-sm/card:justify-self-start!">
            <PeriodTabs
              options={periods}
              value={period}
              defaultValue={defaultPeriod ?? periods[0]?.value}
              onValueChange={onPeriodChange}
            />
          </CardAction>
        ) : caption ? (
          <CardAction className="@max-sm/card:[grid-area:auto]! @max-sm/card:justify-self-start!">
            <span className="text-muted-foreground text-sm tabular-nums">{caption}</span>
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        <BarChart
          data={data}
          xKey={xKey}
          yKey={yKey}
          variant={variant}
          color={color}
          mutedColor={mutedColor}
          grid={grid}
          highlight={highlight}
          referenceLines={[{ y: goal, label: goalLabel, dashed: true }]}
          xFormatter={xFormatter}
          yFormatter={yFormatterProp ?? yFormatter}
          className={cn("h-56", chartClassName)}
        />
      </CardContent>
    </Card>
  )
}

export { GoalChartCard }
registry/dashboardcn/ui/bar-chart.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<string, unknown>

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<React.ComponentProps<typeof ChartContainer>, "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
  /** Animate bars after hydration and when data changes. Respects reduced motion. */
  animate?: boolean
  animationDuration?: 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
}

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
}

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)
}

/** 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,
  animate = true,
  animationDuration = 500,
  referenceLines = [],
  xFormatter = defaultXFormatter,
  yFormatter,
  tooltipLabel,
  onBarClick,
  className,
  ...props
}: BarChartProps) {
  const id = React.useId()
  const reducedMotion = React.useSyncExternalStore(
    subscribeToReducedMotion,
    getReducedMotionSnapshot,
    getReducedMotionServerSnapshot
  )
  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 (
      <g>
        <Rectangle {...shape} fill={fillFor(index)} />
        {showActiveMarker && height > 0 ? (
          <circle
            cx={x + width / 2}
            cy={y}
            r={4}
            fill="var(--background)"
            stroke={on ? color : mutedColor}
            strokeWidth={2}
          />
        ) : null}
      </g>
    )
  }

  return (
    <ChartContainer
      data-slot="bar-chart"
      config={{ [yKey]: { label: yKey, color } }}
      className={cn("aspect-auto h-64 w-full", className)}
      {...props}
    >
      <RechartsBarChart
        data={data}
        margin={{ top: 12, right: 8, bottom: 0, left: showYAxis ? 0 : 8 }}
        barCategoryGap="28%"
      >
        <defs>
          {variant === "gradient" ? (
            <>
              <linearGradient id={`${id}-on`} x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor={color} stopOpacity={1} />
                <stop offset="100%" stopColor={color} stopOpacity={0.6} />
              </linearGradient>
              <linearGradient id={`${id}-off`} x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor={mutedColor} stopOpacity={0.35} />
                <stop offset="100%" stopColor={mutedColor} stopOpacity={0.18} />
              </linearGradient>
            </>
          ) : variant === "striped" ? (
            <>
              {(["on", "off"] as const).map((state) => {
                const fill = state === "on" ? color : mutedColor
                const opacity = state === "on" ? 1 : 0.3
                return (
                  <pattern
                    key={state}
                    id={`${id}-${state}`}
                    patternUnits="userSpaceOnUse"
                    width={10}
                    height={10}
                    patternTransform="rotate(45)"
                  >
                    <rect width={10} height={10} fill={fill} fillOpacity={opacity} />
                    <rect
                      width={5}
                      height={10}
                      fill="var(--background)"
                      fillOpacity={0.25}
                    />
                  </pattern>
                )
              })}
            </>
          ) : (
            <>
              <linearGradient id={`${id}-on`}>
                <stop stopColor={color} />
              </linearGradient>
              <linearGradient id={`${id}-off`}>
                <stop stopColor={mutedColor} stopOpacity={0.3} />
              </linearGradient>
            </>
          )}
        </defs>
        {grid !== "none" ? (
          <CartesianGrid
            vertical={false}
            strokeDasharray={grid === "dashed" ? "6 6" : undefined}
          />
        ) : null}
        <XAxis
          dataKey={xKey}
          tickLine={false}
          axisLine={false}
          tickMargin={10}
          interval={tickLabels ? 0 : "preserveStartEnd"}
          minTickGap={tickLabels ? 0 : 24}
          tickFormatter={
            tickLabels ? (_, index) => tickLabels[index] ?? "" : (v) => xFormatter(v)
          }
        />
        <YAxis
          hide={!showYAxis}
          tickLine={false}
          axisLine={false}
          tickMargin={8}
          width={44}
          tickFormatter={yFormatter}
        />
        {showTooltip ? (
          <ChartTooltip
            cursor={false}
            isAnimationActive={false}
            content={({ active, payload }) => {
              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 (
                <div className="bg-background rounded-lg border px-3 py-2 text-xs shadow-md">
                  <div className="text-sm font-semibold tabular-nums">
                    {yFormatter ? yFormatter(value) : String(value)}
                  </div>
                  <div className="text-muted-foreground">
                    {tooltipLabel ? tooltipLabel(row, index) : xFormatter(row[xKey])}
                  </div>
                </div>
              )
            }}
          />
        ) : null}
        <Bar
          dataKey={yKey}
          radius={barRadius}
          isAnimationActive={animate && !reducedMotion}
          animationDuration={animationDuration}
          animationEasing="ease-out"
          activeBar={activeBar}
          onClick={
            onBarClick
              ? (_, index) => onBarClick(data[index]!, index)
              : undefined
          }
          className={onBarClick ? "cursor-pointer" : undefined}
        >
          {data.map((_, index) => (
            <Cell key={index} fill={fillFor(index)} />
          ))}
        </Bar>
        {referenceLines.map((line, index) => (
          <ReferenceLine
            key={index}
            y={line.y}
            ifOverflow="extendDomain"
            stroke={line.color ?? "var(--muted-foreground)"}
            strokeDasharray={line.dashed === false ? undefined : "4 4"}
            label={
              line.label
                ? {
                    value: line.label,
                    position: "insideTopRight",
                    fill: line.color ?? "var(--muted-foreground)",
                    fontSize: 11,
                  }
                : undefined
            }
          />
        ))}
      </RechartsBarChart>
    </ChartContainer>
  )
}

export { BarChart }
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/ui/period-tabs.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<React.ComponentProps<typeof Tabs>, "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<HTMLDivElement>(null)
  const [internalValue, setInternalValue] = React.useState(
    defaultValue ?? options[0]?.value
  )
  const active = value ?? internalValue
  const [rect, setRect] = React.useState<IndicatorRect | null>(null)

  const measure = React.useCallback(() => {
    const list = listRef.current
    if (!list) return
    const trigger = list.querySelector<HTMLElement>('[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 (
    <Tabs
      data-slot="period-tabs"
      className={cn("w-fit", className)}
      value={value}
      defaultValue={defaultValue}
      onValueChange={(next) => {
        setInternalValue(next)
        onValueChange?.(next)
      }}
      {...props}
    >
      <TabsList
        ref={listRef}
        className={cn(
          "relative",
          size === "sm" && "group-data-[orientation=horizontal]/tabs:h-8 p-1"
        )}
      >
        {animated && rect ? (
          <span
            aria-hidden
            data-slot="period-tabs-indicator"
            className="pointer-events-none absolute rounded-md bg-background shadow-sm transition-[left,top,width,height] duration-300 ease-[cubic-bezier(0.25,1,0.5,1)] motion-reduce:transition-none dark:border dark:border-input dark:bg-input/30"
            style={rect}
          />
        ) : null}
        {options.map((option) => (
          <TabsTrigger
            key={option.value}
            value={option.value}
            className={cn(
              size === "sm" && "h-6 px-2.5 text-xs",
              animated &&
                "relative z-10 data-[state=active]:bg-transparent group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:data-[state=active]:border-transparent dark:data-[state=active]:bg-transparent"
            )}
          >
            {option.label}
          </TabsTrigger>
        ))}
      </TabsList>
    </Tabs>
  )
}

export { PeriodTabs }
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 →