- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Sessions by time
2,468+4.8%
MonTueWedThuFriSatSun000408121620
LessMore
Projects by area
724+8.3%
Second half
NorthSouthEastWestJulAugSepOctNovDec
LessMore
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>
);
}