Waffle Chart
A ten-by-ten grid where each cell is one percent; cells fill in a wave and new data washes across the grid.
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 },
});Installation
npx kinetik-ui add waffle-chartpnpm dlx kinetik-ui add waffle-chartbunx kinetik-ui add waffle-chartyarn dlx kinetik-ui add waffle-chartThe 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
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/waffle-chart.tsx
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.
waffleCellsis 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
| Prop | Type | Default | Description |
|---|---|---|---|
datarequired | readonly WaffleSlice[] | — | Shares in percent. They may add up to less than 100; the rest stays empty. |
accessibilityLabelrequired | string | — | What the chart shows, read first, e.g. "Where the budget goes". |
colors | readonly string[] | — | One colour per slice. Defaults to azure, sky, the theme ink and grey. |
legend | boolean | true | Show the legend under the grid. Defaults to true. |
testID | string | — | |
style | StyleProp<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 },
});