Kinetik UI

Stream Graph

.md

Layered streams on a centred baseline that morph between datasets; tap a layer to lift it and read its share.

v1.0.0Live on web@shopify/react-native-skiareact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add stream-graph

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/stream-graph.json

Install manually

  1. Install dependencies
    npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/stream-graph.tsx
    View source files

Usage

import { StreamGraph } from '@/components/kinetik/stream-graph';

<StreamGraph
  accessibilityLabel="Hours of listening by genre"
  layers={[
    { key: 'pop', label: 'Pop', values: [32, 38, 35, 42] },
    { key: 'jazz', label: 'Jazz', values: [12, 14, 18, 16] },
  ]}
  formatValue={(v) => `${Math.round(v)} h`}
/>;

When to use

  • How a whole is made up over time, when the overall flow matters more than exact values: listening by genre, traffic by source.
  • Switching between periods or segments, where the morph shows what changed.

When not to use

  • Exact comparisons between layers. Use Bar Chart or Line Chart.
  • Data with negative values. Streams can only stack amounts.

Notes

  • Layers stack around a centred baseline and are smoothed with Catmull-Rom curves. Every layer needs the same number of values.
  • New data morphs each shape into place; the first draw grows out of the middle line.
  • Tap a layer, or its key, to isolate it: the others fade and the readout shows its total and share. Tap again to let go.
  • Data is compared by value, so passing a new but equal array does not replay the morph.

Props

StreamGraphProps

PropTypeDefaultDescription
layersrequiredreadonly StreamLayer[]—
accessibilityLabelrequiredstring—What the graph shows, e.g. "Listening by genre".
heightnumber200Plot height. Defaults to 200.
formatValue(value: number) => string(v) => String(Math.round(v))Formats totals in the readout and for screen readers.
hapticsbooleantrueA tick when a layer is isolated. Defaults to true.
testIDstring—
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
One Skia canvas, one path per layer; a morph interpolates the paths on the UI thread. Shapes are built once per dataset. Nothing runs at rest.
Measured

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

Real-device measurement pending.

Accessibility

Reduced motion
New data replaces the shapes at once; isolating a layer still fades the others.
Screen readers
The graph is an image that reads each layer's total; each key is a button with a selected state.
Touch target
The keys are 44 pt tall; the whole graph can be tapped.

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/stream-graph.tsx
import { Canvas, Path, Skia, type SkPath } from '@shopify/react-native-skia';
import { useEffect, useMemo, useState } from 'react';
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type GestureResponderEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import {
  useDerivedValue,
  useSharedValue,
  withSpring,
  withTiming,
  type SharedValue,
} from 'react-native-reanimated';

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

export type StreamLayer = {
  key: string;
  label: string;
  /** One value per step; every layer needs the same number of values. */
  values: readonly number[];
  /** Overrides the layer's colour. */
  color?: string;
};

export type StreamGraphProps = {
  layers: readonly StreamLayer[];
  /** Plot height. Defaults to 200. */
  height?: number;
  /** Formats totals in the readout and for screen readers. */
  formatValue?: (value: number) => string;
  /** What the graph shows, e.g. "Listening by genre". */
  accessibilityLabel: string;
  /** A tick when a layer is isolated. Defaults to true. */
  haptics?: boolean;
  testID?: string;
  style?: StyleProp<ViewStyle>;
};

const PAD = 6;

/**
 * Builds one closed, smooth shape per layer, stacked around a centred
 * baseline so the whole stream wiggles about the middle. Every shape has the
 * same structure for a given number of steps, so two datasets can morph.
 */
export function streamPaths(
  layers: readonly { values: readonly number[] }[],
  width: number,
  height: number,
): SkPath[] {
  const steps = Math.max(0, ...layers.map((l) => l.values.length));
  if (steps < 2 || width === 0) return layers.map(() => Skia.PathBuilder.Make().detach());
  const totals = Array.from({ length: steps }, (_, t) =>
    layers.reduce((sum, l) => sum + Math.max(0, l.values[t] ?? 0), 0),
  );
  const peak = Math.max(1, ...totals);
  const k = (height - PAD * 2) / peak;
  const x = (t: number) => (t / (steps - 1)) * width;
  const mid = height / 2;
  const below = totals.map((total) => mid - (total * k) / 2);
  return layers.map((layer) => {
    const lo = below.map((y) => y);
    const hi = lo.map((y, t) => y + Math.max(0, layer.values[t] ?? 0) * k);
    for (let t = 0; t < steps; t++) below[t] = hi[t]!;
    const b = Skia.PathBuilder.Make();
    b.moveTo(x(0), hi[0]!);
    curve(b, hi, x, false);
    b.lineTo(x(steps - 1), lo[steps - 1]!);
    curve(b, lo, x, true);
    b.close();
    return b.detach();
  });
}

/** Catmull-Rom through the points as cubic Béziers, forwards or backwards. */
function curve(
  b: ReturnType<typeof Skia.PathBuilder.Make>,
  ys: number[],
  x: (t: number) => number,
  back: boolean,
) {
  const n = ys.length;
  const at = (i: number) => (back ? n - 1 - i : i);
  for (let s = 0; s < n - 1; s++) {
    const p0 = at(Math.max(0, s - 1));
    const p1 = at(s);
    const p2 = at(s + 1);
    const p3 = at(Math.min(n - 1, s + 2));
    const c1x = x(p1) + (x(p2) - x(p0)) / 6;
    const c1y = ys[p1]! + (ys[p2]! - ys[p0]!) / 6;
    const c2x = x(p2) - (x(p3) - x(p1)) / 6;
    const c2y = ys[p2]! - (ys[p3]! - ys[p1]!) / 6;
    b.cubicTo(c1x, c1y, c2x, c2y, x(p2), ys[p2]!);
  }
}

/**
 * Layered streams on a centred baseline. New data morphs every shape into
 * place, and tapping a layer (or its key) lifts it while the others fade back,
 * with its total in the readout.
 */
export function StreamGraph({
  layers,
  height = 200,
  formatValue = (v) => String(Math.round(v)),
  accessibilityLabel,
  haptics = true,
  testID,
  style,
}: StreamGraphProps) {
  const { colors, font, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [width, setWidth] = useState(0);
  const [focus, setFocus] = useState<string | null>(null);

  const palette = [
    colors.azure,
    colors.sky,
    colors.ice,
    withAlpha(colors.azure, 0.55),
    withAlpha(colors.sky, 0.5),
    withAlpha(colors.ice, 0.5),
  ];
  const tint = (i: number) => layers[i]?.color ?? palette[i % palette.length]!;

  // Keyed on the numbers, so a parent passing a fresh but equal array does not morph.
  const dataKey = JSON.stringify(layers.map((l) => l.values));
  const next = useMemo(
    () => streamPaths(layers, width, height),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [dataKey, width, height],
  );
  // Shapes morph from the previous dataset; the first one grows out of the baseline.
  const [shapes, setShapes] = useState<{ next: SkPath[]; prev: SkPath[]; gen: number }>(() => ({
    next,
    prev: next,
    gen: 0,
  }));
  if (shapes.next !== next) {
    const flat = streamPaths(
      layers.map((l) => ({ values: l.values.map(() => 0) })),
      width,
      height,
    );
    const prev = shapes.next.length === next.length && shapes.gen > 0 ? shapes.next : flat;
    setShapes({ next, prev, gen: shapes.gen + 1 });
  }
  const t = useSharedValue(1);
  useEffect(() => {
    if (shapes.gen === 0) return;
    if (reduced) {
      t.set(1);
      return;
    }
    t.set(0);
    t.set(withSpring(1, spring('smooth')));
  }, [shapes.gen, reduced, t]);

  const choose = (key: string | null) => {
    haptic('tick');
    setFocus((f) => (f === key ? null : key));
  };

  const tap = (e: GestureResponderEvent) => {
    const { locationX, locationY } = e.nativeEvent;
    const hit = next.findIndex((p) => p.contains(locationX, locationY));
    choose(hit >= 0 ? layers[hit]!.key : null);
  };

  const sums = layers.map((l) => l.values.reduce((a, b) => a + b, 0));
  const all = sums.reduce((a, b) => a + b, 0);
  const fi = layers.findIndex((l) => l.key === focus);
  const summary = layers.map((l, i) => `${l.label} ${formatValue(sums[i]!)}`).join(', ');

  return (
    <View testID={testID} style={[{ gap: space[3] }, style]}>
      <View style={styles.readout}>
        <Text style={{ color: colors.textSubtle, fontSize: font.size.xs }}>
          {fi >= 0 ? layers[fi]!.label : 'All'}
        </Text>
        <Text
          style={{
            color: colors.text,
            fontSize: font.size.xl,
            fontWeight: font.weight.semibold,
            fontVariant: ['tabular-nums'],
          }}
        >
          {formatValue(fi >= 0 ? sums[fi]! : all)}
          {fi >= 0 && all > 0 ? (
            <Text
              style={{
                color: colors.textMuted,
                fontSize: font.size.sm,
                fontWeight: font.weight.regular,
              }}
            >
              {`  ${Math.round((sums[fi]! / all) * 100)}%`}
            </Text>
          ) : null}
        </Text>
      </View>
      <Pressable
        onPress={tap}
        onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
        accessibilityRole="image"
        accessibilityLabel={`${accessibilityLabel}. ${summary}`}
        style={{ height }}
      >
        {width > 0 ? (
          <Canvas style={{ width, height }} pointerEvents="none">
            {shapes.next.map((p, i) => (
              <Layer
                key={layers[i]?.key ?? i}
                next={p}
                prev={shapes.prev[i] ?? p}
                t={t}
                color={tint(i)}
                dim={focus !== null && layers[i]?.key !== focus}
                reduced={reduced}
              />
            ))}
          </Canvas>
        ) : null}
      </Pressable>
      <View style={styles.legend}>
        {layers.map((l, i) => {
          const on = focus === l.key;
          return (
            <Pressable
              key={l.key}
              onPress={() => choose(l.key)}
              accessibilityRole="button"
              accessibilityLabel={`${l.label}, ${formatValue(sums[i]!)}`}
              accessibilityState={{ selected: on }}
              style={styles.key}
            >
              <View
                style={[
                  styles.keyInner,
                  {
                    borderRadius: radius.pill,
                    backgroundColor: on ? colors.surfaceRaised : undefined,
                    borderColor: on ? colors.borderStrong : 'transparent',
                  },
                ]}
              >
                <View style={[styles.swatch, { backgroundColor: tint(i) }]} />
                <Text
                  style={{ color: on ? colors.text : colors.textMuted, fontSize: font.size.sm }}
                >
                  {l.label}
                </Text>
              </View>
            </Pressable>
          );
        })}
      </View>
    </View>
  );
}

type LayerProps = {
  next: SkPath;
  prev: SkPath;
  t: SharedValue<number>;
  color: string;
  dim: boolean;
  reduced: boolean;
};

function Layer({ next, prev, t, color, dim, reduced }: LayerProps) {
  const fade = useSharedValue(dim ? 0.18 : 1);
  useEffect(() => {
    fade.set(withTiming(dim ? 0.18 : 1, timing('base', reduced)));
  }, [dim, reduced, fade]);
  const path = useDerivedValue(() => {
    const p = t.get();
    if (p >= 1) return next;
    return next.interpolate(prev, Math.max(0, p)) ?? next;
  });
  const opacity = useDerivedValue(() => fade.get());
  return <Path path={path} color={color} opacity={opacity} />;
}

const styles = StyleSheet.create({
  readout: { gap: 2 },
  legend: { flexDirection: 'row', flexWrap: 'wrap', columnGap: 4 },
  key: { height: minTouchTarget, justifyContent: 'center' },
  keyInner: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
    paddingHorizontal: 10,
    height: 30,
    borderWidth: 1,
  },
  swatch: { width: 10, height: 10, borderRadius: 3 },
});