shadimage
dashboardcn
blockcontributions-cardsource: ok · hit

Contributions Card

A contribution-graph card: a headline total with delta, a row of stat tiles, a period switcher, and a year of daily activity.

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

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

cardtabstooltip

Files6 · source cached 10/14/2026, 11:33:11 AM

registry/dashboardcn/blocks/contributions-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 {
  ActivityHeatmap,
  type ActivityHeatmapProps,
} from "@/registry/dashboardcn/ui/activity-heatmap"
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 ContributionsStat {
  label: string
  /** Numbers are formatted with `format`; strings render as-is, e.g. "12h 54m". */
  value: number | string
  format?: NumberFormat
  currency?: string
  /** Suffix after the number, e.g. "days". */
  unit?: string
}

export interface ContributionsCardProps
  extends Omit<React.ComponentProps<typeof Card>, "children"> {
  title: string
  /** Headline number. Defaults to the sum of `data`. */
  value?: number
  format?: NumberFormat
  currency?: string
  /** Change vs the previous period, as a fraction. */
  delta?: number
  deltaLabel?: string
  invertDelta?: boolean
  /** Tiles between the headline and the heatmap. */
  stats?: ContributionsStat[]
  /** Label above the heatmap, e.g. "Activity". */
  heatmapLabel?: string
  /** Options for the switcher next to the heatmap label. Omit to hide it. */
  periods?: PeriodOption[]
  period?: string
  defaultPeriod?: string
  onPeriodChange?: (period: string) => void
  data: ActivityHeatmapProps["data"]
  endDate?: ActivityHeatmapProps["endDate"]
  weeks?: ActivityHeatmapProps["weeks"]
  startDate?: ActivityHeatmapProps["startDate"]
  /** Any CSS color for the heatmap. Defaults to chart-1. */
  color?: ActivityHeatmapProps["color"]
  colors?: ActivityHeatmapProps["colors"]
  scale?: ActivityHeatmapProps["scale"]
  cellSize?: ActivityHeatmapProps["cellSize"]
  gap?: ActivityHeatmapProps["gap"]
  /** Label for the value in the tooltip, e.g. "commits". */
  unit?: ActivityHeatmapProps["unit"]
  weekdayLabels?: ActivityHeatmapProps["weekdayLabels"]
  showLegend?: ActivityHeatmapProps["showLegend"]
  valueFormatter?: ActivityHeatmapProps["valueFormatter"]
  onCellClick?: ActivityHeatmapProps["onCellClick"]
  /** Slot in the top-right corner, e.g. a menu button. */
  action?: React.ReactNode
}

/** A contribution-graph card: a headline total with delta, a row of stat tiles, and a year of daily activity. */
function ContributionsCard({
  title,
  value,
  format = "number",
  currency,
  delta,
  deltaLabel,
  invertDelta,
  stats,
  heatmapLabel = "Activity",
  periods,
  period,
  defaultPeriod,
  onPeriodChange,
  data,
  endDate,
  weeks,
  startDate,
  color,
  colors,
  scale,
  cellSize = 11,
  gap = 3,
  unit = "contributions",
  weekdayLabels = "none",
  showLegend = false,
  valueFormatter,
  onCellClick,
  action,
  className,
  ...props
}: ContributionsCardProps) {
  const total = React.useMemo(
    () => value ?? data.reduce((sum, datum) => sum + datum.value, 0),
    [value, data]
  )

  return (
    <Card
      data-slot="contributions-card"
      className={cn("max-w-fit gap-5", className)}
      {...props}
    >
      <CardHeader>
        <CardDescription>{title}</CardDescription>
        <CardTitle className="flex flex-wrap items-center gap-2 text-3xl font-semibold tabular-nums tracking-tight">
          <MetricValue value={total} format={format} currency={currency} />
          {delta !== undefined ? (
            <DeltaBadge delta={delta} invert={invertDelta} variant="soft" className="text-sm" />
          ) : null}
        </CardTitle>
        {deltaLabel ? <CardDescription>{deltaLabel}</CardDescription> : null}
        {action ? <CardAction>{action}</CardAction> : null}
      </CardHeader>
      <CardContent className="flex flex-col gap-5">
        {stats?.length ? (
          // The query context sits here rather than on the card so the card
          // itself can still size to the heatmap — an inline-size container
          // contributes no intrinsic width to its own box.
          <div className="@container/card">
            <dl className="grid grid-cols-2 gap-2 @md/card:grid-cols-4">
              {stats.map((stat) => (
                <div
                  key={stat.label}
                  className="flex min-w-0 flex-col gap-0.5 rounded-lg border px-3 py-2.5"
                >
                  <dd className="order-1 truncate text-lg font-semibold tabular-nums tracking-tight">
                    {typeof stat.value === "number"
                      ? formatNumber(stat.value, {
                          format: stat.format,
                          currency: stat.currency,
                          maximumFractionDigits: stat.format ? undefined : 1,
                        })
                      : stat.value}
                    {stat.unit ? (
                      <span className="text-muted-foreground ml-1 text-sm font-normal">
                        {stat.unit}
                      </span>
                    ) : null}
                  </dd>
                  <dt className="text-muted-foreground order-2 truncate text-xs">{stat.label}</dt>
                </div>
              ))}
            </dl>
          </div>
        ) : null}
        <div className="flex flex-col gap-3">
          {heatmapLabel || periods?.length ? (
            <div className="flex flex-wrap items-center justify-between gap-3">
              {heatmapLabel ? (
                <span className="text-sm font-medium">{heatmapLabel}</span>
              ) : null}
              {periods?.length ? (
                <PeriodTabs
                  options={periods}
                  value={period}
                  defaultValue={defaultPeriod}
                  onValueChange={onPeriodChange}
                />
              ) : null}
            </div>
          ) : null}
          <ActivityHeatmap
            data={data}
            endDate={endDate}
            weeks={weeks}
            startDate={startDate}
            color={color}
            colors={colors}
            scale={scale}
            cellSize={cellSize}
            gap={gap}
            unit={unit}
            weekdayLabels={weekdayLabels}
            showLegend={showLegend}
            valueFormatter={valueFormatter}
            onCellClick={onCellClick}
          />
        </div>
      </CardContent>
    </Card>
  )
}

export { ContributionsCard }
registry/dashboardcn/ui/activity-heatmap.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/registry/dashboardcn/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<HTMLDivElement>(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 (
    <div
      data-slot="activity-heatmap"
      className={cn("flex min-w-0 flex-col gap-2 text-xs", className)}
      {...props}
    >
      <div className="flex min-w-0">
        {hasWeekdays ? (
          <div
            aria-hidden
            className="grid shrink-0 pr-1"
            style={{
              gap,
              gridTemplateRows: `${hasHeader ? `${fontSize}px` : "0px"} repeat(7, ${cellSize}px)`,
            }}
          >
            <div />
            {WEEKDAYS.map((day, index) => (
              <div
                key={day}
                className="text-muted-foreground flex items-center leading-none whitespace-nowrap"
                style={{ fontSize }}
              >
                {weekdayLabels === "all" || index % 2 === 1 ? day : ""}
              </div>
            ))}
          </div>
        ) : null}
        <div
          ref={scrollRef}
          className="min-w-0 flex-1 overflow-x-auto overscroll-x-contain [scrollbar-width:thin] [scrollbar-color:color-mix(in_oklab,var(--foreground)_20%,transparent)_transparent]"
        >
          <TooltipProvider delayDuration={100} skipDelayDuration={0}>
            <div
              role="group"
              aria-label="Activity heatmap"
              className="grid w-max"
              style={{
                gap,
                gridTemplateColumns: `repeat(${columns.length}, ${cellSize}px)`,
                gridTemplateRows: `${hasHeader ? `${fontSize}px` : "0px"} repeat(7, ${cellSize}px)`,
              }}
            >
              {hasHeader
                ? months.map((month) => (
                    <div
                      key={`${month.from}-${month.label}`}
                      className="text-muted-foreground self-end leading-none whitespace-nowrap"
                      style={{
                        gridRow: 1,
                        gridColumn: `${month.from + 1} / ${month.to + 2}`,
                        fontSize,
                      }}
                    >
                      {month.label}
                    </div>
                  ))
                : null}

              {columns.map((week, weekIndex) =>
                week.map((day, dayIndex) => {
                  const style = {
                    gridColumn: weekIndex + 1,
                    gridRow: dayIndex + 2,
                  }
                  if (!day) {
                    return <div key={dayIndex} aria-hidden style={style} />
                  }
                  const value = values.get(toKey(day)) ?? 0
                  const level = levelFor(value, max, levels, scale)
                  const datum = { date: day, value }
                  const body = renderTooltip ? (
                    renderTooltip(datum)
                  ) : (
                    <>
                      <span className="font-medium tabular-nums">
                        {valueFormatter(value)} {unit}
                      </span>
                      <span className="text-background/70 block">
                        {dateFormatter.format(day)}
                      </span>
                    </>
                  )
                  const label = `${valueFormatter(value)} ${unit} on ${dateFormatter.format(day)}`

                  const cell = (
                    <button
                      type="button"
                      aria-label={label}
                      data-level={level}
                      data-date={toKey(day)}
                      onClick={onCellClick ? () => onCellClick(datum) : undefined}
                      className={cn(
                        "focus-visible:ring-ring/60 ring-inset rounded-[2px] outline-none transition-[filter] duration-100 hover:brightness-110 focus-visible:ring-2 dark:hover:brightness-125",
                        "inset-ring-foreground/6 inset-ring",
                        onCellClick ? "cursor-pointer" : "cursor-default"
                      )}
                      style={{ ...style, backgroundColor: palette[level] }}
                    />
                  )

                  if (body === null) {
                    return <React.Fragment key={dayIndex}>{cell}</React.Fragment>
                  }

                  return (
                    <Tooltip key={dayIndex}>
                      <TooltipTrigger asChild>{cell}</TooltipTrigger>
                      <TooltipContent sideOffset={4} className="text-left">
                        {body}
                      </TooltipContent>
                    </Tooltip>
                  )
                })
              )}
            </div>
          </TooltipProvider>
        </div>
      </div>

      {showLegend ? (
        <div
          className="text-muted-foreground flex items-center justify-end gap-1"
          style={{ fontSize }}
        >
          <span className="mr-0.5">Less</span>
          {palette.map((fill, level) => (
            <span
              key={level}
              className="inset-ring-foreground/6 inset-ring rounded-[2px]"
              style={{
                width: cellSize,
                height: cellSize,
                backgroundColor: fill,
              }}
            />
          ))}
          <span className="ml-0.5">More</span>
        </div>
      ) : null}
    </div>
  )
}

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