Skip to content

Treemap ​

Squarified treemap whose tiles glide to their new size and position whenever the data changes.

Bundle composition

Gzipped size by module

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/treemap.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Copy the Chart Utils files into components/ui/chart-utils/, then copy these files into components/ui/treemap/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { TreemapItem } from './types'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, ref } from 'vue'
import { cn } from '@/lib/utils'
import { chartColor, ChartTooltip, formatCompact, useElementWidth } from '@/components/ui/chart-utils'
import { squarify } from './squarify'

const props = withDefaults(defineProps<{
  data: TreemapItem[]
  height?: number
  /** Gap between tiles in px. */
  gap?: number
  /** Tiles narrower or shorter than this (px) hide their label. */
  minLabelSize?: number
  /** Formats values on tiles and in the tooltip. */
  formatValue?: (value: number) => string
  class?: HTMLAttributes['class']
}>(), {
  height: 320,
  gap: 3,
  minLabelSize: 56,
})

const MOVE = { type: 'spring', stiffness: 260, damping: 32 } as const

const reduced = useReducedMotion()
const root = ref<HTMLElement>()
const width = useElementWidth(root)
const fmt = (v: number) => (props.formatValue ?? formatCompact)(v)
const pct = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 })

const groups = computed(() => [...new Set(props.data.map(d => d.group).filter((g): g is string => !!g))])
const total = computed(() => props.data.reduce((s, d) => s + Math.max(0, d.value), 0))

const tiles = computed(() => {
  const rects = squarify(props.data.map(d => d.value), { x: 0, y: 0, w: width.value, h: props.height })
  const max = Math.max(...props.data.map(d => d.value), 1)
  const half = props.gap / 2
  return props.data
    .map((d, i) => {
      const r = rects[i]!
      const color = d.color ?? chartColor(d.group ? groups.value.indexOf(d.group) : i)
      const w = Math.max(0, r.w - props.gap)
      const h = Math.max(0, r.h - props.gap)
      return {
        key: d.label,
        label: d.label,
        group: d.group,
        value: d.value,
        share: total.value ? d.value / total.value : 0,
        color,
        // Bigger tiles get a stronger tint, so size reads twice.
        tint: Math.round(18 + 34 * Math.sqrt(Math.max(0, d.value) / max)),
        x: r.x + half,
        y: r.y + half,
        w,
        h,
        showLabel: w >= props.minLabelSize && h >= 34,
        showValue: w >= props.minLabelSize && h >= 52,
      }
    })
    .filter(t => t.w > 0 && t.h > 0)
    .sort((a, b) => b.value - a.value)
})

const active = ref<string | null>(null)
const current = computed(() => tiles.value.find(t => t.key === active.value) ?? null)

function onKey(event: KeyboardEvent) {
  const list = tiles.value
  const idx = list.findIndex(t => t.key === active.value)
  let next: number | undefined
  if (event.key === 'ArrowRight' || event.key === 'ArrowDown')
    next = idx < 0 ? 0 : Math.min(list.length - 1, idx + 1)
  else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp')
    next = idx < 0 ? list.length - 1 : Math.max(0, idx - 1)
  else if (event.key === 'Home')
    next = 0
  else if (event.key === 'End')
    next = list.length - 1
  else if (event.key === 'Escape')
    active.value = null
  if (next !== undefined) {
    event.preventDefault()
    active.value = list[next]?.key ?? null
  }
}

const summary = computed(() => {
  const top = tiles.value.slice(0, 3).map(t => `${t.label} ${pct.format(t.share)}`).join(', ')
  return `Treemap of ${tiles.value.length} items, total ${fmt(total.value)}. Largest: ${top}. Use arrow keys to step through items.`
})
const announcement = computed(() => {
  const t = current.value
  return t ? `${t.label}: ${fmt(t.value)}, ${pct.format(t.share)} of total` : ''
})
</script>

<template>
  <div ref="root" :class="cn('relative w-full select-none', props.class)">
    <div
      role="img"
      :aria-label="summary"
      tabindex="0"
      class="relative overflow-hidden rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
      :style="{ height: `${props.height}px` }"
      @keydown="onKey"
      @blur="active = null"
      @pointerleave="active = null"
    >
      <AnimatePresence>
        <motion.div
          v-for="(t, i) in tiles"
          :key="t.key"
          class="absolute left-0 top-0 overflow-hidden rounded-md border"
          :style="{
            borderColor: `color-mix(in oklab, ${t.color} ${active === t.key ? 80 : 40}%, transparent)`,
            background: `color-mix(in oklab, ${t.color} ${active === t.key ? t.tint + 16 : t.tint}%, transparent)`,
          }"
          :initial="reduced ? false : { opacity: 0, scale: 0.9, left: t.x, top: t.y, width: t.w, height: t.h }"
          :animate="{ opacity: active === null || active === t.key ? 1 : 0.55, scale: 1, left: t.x, top: t.y, width: t.w, height: t.h }"
          :exit="{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }"
          :transition="{
            default: MOVE,
            opacity: { duration: 0.25, delay: active === null ? Math.min(i, 20) * 0.03 : 0 },
            scale: { ...MOVE, delay: Math.min(i, 20) * 0.03 },
          }"
          @pointerenter="active = t.key"
        >
          <div
            class="pointer-events-none flex h-full flex-col p-2 transition-opacity duration-200"
            :class="t.showLabel ? 'opacity-100' : 'opacity-0'"
          >
            <span class="truncate text-xs font-medium text-foreground">{{ t.label }}</span>
            <span
              class="truncate text-xs tabular-nums text-muted-foreground transition-opacity duration-200"
              :class="t.showValue ? 'opacity-100' : 'opacity-0'"
            >{{ fmt(t.value) }}</span>
          </div>
        </motion.div>
      </AnimatePresence>
    </div>

    <ChartTooltip
      :open="current !== null"
      :x="current ? current.x + current.w / 2 : 0"
      :y="current ? current.y + Math.min(current.h / 2, 28) : 0"
      :bounds="{ width, height: props.height }"
    >
      <div class="mb-1 flex items-center gap-2">
        <span class="size-2 shrink-0 rounded-[3px]" :style="{ background: current?.color }" />
        <span class="font-medium">{{ current?.label }}</span>
      </div>
      <p v-if="current?.group" class="m-0 mb-1 text-muted-foreground">
        {{ current.group }}
      </p>
      <div class="grid grid-cols-[auto_auto] gap-x-4 gap-y-0.5 text-muted-foreground">
        <span>Value</span>
        <span class="text-right font-medium tabular-nums text-foreground">{{ fmt(current?.value ?? 0) }}</span>
        <span>Share</span>
        <span class="text-right font-medium tabular-nums text-foreground">{{ pct.format(current?.share ?? 0) }}</span>
      </div>
    </ChartTooltip>

    <p class="sr-only" aria-live="polite">
      {{ announcement }}
    </p>
  </div>
</template>
ts
export interface TreemapRect { x: number, y: number, w: number, h: number }

/**
 * Squarified treemap layout (Bruls, Huizing & van Wijk). Items are laid out
 * largest first in rows along the shorter side of the remaining space; a row
 * is closed as soon as adding the next item would make its worst aspect
 * ratio worse. Returns one rectangle per input value, in input order.
 */
export function squarify(values: number[], bounds: TreemapRect): TreemapRect[] {
  const out: TreemapRect[] = values.map(() => ({ x: bounds.x, y: bounds.y, w: 0, h: 0 }))
  const total = values.reduce((s, v) => s + Math.max(0, v), 0)
  if (!total || bounds.w <= 0 || bounds.h <= 0)
    return out

  const scale = (bounds.w * bounds.h) / total
  const queue = values
    .map((v, i) => ({ i, area: Math.max(0, v) * scale }))
    .filter(item => item.area > 0)
    .sort((a, b) => b.area - a.area)
  const free = { ...bounds }

  // Worst aspect ratio of a row of `areas` laid along a side of length `side`.
  const worst = (sum: number, min: number, max: number, side: number) => {
    const s2 = sum * sum
    const side2 = side * side
    return Math.max((side2 * max) / s2, s2 / (side2 * min))
  }

  const place = (row: typeof queue, sum: number) => {
    if (free.w >= free.h) {
      // Column on the left of the free space.
      const colW = sum / free.h
      let y = free.y
      for (const item of row) {
        const h = item.area / colW
        out[item.i] = { x: free.x, y, w: colW, h }
        y += h
      }
      free.x += colW
      free.w -= colW
    }
    else {
      // Row along the top of the free space.
      const rowH = sum / free.w
      let x = free.x
      for (const item of row) {
        const w = item.area / rowH
        out[item.i] = { x, y: free.y, w, h: rowH }
        x += w
      }
      free.y += rowH
      free.h -= rowH
    }
  }

  let row: typeof queue = []
  let sum = 0
  let min = Infinity
  let max = 0
  for (const item of queue) {
    const side = Math.min(free.w, free.h)
    const nextSum = sum + item.area
    const nextMin = Math.min(min, item.area)
    const nextMax = Math.max(max, item.area)
    if (!row.length || worst(nextSum, nextMin, nextMax, side) <= worst(sum, min, max, side)) {
      row.push(item)
      sum = nextSum
      min = nextMin
      max = nextMax
    }
    else {
      place(row, sum)
      row = [item]
      sum = item.area
      min = item.area
      max = item.area
    }
  }
  if (row.length)
    place(row, sum)
  return out
}
ts
export interface TreemapItem {
  /** Unique name; tiles are matched by label when the data changes. */
  label: string
  value: number
  /** Items in the same group share a colour. */
  group?: string
  /** Any CSS colour. Defaults to a `--chart-*` token per group (or per item). */
  color?: string
}
ts
export { squarify } from './squarify'
export type { TreemapRect } from './squarify'
export { default as Treemap } from './Treemap.vue'
export type { TreemapItem } from './types'

Usage ​

ts
import { Treemap } from '@/components/ui/treemap'
vue
<Treemap
  :data="[
    { label: 'Engineering', value: 48, group: 'Product' },
    { label: 'Design', value: 14, group: 'Product' },
    { label: 'Sales', value: 31, group: 'Revenue' },
    { label: 'Support', value: 12, group: 'Revenue' },
  ]"
/>

Tiles are laid out with the squarified algorithm, which keeps them as close to square as possible so labels have room. Tiles are matched by label, so when values change each tile glides to its new rectangle; items that appear or disappear fade in and out. Labels fade away on tiles smaller than minLabelSize, and every tile still has a tooltip with its value and share of the total.

The layout function is exported too: squarify(values, { x, y, w, h }) returns one rectangle per value.

API ​

PropTypeDefaultDescription
dataTreemapItem[]—{ label, value, group?, color? } per tile.
heightnumber320Height in px; width fills the container.
gapnumber3Space between tiles in px.
minLabelSizenumber56Tiles narrower than this hide their label.
formatValue(v: number) => string1.2KValue format on tiles and in the tooltip.

Tiles in the same group share a --chart-* colour; bigger tiles get a stronger tint.

Designed by Kuldeep Pisda · Released under the MIT License