Kinetik UI

Waffle Chart

.md

A ten-by-ten grid where each cell is one percent; cells fill in a wave and new data washes across the grid.

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

Installation

Kinetik CLI
npx kinetik-ui add waffle-chart

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/waffle-chart.json

Install manually

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

Usage

import { WaffleChart } from '@/components/kinetik/waffle-chart';

<WaffleChart
  accessibilityLabel="Trip budget"
  data={[
    { label: 'Stays', value: 46 },
    { label: 'Flights', value: 28 },
    { label: 'Food', value: 17 },
  ]}
/>;

Values are percentages. If they add up to less than 100, the remaining cells stay empty, which makes "how much is left" easy to see.

When to use

  • Parts of a whole that people should be able to count: budgets, survey answers, storage.
  • Small numbers of slices, up to about five.

When not to use

  • Values that do not add up to a whole. Use Bar Chart.
  • Precise comparisons between similar shares. Cells round to whole percent.

Notes

  • Shares are rounded to whole cells with the largest-remainder method, so the cells always add up to the rounded total and never past a hundred. waffleCells is exported if you need the same counts elsewhere.
  • Tap a legend entry to dim every other slice; tap it again to clear.
  • The grid is square and takes the width it is given.
  • With reduced motion the grid appears filled and changes colour at once.

Props

WaffleChartProps

PropTypeDefaultDescription
datarequiredreadonly WaffleSlice[]—Shares in percent. They may add up to less than 100; the rest stays empty.
accessibilityLabelrequiredstring—What the chart shows, read first, e.g. "Where the budget goes".
colorsreadonly string[]—One colour per slice. Defaults to azure, sky, the theme ink and grey.
legendbooleantrueShow the legend under the grid. Defaults to true.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
A hundred plain views read three shared values, so a wave updates a hundred styles per frame for about a second. 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 · 158 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
The grid appears filled and new data changes the colours at once.
Screen readers
One image whose name lists every slice and its share. Legend entries are buttons with a selected state.
Touch target
Legend entries are at least 44 pt tall.

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/waffle-chart.tsx
import { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  interpolateColor,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
  type SharedValue,
} from 'react-native-reanimated';

import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';

export type WaffleSlice = { label: string; value: number };

export type WaffleChartProps = {
  /** Shares in percent. They may add up to less than 100; the rest stays empty. */
  data: readonly WaffleSlice[];
  /** One colour per slice. Defaults to azure, sky, the theme ink and grey. */
  colors?: readonly string[];
  /** Show the legend under the grid. Defaults to true. */
  legend?: boolean;
  /** What the chart shows, read first, e.g. "Where the budget goes". */
  accessibilityLabel: string;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const GRID = 10;
const GAP = 3;
/** Largest diagonal index in a 10 × 10 grid. */
const DIAGONALS = GRID * 2 - 2;

/** Whole cells per slice that add up to the rounded total (largest remainder). */
export function waffleCells(values: readonly number[]): number[] {
  const clamped = values.map((v) => Math.max(0, v));
  const total = Math.min(100, Math.round(clamped.reduce((a, b) => a + b, 0)));
  const floors = clamped.map((v) => Math.floor(v));
  let left = total - floors.reduce((a, b) => a + b, 0);
  const order = clamped.map((v, i) => ({ i, r: v - Math.floor(v) })).sort((a, b) => b.r - a.r);
  for (const { i } of order) {
    if (left <= 0) break;
    floors[i]! += 1;
    left -= 1;
  }
  // Never more than a hundred cells, whatever the input.
  let over = floors.reduce((a, b) => a + b, 0) - 100;
  for (let i = floors.length - 1; i >= 0 && over > 0; i--) {
    const cut = Math.min(over, floors[i]!);
    floors[i]! -= cut;
    over -= cut;
  }
  return floors;
}

/**
 * A ten-by-ten grid where every cell is one percent. The cells fill in as a
 * wave from one corner, new data washes across the grid the same way, and
 * tapping a slice in the legend dims everything else.
 */
export function WaffleChart({
  data,
  colors: palette,
  legend = true,
  accessibilityLabel,
  testID,
  style,
}: WaffleChartProps) {
  const { colors, font, radius } = useKinetikTheme();
  const reduced = useReduceMotion();
  const [width, setWidth] = useState(0);
  const [focus, setFocus] = useState(-1);
  const inks = palette ?? [colors.azure, colors.sky, colors.ember, colors.ice];

  const counts = useMemo(() => waffleCells(data.map((d) => d.value)), [data]);
  // Slice index for each of the hundred cells, or -1 for empty ones.
  const owners = useMemo(() => {
    const out: number[] = [];
    counts.forEach((n, i) => {
      for (let k = 0; k < n; k++) out.push(i);
    });
    while (out.length < GRID * GRID) out.push(-1);
    return out;
  }, [counts]);
  const cellColor = (owner: number) => (owner < 0 ? colors.track : inks[owner % inks.length]!);
  const target = owners.map(cellColor);

  // The colours before the latest change, so the cells can wash from old to new.
  const key = target.join('|');
  const [swap, setSwap] = useState({ key, from: target, to: target, wave: 0 });
  if (swap.key !== key) {
    setSwap({ key, from: swap.to, to: target, wave: swap.wave + 1 });
  }
  const from = swap.from;

  const enter = useSharedValue(reduced ? 1 : 0);
  const wash = useSharedValue(1);
  const focused = useSharedValue(-1);

  useEffect(() => {
    enter.set(
      reduced ? 1 : withTiming(1, { duration: durations.slow * 2, easing: easings.standard }),
    );
  }, [reduced, enter]);
  useEffect(() => {
    if (swap.wave === 0) return;
    wash.set(0);
    wash.set(
      reduced ? 1 : withTiming(1, { duration: durations.slow * 2.2, easing: easings.standard }),
    );
  }, [swap.wave, reduced, wash]);
  useEffect(() => {
    focused.set(focus);
  }, [focus, focused]);

  // Rounded down a little, so ten cells always fit on a row whatever the pixel rounding.
  const cell = width > 0 ? Math.floor(((width - GAP * (GRID - 1)) / GRID) * 10) / 10 : 0;
  const summary = data.map((d) => `${d.label} ${Math.round(d.value)}%`).join(', ');

  return (
    <View testID={testID} style={[{ gap: 16 }, style]}>
      <View
        accessible
        accessibilityRole="image"
        accessibilityLabel={`${accessibilityLabel}: ${summary}`}
        onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
        style={[styles.grid, { gap: GAP }]}
      >
        {cell > 0
          ? owners.map((owner, i) => (
              <Cell
                key={i}
                size={cell}
                order={Math.floor(i / GRID) + (i % GRID)}
                owner={owner}
                from={from[i] ?? target[i]!}
                to={target[i]!}
                enter={enter}
                wash={wash}
                focused={focused}
                radius={Math.min(radius.xs, cell / 4)}
              />
            ))
          : null}
      </View>
      {legend ? (
        <View style={styles.legend}>
          {data.map((d, i) => (
            <Pressable
              key={d.label}
              onPress={() => setFocus((f) => (f === i ? -1 : i))}
              accessibilityRole="button"
              accessibilityLabel={`${d.label}, ${Math.round(d.value)}%`}
              accessibilityState={{ selected: focus === i }}
              style={[styles.item, { opacity: focus === -1 || focus === i ? 1 : 0.4 }]}
            >
              <View
                style={[styles.swatch, { backgroundColor: inks[i % inks.length], borderRadius: 3 }]}
              />
              <Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>{d.label}</Text>
              <Text
                style={{
                  color: colors.text,
                  fontSize: font.size.sm,
                  fontWeight: font.weight.semibold,
                  fontVariant: ['tabular-nums'],
                }}
              >
                {Math.round(d.value)}%
              </Text>
            </Pressable>
          ))}
        </View>
      ) : null}
    </View>
  );
}

type CellProps = {
  size: number;
  order: number;
  owner: number;
  from: string;
  to: string;
  enter: SharedValue<number>;
  wash: SharedValue<number>;
  focused: SharedValue<number>;
  radius: number;
};

function Cell({ size, order, owner, from, to, enter, wash, focused, radius }: CellProps) {
  const style = useAnimatedStyle(() => {
    // Each diagonal starts a moment after the one before it.
    const delay = (order / DIAGONALS) * 0.55;
    const e = Math.max(0, Math.min(1, (enter.get() - delay) / 0.45));
    const w = Math.max(0, Math.min(1, (wash.get() - delay) / 0.45));
    const f = focused.get();
    return {
      backgroundColor: interpolateColor(w, [0, 1], [from, to]),
      opacity: e * (f < 0 || f === owner ? 1 : 0.22),
      transform: [{ scale: 0.4 + e * 0.6 - Math.sin(w * Math.PI) * 0.12 }],
    };
  });
  return <Animated.View style={[{ width: size, height: size, borderRadius: radius }, style]} />;
}

const styles = StyleSheet.create({
  grid: { flexDirection: 'row', flexWrap: 'wrap', aspectRatio: 1 },
  legend: { flexDirection: 'row', flexWrap: 'wrap', columnGap: 16, rowGap: 0 },
  item: { flexDirection: 'row', alignItems: 'center', gap: 6, minHeight: 44 },
  swatch: { width: 10, height: 10 },
});