Kinetik UI

Treemap

.md

Sizes as nested, near-square tiles; tap a group and the map zooms into it as its parts open out inside.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add treemap

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/treemap.json

Install manually

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/treemap.tsx
    View source files

Usage

import { Treemap } from '@/components/kinetik/treemap';

<Treemap
  accessibilityLabel="Trip spending"
  rootLabel="All spending"
  data={[
    {
      key: 'stays',
      label: 'Stays',
      children: [
        { key: 'flat', label: 'Lisbon flat', value: 920 },
        { key: 'cabin', label: 'Lake cabin', value: 640 },
      ],
    },
    { key: 'tours', label: 'Tours', value: 380 },
  ]}
  formatValue={(v) => `€${v}`}
/>;

Leaves take a value; groups take children and are sized by their sum. Nesting can go as deep as you like; each level is one tap.

When to use

  • Seeing which parts of a whole are big at a glance: spending, storage, sales by region.
  • Hierarchies people want to explore a level at a time.

When not to use

  • Comparing similar sizes precisely. Areas are hard to compare; use Bar Chart.
  • Many tiny parts. Tiles too small for a label are drawn without one.

Notes

  • Tiles are laid out with the squarified algorithm, so they stay as close to square as the sizes allow. squarify and nodeValue are exported.
  • Tapping a group zooms in: the tapped tile grows to fill the frame while its parts open out inside it. The trail above zooms back out the same way.
  • Tiles are shaded by rank, from solid azure for the biggest to a pale wash for the smallest.
  • With reduced motion levels change in place.

Props

TreemapProps

PropTypeDefaultDescription
datarequiredreadonly TreemapNode[]—
accessibilityLabelrequiredstring—What the map shows, read first.
heightnumber280Height of the map. Defaults to 280.
formatValue(value: number) => string(v) => String(Math.round(v))Formats sizes on tiles. Defaults to whole numbers.
rootLabelstring'All'Names the top level in the trail, e.g. "All spending". Defaults to 'All'.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Plain views laid out with a squarified algorithm in JS; each tile springs four values while zooming. Nothing runs at rest.
Measured

Pixel 9 emulator, API 35, release build · interaction median 0.85× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 145 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
Levels change in place, without the zoom.
Screen readers
Every tile reads its label, size and share; groups are buttons that say they have parts. The trail above has a Back to button for each level.
Touch target
Tiles are as big as their share; the trail's buttons are 44 pt tall. Keep small slices out of a tap-only flow.

Try it on a device

Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.

Source files

components/kinetik/treemap.tsx
import { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';

import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';

export type TreemapNode = {
  key: string;
  label: string;
  /** Leaf size. Ignored when `children` is set; a parent's size is the sum of its children. */
  value?: number;
  children?: readonly TreemapNode[];
};

export type TreemapProps = {
  data: readonly TreemapNode[];
  /** Height of the map. Defaults to 280. */
  height?: number;
  /** Formats sizes on tiles. Defaults to whole numbers. */
  formatValue?: (value: number) => string;
  /** Names the top level in the trail, e.g. "All spending". Defaults to 'All'. */
  rootLabel?: string;
  /** What the map shows, read first. */
  accessibilityLabel: string;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

export type Rect = { x: number; y: number; w: number; h: number };

const GAP = 3;

export function nodeValue(node: TreemapNode): number {
  return node.children
    ? node.children.reduce((a, c) => a + nodeValue(c), 0)
    : Math.max(0, node.value ?? 0);
}

/** Squarified treemap layout (Bruls, Huizing and van Wijk): tiles as close to square as it can. */
export function squarify(values: readonly number[], rect: Rect): Rect[] {
  const out = new Array<Rect>(values.length);
  const total = values.reduce((a, b) => a + b, 0);
  if (total <= 0 || rect.w <= 0 || rect.h <= 0) {
    values.forEach((_, i) => (out[i] = { x: rect.x, y: rect.y, w: 0, h: 0 }));
    return out;
  }
  const order = values
    .map((v, i) => ({ i, a: (v / total) * rect.w * rect.h }))
    .sort((p, q) => q.a - p.a);
  let { x, y, w, h } = rect;

  const worst = (row: number[], side: number) => {
    const s = row.reduce((a, b) => a + b, 0);
    const max = Math.max(...row);
    const min = Math.min(...row);
    return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min));
  };
  const place = (row: { i: number; a: number }[]) => {
    const s = row.reduce((acc, r) => acc + r.a, 0);
    if (w >= h) {
      const cw = s / h;
      let cy = y;
      for (const r of row) {
        const rh = r.a / cw;
        out[r.i] = { x, y: cy, w: cw, h: rh };
        cy += rh;
      }
      x += cw;
      w -= cw;
    } else {
      const rh = s / w;
      let cx = x;
      for (const r of row) {
        const rw = r.a / rh;
        out[r.i] = { x: cx, y, w: rw, h: rh };
        cx += rw;
      }
      y += rh;
      h -= rh;
    }
  };

  let row: { i: number; a: number }[] = [];
  for (const item of order) {
    if (item.a <= 0) {
      out[item.i] = { x, y, w: 0, h: 0 };
      continue;
    }
    const side = Math.min(w, h);
    const areas = row.map((r) => r.a);
    if (row.length === 0 || worst([...areas, item.a], side) <= worst(areas, side)) {
      row.push(item);
    } else {
      place(row);
      row = [item];
    }
  }
  if (row.length) place(row);
  return out;
}

/** Maps a rect inside `from` to the same place inside `to`. */
function remap(r: Rect, from: Rect, to: Rect): Rect {
  const sx = to.w / (from.w || 1);
  const sy = to.h / (from.h || 1);
  return { x: to.x + (r.x - from.x) * sx, y: to.y + (r.y - from.y) * sy, w: r.w * sx, h: r.h * sy };
}

/**
 * Sizes as nested tiles, each as square as it can be. Tap a tile with parts
 * and the map zooms into it: the tile grows to fill the frame while its parts
 * open out inside. The trail above zooms back out the same way.
 */
export function Treemap({
  data,
  height = 280,
  formatValue = (v) => String(Math.round(v)),
  rootLabel = 'All',
  accessibilityLabel,
  testID,
  style,
}: TreemapProps) {
  const { colors, font, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const [width, setWidth] = useState(0);
  // The drilled path, and where the last zoom started from, so new tiles can grow out of it.
  const [path, setPath] = useState<TreemapNode[]>([]);
  const [origin, setOrigin] = useState<{ rect: Rect; zoomIn: boolean } | null>(null);
  const frame: Rect = { x: 0, y: 0, w: width, h: height };

  const nodes = useMemo(
    () => (path.length ? (path[path.length - 1]!.children ?? []) : data),
    [path, data],
  );
  const total = nodes.reduce((a, n) => a + nodeValue(n), 0);
  const rects = useMemo(
    () => squarify(nodes.map(nodeValue), { x: 0, y: 0, w: width, h: height }),
    [nodes, width, height],
  );
  // Shades by rank: the biggest tile is solid azure, the smallest a pale wash.
  const rank = useMemo(() => {
    const sorted = nodes.map((n, i) => ({ i, v: nodeValue(n) })).sort((a, b) => b.v - a.v);
    const r = new Array<number>(nodes.length);
    sorted.forEach((s, k) => (r[s.i] = nodes.length > 1 ? k / (nodes.length - 1) : 0));
    return r;
  }, [nodes]);

  const zoomInto = (node: TreemapNode, rect: Rect) => {
    setOrigin({ rect, zoomIn: true });
    setPath((p) => [...p, node]);
  };
  const zoomTo = (depth: number) => {
    if (depth >= path.length) return;
    // The tile we leave becomes small again inside its parent's layout.
    const parentNodes = depth === 0 ? data : (path[depth - 1]!.children ?? []);
    const child = path[depth]!;
    const parentRects = squarify(parentNodes.map(nodeValue), frame);
    const at = parentNodes.findIndex((n) => n.key === child.key);
    setOrigin({ rect: parentRects[at] ?? frame, zoomIn: false });
    setPath((p) => p.slice(0, depth));
  };

  const trail = [rootLabel, ...path.map((p) => p.label)];

  return (
    <View testID={testID} style={[{ gap: space[2] }, style]}>
      <View style={styles.trail} accessibilityRole="toolbar" accessibilityLabel="Treemap level">
        {trail.map((label, depth) => {
          const current = depth === trail.length - 1;
          return (
            <View key={`${depth}-${label}`} style={styles.crumb}>
              {depth > 0 ? (
                <Text style={{ color: colors.textSubtle, fontSize: font.size.sm }}>›</Text>
              ) : null}
              <Pressable
                disabled={current}
                onPress={() => zoomTo(depth)}
                accessibilityRole="button"
                accessibilityLabel={current ? `${label}, current level` : `Back to ${label}`}
                style={styles.crumbHit}
              >
                <Text
                  style={{
                    color: current ? colors.text : colors.azure,
                    fontSize: font.size.sm,
                    fontWeight: current ? font.weight.semibold : font.weight.medium,
                  }}
                >
                  {label}
                </Text>
              </Pressable>
            </View>
          );
        })}
      </View>

      <View
        accessibilityLabel={accessibilityLabel}
        onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
        style={[styles.frame, { height, borderRadius: radius.lg }]}
      >
        {width > 0
          ? nodes.map((node, i) => {
              const to = rects[i]!;
              // New tiles start where the zoom left them: inside the tapped tile, or
              // spread wide around the tile we are leaving.
              const from = origin
                ? origin.zoomIn
                  ? remap(to, frame, origin.rect)
                  : remap(to, origin.rect, frame)
                : to;
              const value = nodeValue(node);
              const share = total > 0 ? value / total : 0;
              const strength = 1 - rank[i]! * 0.7;
              return (
                <Tile
                  key={`${path.length}-${node.key}`}
                  from={from}
                  to={to}
                  reduced={reduced}
                  background={withAlpha(colors.azure, strength)}
                  ink={strength > 0.55 ? colors.onAzure : colors.text}
                  label={node.label}
                  value={formatValue(value)}
                  share={share}
                  hasParts={Boolean(node.children?.length)}
                  radius={radius.sm}
                  onPress={node.children?.length ? () => zoomInto(node, to) : undefined}
                />
              );
            })
          : null}
      </View>
    </View>
  );
}

type TileProps = {
  from: Rect;
  to: Rect;
  reduced: boolean;
  background: string;
  ink: string;
  label: string;
  value: string;
  share: number;
  hasParts: boolean;
  radius: number;
  onPress?: () => void;
};

function Tile({
  from,
  to,
  reduced,
  background,
  ink,
  label,
  value,
  share,
  hasParts,
  radius,
  onPress,
}: TileProps) {
  const { font } = useKinetikTheme();
  const x = useSharedValue(from.x);
  const y = useSharedValue(from.y);
  const w = useSharedValue(from.w);
  const h = useSharedValue(from.h);
  const fade = useSharedValue(reduced ? 1 : 0);

  useEffect(() => {
    const cfg = spring('smooth', reduced);
    x.set(withSpring(to.x, cfg));
    y.set(withSpring(to.y, cfg));
    w.set(withSpring(to.w, cfg));
    h.set(withSpring(to.h, cfg));
    fade.set(withSpring(1, cfg));
  }, [to.x, to.y, to.w, to.h, reduced, x, y, w, h, fade]);

  const style = useAnimatedStyle(() => ({
    left: x.get() + GAP / 2,
    top: y.get() + GAP / 2,
    width: Math.max(0, w.get() - GAP),
    height: Math.max(0, h.get() - GAP),
    opacity: fade.get(),
  }));

  const roomy = to.w > 64 && to.h > 44;
  const percent = `${Math.round(share * 100)}%`;
  const name = `${label}, ${value}, ${percent}${hasParts ? ', has parts' : ''}`;
  const content = roomy ? (
    <>
      <Text
        numberOfLines={1}
        style={{ color: ink, fontSize: font.size.sm, fontWeight: font.weight.semibold }}
      >
        {label}
      </Text>
      <Text
        numberOfLines={1}
        style={{ color: ink, opacity: 0.8, fontSize: font.size.xs, fontVariant: ['tabular-nums'] }}
      >
        {value} · {percent}
      </Text>
    </>
  ) : null;

  return (
    <Animated.View
      style={[styles.tile, { backgroundColor: background, borderRadius: radius }, style]}
    >
      {onPress ? (
        <Pressable
          onPress={onPress}
          accessibilityRole="button"
          accessibilityLabel={name}
          accessibilityHint="Zooms in"
          style={styles.press}
        >
          {content}
        </Pressable>
      ) : (
        // Leaves only show their size, so they are text, not a control.
        <View accessible accessibilityRole="text" accessibilityLabel={name} style={styles.press}>
          {content}
        </View>
      )}
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  trail: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', minHeight: 24 },
  // 44 pt tall to the finger; the negative margin keeps the trail compact.
  crumbHit: { minHeight: 44, justifyContent: 'center', marginVertical: -10 },
  crumb: { flexDirection: 'row', alignItems: 'center', gap: 6, marginRight: 6 },
  frame: { overflow: 'hidden' },
  tile: { position: 'absolute', overflow: 'hidden' },
  press: { flex: 1, padding: 8, gap: 2 },
});