shadimage
dashboardcn
blockheatmap-chart-cardsource: ok · hit

Heatmap Chart Card

A card with a headline total, a delta, an optional period switcher, and a matrix heatmap whose hovered cell swaps into the headline.

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

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

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

cardtooltiptabs

Files6 · source cached 10/9/2026, 10:43:16 PM

registry/dashboardcn/blocks/heatmap-chart-card.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { type NumberFormat } from "@/registry/dashboardcn/lib/format"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { DeltaBadge } from "@/registry/dashboardcn/ui/delta-badge"
import {
  HeatmapChart,
  type HeatmapCell,
  type HeatmapChartProps,
} from "@/registry/dashboardcn/ui/heatmap-chart"
import { MetricValue } from "@/registry/dashboardcn/ui/metric-value"
import { PeriodTabs, type PeriodOption } from "@/registry/dashboardcn/ui/period-tabs"

export interface HeatmapChartCardProps
  extends Omit<React.ComponentProps<typeof Card>, "children">,
    Pick<
      HeatmapChartProps,
      "rows" | "columns" | "color" | "max" | "scale" | "unit" | "valueFormatter"
    > {
  title: string
  /** Headline value. Defaults to the sum of every cell. Strings render as-is. */
  value?: number | string
  /** Fractional change, e.g. 0.052 for +5.2%. */
  delta?: number
  /** Context under the headline, e.g. "vs previous 7 days". */
  deltaLabel?: string
  /** Treat a decrease as good (churn, latency, errors). */
  invertDelta?: boolean
  format?: NumberFormat
  currency?: string
  /** Period options for the switcher. Omit to hide it. */
  periods?: PeriodOption[]
  period?: string
  defaultPeriod?: string
  onPeriodChange?: (period: string) => void
  chartClassName?: string
}

/**
 * A card with a headline total, a delta, an optional period switcher, and a matrix heatmap.
 * Hovering a cell swaps the headline to that cell's value and names the cell under it.
 */
function HeatmapChartCard({
  title,
  value,
  delta,
  deltaLabel,
  invertDelta,
  format,
  currency,
  unit,
  valueFormatter,
  rows,
  columns,
  color,
  max,
  scale,
  periods,
  period,
  defaultPeriod,
  onPeriodChange,
  chartClassName,
  className,
  ...props
}: HeatmapChartCardProps) {
  const [active, setActive] = React.useState<HeatmapCell | null>(null)

  const total = React.useMemo(
    () =>
      rows.reduce(
        (sum, row) => row.values.reduce((s, v) => s + (Number(v) || 0), sum),
        0
      ),
    [rows]
  )

  const hoveredRow = active ? rows[active.row] : undefined
  const hovered =
    active && hoveredRow
      ? {
          value: hoveredRow.values[active.column] ?? 0,
          label: `${hoveredRow.label} · ${columns[active.column] ?? ""}`,
        }
      : null

  return (
    <Card data-slot="heatmap-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 items-baseline gap-2 text-3xl font-semibold tabular-nums tracking-tight">
          <MetricValue
            value={hovered ? hovered.value : (value ?? total)}
            format={format}
            currency={currency}
          />
          {delta !== undefined && !hovered ? (
            <DeltaBadge delta={delta} invert={invertDelta} />
          ) : null}
        </CardTitle>
        {/* Reserved even when empty so the caption does not shift the chart while hovering. */}
        <div
          data-slot="heatmap-chart-card-caption"
          className="text-muted-foreground min-h-5 text-sm"
        >
          {hovered ? hovered.label : deltaLabel}
        </div>
        {periods?.length ? (
          <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>
        ) : null}
      </CardHeader>
      <CardContent>
        <HeatmapChart
          rows={rows}
          columns={columns}
          color={color}
          max={max}
          scale={scale}
          unit={unit}
          valueFormatter={valueFormatter}
          activeCell={active}
          onActiveCellChange={setActive}
          className={chartClassName}
        />
      </CardContent>
    </Card>
  )
}

export { HeatmapChartCard }
registry/dashboardcn/ui/heatmap-chart.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 HeatmapRow {
  label: string
  /** One value per column, in column order. Missing entries read as 0. */
  values: number[]
}

/** A cell's position by row and column index. */
export interface HeatmapCell {
  row: number
  column: number
}

export interface HeatmapDatum extends HeatmapCell {
  rowLabel: string
  columnLabel: string
  value: number
}

export interface HeatmapChartProps extends React.ComponentProps<"div"> {
  /** One row per line of the matrix, top to bottom. */
  rows: HeatmapRow[]
  /** Column labels, left to right. Sets the number of columns. */
  columns: string[]
  /** Any CSS color. Cells mix this into `--muted` by value. Defaults to chart-1. */
  color?: string
  /** Value drawn fully saturated. Defaults to the largest value. */
  max?: number
  /** How values map to color. `sqrt` spreads out small values. */
  scale?: "linear" | "sqrt"
  /** Quantize the fill into this many steps instead of a continuous ramp. */
  levels?: number
  /** Gap between cells in px. Cells themselves stretch to fill the container. */
  gap?: number
  valueFormatter?: (value: number) => string
  /** Label for the value in the tooltip, e.g. "sessions". */
  unit?: string
  showLegend?: boolean
  showRowLabels?: boolean
  showColumnLabels?: boolean
  /** Show every nth column label. Defaults to whatever keeps about 12 labels. */
  columnLabelEvery?: number
  /** Replace the tooltip body. Return `null` to hide it for that cell. */
  renderTooltip?: (datum: HeatmapDatum) => React.ReactNode
  onCellClick?: (datum: HeatmapDatum) => void
  /** The hovered cell, to control it from outside. */
  activeCell?: HeatmapCell | null
  /** Called when the hovered cell changes, with null when the pointer leaves. */
  onActiveCellChange?: (cell: HeatmapCell | null) => void
}

/** Where a value sits between the muted track (0) and the accent (1). */
function fractionFor(
  value: number,
  max: number,
  scale: "linear" | "sqrt",
  levels?: number
) {
  if (value <= 0 || max <= 0) return 0
  let t = Math.min(1, value / max)
  if (scale === "sqrt") t = Math.sqrt(t)
  if (levels && levels > 1) {
    t = Math.max(1, Math.ceil(t * (levels - 1))) / (levels - 1)
  }
  return t
}

function fillFor(color: string, fraction: number) {
  if (fraction <= 0) return "var(--muted)"
  // Floor the mix so the smallest non-zero values still read against the track.
  const pct = Math.round(12 + fraction * 88)
  return `color-mix(in oklab, ${color} ${pct}%, var(--muted))`
}

/**
 * A matrix heatmap with a row per line and a column per label, e.g. weekday by hour.
 * Cells stretch to the container width. Hovering a cell rings it, lights up its row
 * and column labels, and shows its value.
 */
function HeatmapChart({
  rows,
  columns,
  color = "var(--chart-1)",
  max,
  scale = "linear",
  levels,
  gap = 4,
  valueFormatter = (value) => formatNumber(value),
  unit,
  showLegend = true,
  showRowLabels = true,
  showColumnLabels = true,
  columnLabelEvery,
  renderTooltip,
  onCellClick,
  activeCell,
  onActiveCellChange,
  className,
  ...props
}: HeatmapChartProps) {
  const [internal, setInternal] = React.useState<HeatmapCell | null>(null)
  const active = activeCell === undefined ? internal : activeCell
  const setActive = (cell: HeatmapCell | null) => {
    setInternal(cell)
    onActiveCellChange?.(cell)
  }

  const top = React.useMemo(
    () =>
      max ??
      rows.reduce(
        (acc, row) => row.values.reduce((m, v) => Math.max(m, v), acc),
        0
      ),
    [rows, max]
  )

  const every = columnLabelEvery ?? Math.max(1, Math.ceil(columns.length / 12))
  const steps = levels && levels > 1 ? levels : 5
  const swatches = Array.from({ length: steps }, (_, i) =>
    fillFor(color, i / (steps - 1))
  )

  const withUnit = (value: number) =>
    unit ? `${valueFormatter(value)} ${unit}` : valueFormatter(value)

  return (
    <div
      data-slot="heatmap-chart"
      className={cn("flex min-w-0 flex-col gap-2 text-xs", className)}
      {...props}
    >
      <TooltipProvider delayDuration={100} skipDelayDuration={0}>
        <div
          role="group"
          aria-label="Heatmap"
          className="grid min-w-0"
          style={{
            gap,
            gridTemplateColumns: `${showRowLabels ? "auto " : ""}repeat(${columns.length}, minmax(0, 1fr))`,
          }}
          onPointerLeave={() => setActive(null)}
        >
          {rows.map((row, r) => (
            <React.Fragment key={r}>
              {showRowLabels ? (
                <div
                  data-slot="heatmap-chart-row-label"
                  data-active={active?.row === r}
                  onPointerEnter={() => setActive(null)}
                  className="text-muted-foreground data-[active=true]:text-foreground flex items-center pr-1.5 leading-none whitespace-nowrap transition-colors"
                >
                  {row.label}
                </div>
              ) : null}
              {columns.map((column, c) => {
                const value = row.values[c] ?? 0
                const datum: HeatmapDatum = {
                  row: r,
                  column: c,
                  rowLabel: row.label,
                  columnLabel: column,
                  value,
                }
                const isActive = active?.row === r && active?.column === c
                const body = renderTooltip ? (
                  renderTooltip(datum)
                ) : (
                  <>
                    <span className="font-medium tabular-nums">{withUnit(value)}</span>
                    <span className="text-background/70 block">
                      {row.label} · {column}
                    </span>
                  </>
                )

                const cell = (
                  <button
                    type="button"
                    aria-label={`${withUnit(value)}, ${row.label} ${column}`}
                    data-slot="heatmap-chart-cell"
                    data-row={r}
                    data-column={c}
                    data-active={isActive}
                    onPointerEnter={() => setActive({ row: r, column: c })}
                    onFocus={() => setActive({ row: r, column: c })}
                    onBlur={(event) => {
                      // Keep the highlight while focus moves between cells so the readout does not flash.
                      const next = event.relatedTarget
                      if (next instanceof HTMLElement && next.dataset.slot === "heatmap-chart-cell") return
                      setActive(null)
                    }}
                    onClick={onCellClick ? () => onCellClick(datum) : undefined}
                    className={cn(
                      "inset-ring-foreground/6 aspect-square w-full rounded-sm outline-none inset-ring transition-[box-shadow] duration-100",
                      "data-[active=true]:inset-ring-foreground/45 data-[active=true]:inset-ring-2",
                      "focus-visible:inset-ring-ring focus-visible:inset-ring-2",
                      onCellClick ? "cursor-pointer" : "cursor-default"
                    )}
                    style={{
                      backgroundColor: fillFor(
                        color,
                        fractionFor(value, top, scale, levels)
                      ),
                    }}
                  />
                )

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

                return (
                  <Tooltip key={c}>
                    <TooltipTrigger asChild>{cell}</TooltipTrigger>
                    <TooltipContent sideOffset={4} className="text-left">
                      {body}
                    </TooltipContent>
                  </Tooltip>
                )
              })}
            </React.Fragment>
          ))}

          {showColumnLabels ? (
            <>
              {showRowLabels ? <div aria-hidden /> : null}
              {columns.map((column, c) => (
                <div
                  key={c}
                  data-slot="heatmap-chart-column-label"
                  data-active={active?.column === c}
                  onPointerEnter={() => setActive(null)}
                  className="text-muted-foreground data-[active=true]:text-foreground min-w-0 pt-0.5 text-center leading-none whitespace-nowrap transition-colors"
                >
                  {c % every === 0 ? column : ""}
                </div>
              ))}
            </>
          ) : null}
        </div>
      </TooltipProvider>

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

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