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