For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Heatmap Chart Card 01

A compact matrix for comparing values across two dimensions.

Usage

Supply rows with a label and values aligned to columns. Set color and max to adjust the scale. A static range labels the period; ranges adds a period menu and supports an onRangeChange callback. Hover or focus a cell to inspect its value. Pass popupClassName when the menu needs a scoped theme.

Rows rise in one after another and the headline counts up; picking another range replays both. Reduced motion shows the final values straight away.

Source

"use client";

import { RiArrowDownSLine, RiCalendarLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Reveal } from "@/components/ui/reveal";

export interface HeatmapRow {
  label: string;
  values: number[];
}
export interface HeatmapRange {
  id: string;
  label: string;
  rows: HeatmapRow[];
  columns?: string[];
  headline?: number;
  delta?: number;
}
export interface HeatmapChartCardProps {
  title: string;
  rows: HeatmapRow[];
  columns: string[];
  headline?: number;
  delta?: number;
  range?: string;
  ranges?: HeatmapRange[];
  onRangeChange?: (range: HeatmapRange) => void;
  color?: string;
  max?: number;
  /** Formats the headline and cell values, for example as currency. */
  format?: (value: number) => string;
  className?: string;
  popupClassName?: string;
}

/**
 * One cell of the matrix. It is memoised because the card re-renders on every
 * hovered cell, and a matrix has hundreds of them.
 */
const HeatCell = React.memo(function HeatCell({
  row,
  column,
  label,
  cellColor,
  onActive,
}: {
  row: number;
  column: number;
  label: string;
  cellColor: string;
  onActive: (cell: { row: number; column: number } | null) => void;
}) {
  return (
    <Button
      type="button"
      variant="unstyled"
      radius={4}
      onMouseDown={(event) => event.preventDefault()}
      aria-label={label}
      onMouseEnter={() => onActive({ row, column })}
      onFocus={() => onActive({ row, column })}
      onBlur={() => onActive(null)}
      className="h-full w-full"
    >
      <span
        aria-hidden="true"
        className="size-full rounded-[4px] bg-(--cell) group-hover/button:inset-ring-2 group-hover/button:inset-ring-foreground/30"
        style={{ "--cell": cellColor } as React.CSSProperties}
      />
    </Button>
  );
});

export function HeatmapChartCard({
  title,
  rows,
  columns,
  headline,
  delta,
  range,
  ranges,
  onRangeChange,
  color = "var(--funnel-chart-2, var(--primary))",
  max,
  format = (value) => value.toLocaleString(),
  className,
  popupClassName,
}: HeatmapChartCardProps) {
  const [selectedId, setSelectedId] = React.useState(ranges?.[0]?.id);
  const [active, setActive] = React.useState<{
    row: number;
    column: number;
  } | null>(null);
  const selected =
    ranges?.find((item) => item.id === selectedId) ?? ranges?.[0];
  const currentRows = selected?.rows ?? rows;
  const currentColumns = selected?.columns ?? columns;
  const currentDelta = selected?.delta ?? delta;
  const value =
    active === null
      ? undefined
      : currentRows[active.row]?.values[active.column];
  const total =
    selected?.headline ??
    headline ??
    currentRows.reduce(
      (sum, row) => sum + row.values.reduce((a, b) => a + b, 0),
      0
    );
  const maximum = Math.max(
    0,
    max ?? Math.max(0, ...currentRows.flatMap((row) => row.values))
  );
  const headlineRef = useCountUp<HTMLSpanElement>(value ?? total, { format });
  const activeLabel =
    active && value !== undefined
      ? `${currentRows[active.row]?.label} · ${currentColumns[active.column]}`
      : title;
  const fill = (number: number) =>
    `color-mix(in oklch, ${color} ${Math.round(12 + (maximum > 0 ? Math.max(0, Math.min(1, number / maximum)) : 0) * 88)}%, var(--muted))`;
  // Two matrix densities: short column sets get tall cells, long ones compact.
  const cellHeight = currentColumns.length <= 6 ? "h-10" : "h-5";
  // A cell never shrinks below its label (about 7px a character, with 8px of
  // air) or 22px; the grid scrolls inside the card instead of crowding labels.
  // Long column sets (a dozen weeks, say) label every other column and let
  // the label spill into its neighbour, so the cells still fit the card.
  const thin = currentColumns.length > 8;
  const longest = Math.max(0, ...currentColumns.map((column) => column.length));
  const cellMin = thin ? 18 : Math.max(22, longest * 7 + 8);
  const gridMinWidth = Math.max(200, currentColumns.length * cellMin + 44);
  const legend = [0, 0.25, 0.5, 0.75, 1];

  return (
    <Reveal
      variant="card"
      render={
        <Card
          role="region"
          aria-label={title}
          variant="muted"
          className={cn("@container w-full max-w-105 min-w-0", className)}
        />
      }
    >
      <CardHeader className="gap-x-3 gap-y-0">
        <CardDescription className="min-w-0 truncate">
          {activeLabel}
        </CardDescription>
        <CardTitle size="lg" className="flex items-center gap-2">
          <span ref={headlineRef}>{format(value ?? total)}</span>
          {currentDelta !== undefined && value === undefined && (
            <Badge
              variant="label"
              tone={
                currentDelta > 0
                  ? "lime"
                  : currentDelta < 0
                    ? "rose"
                    : "neutral"
              }
            >
              {currentDelta > 0 ? "+" : ""}
              {currentDelta}%
            </Badge>
          )}
        </CardTitle>
        <CardAction>
          {selected && ranges && ranges.length > 1 ? (
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    variant="outline"
                    size="sm"
                    aria-label={`Range: ${selected.label}`}
                  />
                }
              >
                <RiCalendarLine data-icon="inline-start" />
                <span className="hidden @min-[300px]/card-header:inline">
                  {selected.label}
                </span>
                <RiArrowDownSLine
                  data-icon="inline-end"
                  className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
                />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end" className={popupClassName}>
                <DropdownMenuRadioGroup
                  value={selected.id}
                  onValueChange={(id) => {
                    const item = ranges.find((entry) => entry.id === id);
                    setSelectedId(id);
                    setActive(null);
                    if (item) {
                      onRangeChange?.(item);
                    }
                  }}
                >
                  {ranges.map((item) => (
                    <DropdownMenuRadioItem
                      key={item.id}
                      value={item.id}
                      closeOnClick
                    >
                      {item.label}
                    </DropdownMenuRadioItem>
                  ))}
                </DropdownMenuRadioGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          ) : (
            (range || selected) && (
              <Badge variant="outline">
                <RiCalendarLine className="text-muted-foreground" />
                <span className="hidden @min-[300px]/card-header:inline">
                  {selected?.label ?? range}
                </span>
              </Badge>
            )
          )}
        </CardAction>
      </CardHeader>
      <CardContent className="min-w-0">
        <div
          className="min-w-0 overflow-x-auto"
          onMouseLeave={() => setActive(null)}
        >
          <div
            className="grid min-w-(--grid-min-width) grid-cols-(--grid-columns) gap-1"
            style={
              {
                "--grid-columns": `max-content repeat(${currentColumns.length}, minmax(${cellMin}px, 1fr))`,
                "--grid-min-width": `${gridMinWidth}px`,
              } as React.CSSProperties
            }
          >
            {currentRows.map((row, rowIndex) => (
              <React.Fragment key={`${row.label}-${rowIndex}`}>
                <span
                  className={cn(
                    "flex items-center pr-2 text-xs text-muted-foreground transition-colors duration-150 motion-reduce:transition-none",
                    active?.row === rowIndex && "text-foreground"
                  )}
                >
                  {row.label}
                </span>
                {currentColumns.map((column, columnIndex) => {
                  const cell = row.values[columnIndex];
                  return cell === undefined ? (
                    <span key={`${column}-${columnIndex}`} />
                  ) : (
                    <span
                      key={`${column}-${columnIndex}`}
                      className={cellHeight}
                    >
                      <HeatCell
                        row={rowIndex}
                        column={columnIndex}
                        label={`${row.label}, ${column}: ${format(cell)}`}
                        cellColor={fill(cell)}
                        onActive={setActive}
                      />
                    </span>
                  );
                })}
              </React.Fragment>
            ))}
            <span />
            {currentColumns.map((column, index) => (
              <span
                key={`${column}-${index}`}
                className={cn(
                  "pt-1 text-center text-xs whitespace-nowrap text-muted-foreground transition-colors duration-150 motion-reduce:transition-none",
                  active?.column === index && "text-foreground"
                )}
              >
                {thin && index % 2 === 1 ? null : column}
              </span>
            ))}
          </div>
        </div>
      </CardContent>
      <CardFooter className="justify-end gap-1">
        <span className="text-xs text-muted-foreground">Less</span>
        {legend.map((step) => (
          <span
            key={step}
            aria-hidden
            className="size-3 rounded-[3px] bg-(--cell) transition-colors duration-150 motion-reduce:transition-none"
            style={{ "--cell": fill(step * maximum) } as React.CSSProperties}
          />
        ))}
        <span className="text-xs text-muted-foreground">More</span>
      </CardFooter>
    </Reveal>
  );
}