Loading home page
Divide a single box into proportionally sized, color-coded tiles.
Edit labels and values to reshape the tiles.
Add the color tokens and the motion dependency, then copy the source.
Eight categorical colors that adapt to light and dark mode.
:root {
--chart-1: oklch(0.5 0.19 292);
--chart-2: oklch(0.53 0.11 195);
--chart-3: oklch(0.55 0.13 75);
--chart-4: oklch(0.51 0.16 255);
--chart-5: oklch(0.53 0.18 20);
--chart-6: oklch(0.54 0.14 150);
--chart-7: oklch(0.52 0.14 230);
--chart-8: oklch(0.55 0.15 45);
}
.dark {
--chart-1: oklch(0.66 0.17 292);
--chart-2: oklch(0.7 0.1 195);
--chart-3: oklch(0.75 0.12 75);
--chart-4: oklch(0.67 0.14 255);
--chart-5: oklch(0.68 0.16 20);
--chart-6: oklch(0.71 0.13 150);
--chart-7: oklch(0.69 0.13 230);
--chart-8: oklch(0.73 0.14 45);
}Powers the tile entrance and hover motion.
bun add framer-motionSave as components/tile-treemap.utils.ts.
export type TreemapRect = {
x: number;
y: number;
width: number;
height: number;
};
type WeightedItem = {
index: number;
area: number;
};
const EMPTY_RECT: TreemapRect = { x: 0, y: 0, width: 0, height: 0 };
export function toPercentages(values: number[]): number[] {
const total = values.reduce((sum, value) => sum + Math.max(value, 0), 0);
if (total <= 0) return values.map(() => 0);
return values.map((value) => (Math.max(value, 0) / total) * 100);
}
export function squarify(values: number[], width: number, height: number): TreemapRect[] {
const rects = values.map(() => ({ ...EMPTY_RECT }));
const total = values.reduce((sum, value) => sum + Math.max(value, 0), 0);
if (total <= 0 || width <= 0 || height <= 0) return rects;
const scale = (width * height) / total;
const items: WeightedItem[] = values
.map((value, index) => ({ index, area: Math.max(value, 0) * scale }))
.filter((item) => item.area > 0)
.sort((a, b) => b.area - a.area);
let frame = { x: 0, y: 0, width, height };
let row: WeightedItem[] = [];
let cursor = 0;
while (cursor < items.length) {
const item = items[cursor];
if (!item) break;
if (row.length === 0) {
row.push(item);
cursor += 1;
continue;
}
const side = Math.min(frame.width, frame.height);
const currentWorst = worstAspectRatio(
row.map((entry) => entry.area),
side,
);
const nextWorst = worstAspectRatio([...row.map((entry) => entry.area), item.area], side);
if (nextWorst <= currentWorst) {
row.push(item);
cursor += 1;
continue;
}
frame = placeRow(row, frame, rects);
row = [];
}
if (row.length > 0) placeRow(row, frame, rects);
return rects;
}
function worstAspectRatio(areas: number[], side: number): number {
const rowArea = areas.reduce((sum, area) => sum + area, 0);
if (rowArea <= 0 || side <= 0) return Number.POSITIVE_INFINITY;
const thickness = rowArea / side;
let worst = 1;
for (const area of areas) {
const length = area / thickness;
if (length <= 0) return Number.POSITIVE_INFINITY;
worst = Math.max(worst, thickness / length, length / thickness);
}
return worst;
}
function placeRow(row: WeightedItem[], frame: TreemapRect, rects: TreemapRect[]): TreemapRect {
const rowArea = row.reduce((sum, item) => sum + item.area, 0);
const horizontal = frame.width >= frame.height;
const side = horizontal ? frame.height : frame.width;
const thickness = rowArea / side;
let offset = 0;
for (const item of row) {
const length = item.area / thickness;
rects[item.index] = horizontal
? { x: frame.x, y: frame.y + offset, width: thickness, height: length }
: { x: frame.x + offset, y: frame.y, width: length, height: thickness };
offset += length;
}
return horizontal
? {
x: frame.x + thickness,
y: frame.y,
width: Math.max(frame.width - thickness, 0),
height: frame.height,
}
: {
x: frame.x,
y: frame.y + thickness,
width: frame.width,
height: Math.max(frame.height - thickness, 0),
};
}
Save as components/tile-treemap.tsx.
"use client";
import { motion, useReducedMotion } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { squarify, toPercentages } from "./tile-treemap.utils";
export type TileTreemapItem = {
label: string;
value: number;
color?: string;
};
export type TileTreemapProps = {
data: TileTreemapItem[];
height?: number;
className?: string;
valueFormatter?: (value: number, percentage: number) => string;
hoverScale?: number;
};
const CHART_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-6)",
"var(--chart-7)",
"var(--chart-8)",
];
const DEFAULT_ASPECT_RATIO = "16 / 10";
const LABEL_MIN_WIDTH = 64;
const LABEL_MIN_HEIGHT = 40;
const PERCENT_MIN_WIDTH = 48;
const PERCENT_MIN_HEIGHT = 28;
export function TileTreemap({
data,
height,
className,
valueFormatter,
hoverScale = 1.01,
}: TileTreemapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
const reduceMotion = useReducedMotion();
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const observer = new ResizeObserver(() => {
setSize((current) =>
current.width === element.clientWidth && current.height === element.clientHeight
? current
: { width: element.clientWidth, height: element.clientHeight },
);
});
observer.observe(element);
return () => observer.disconnect();
}, []);
const { rects, percentages } = useMemo(() => {
const values = data.map((item) => item.value);
return {
rects: squarify(values, size.width, size.height),
percentages: toPercentages(values),
};
}, [data, size.width, size.height]);
if (data.length === 0 || !data.some((item) => item.value > 0)) {
return (
<div
className={cn(
"bg-muted/40 text-muted-foreground flex min-h-40 items-center justify-center rounded-lg border text-sm",
className,
)}
>
No data to display.
</div>
);
}
return (
<div
ref={containerRef}
className={cn("relative w-full", className)}
style={height ? { height } : { aspectRatio: DEFAULT_ASPECT_RATIO }}
>
{size.width > 0 &&
size.height > 0 &&
data.map((item, index) => {
const rect = rects[index];
if (!rect || rect.width <= 0 || rect.height <= 0) return null;
const percentage = percentages[index] ?? 0;
const color = item.color ?? CHART_COLORS[index % CHART_COLORS.length];
const formatted = valueFormatter
? valueFormatter(item.value, percentage)
: `${percentage.toFixed(1)}%`;
const showLabel = rect.width >= LABEL_MIN_WIDTH && rect.height >= LABEL_MIN_HEIGHT;
const showPercentage =
rect.width >= PERCENT_MIN_WIDTH && rect.height >= PERCENT_MIN_HEIGHT;
return (
<motion.div
key={`${item.label}-${index}`}
aria-label={`${item.label}: ${item.value} (${percentage.toFixed(1)}%)`}
className="group absolute p-1 focus-visible:outline-none"
role="img"
style={{ left: rect.x, top: rect.y, width: rect.width, height: rect.height }}
tabIndex={0}
initial={reduceMotion ? false : { opacity: 0, scale: 0.92 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: 0.3,
ease: [0.22, 1, 0.36, 1],
delay: Math.min(index, 12) * 0.03,
},
}}
whileHover={
reduceMotion ? undefined : { scale: hoverScale, transition: { duration: 0.18 } }
}
whileFocus={
reduceMotion ? undefined : { scale: hoverScale, transition: { duration: 0.18 } }
}
>
<div
className="group-focus-visible:ring-ring/60 flex size-full flex-col justify-end overflow-hidden rounded-lg p-2 transition-[filter] duration-150 ease-out group-hover:brightness-110 group-focus-visible:ring-2"
style={{ backgroundColor: color }}
>
{showLabel && (
<span className="text-background truncate text-xs font-medium">{item.label}</span>
)}
{showPercentage && (
<span className="text-background/80 font-mono text-[11px] tabular-nums">
{formatted}
</span>
)}
</div>
</motion.div>
);
})}
</div>
);
}
Pass values and the component computes each share and tile area.
"use client";
import { TileTreemap } from "@/components/tile-treemap";
const data = [
{ label: "TypeScript", value: 42 },
{ label: "JavaScript", value: 23 },
{ label: "Python", value: 14 },
{ label: "Rust", value: 9 },
];
export function Example() {
return <TileTreemap data={data} />;
}| Prop | Type | Default | Description |
|---|---|---|---|
| data | TileTreemapItem[] | — | Items to lay out. Each tile area is proportional to its value. |
| height | number | 16:10 | Fixed pixel height. Falls back to a 16:10 aspect ratio when omitted. |
| className | string | — | Classes applied to the treemap container. |
| valueFormatter | (value, percentage) => string | — | Formats the value shown inside each tile. |
| hoverScale | number | 1.04 | Scale applied to a tile on hover or keyboard focus. |
type TileTreemapItem = {
label: string;
value: number;
color?: string;
};
type TileTreemapProps = {
data: TileTreemapItem[];
height?: number;
className?: string;
valueFormatter?: (value: number, percentage: number) => string;
hoverScale?: number;
};