Kinetik UI

Bar Chart

.md

Bars that spring up in a wave; drag across them and the one under the finger stays bright with its value.

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

Installation

Kinetik CLI
npx kinetik-ui add bar-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/bar-chart.json

Install manually

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

Usage

import { BarChart } from '@/components/kinetik/bar-chart';

<BarChart
  accessibilityLabel="Steps this week"
  data={days.map((d) => ({ label: d.short, value: d.steps }))}
  formatValue={(v) => v.toLocaleString()}
  onSelect={setDay}
/>;

The chart fills its parent's width and spaces the bars evenly. Bars grow from zero in a wave the first time and spring to new values when data changes. Pass max to keep the scale fixed across datasets, for example a daily goal.

When to use

  • Comparing a handful of categories or periods: days, months, teams, products.
  • Summaries where the exact value matters on demand but not all at once.

When not to use

  • Continuous trends with many points. Use Line Chart.
  • More than about 16 bars; they get too thin to touch.

Notes

  • The selection stays after the finger lifts; tap the selected bar to clear it.
  • A tick plays as the finger crosses from bar to bar.
  • Screen readers move between bars with the adjustable actions.

Props

BarChartProps

PropTypeDefaultDescription
datarequiredreadonly BarDatum[]—
accessibilityLabelrequiredstring—A summary of what the chart shows, e.g. "Steps per day this week".
heightnumber180Height of the bars' area. Defaults to 180.
colorstring—Bar colour. Defaults to the theme ember.
maxnumber—The value the tallest bar reaches. Defaults to the largest value.
formatValue(value: number) => string(v) => String(v)
onSelect(index: number | null) => void—Called with the selected bar, or null when the selection clears.
hapticsbooleantrueA tick as the finger moves from bar to bar. Defaults to true.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
One spring per bar when values change and one opacity timing per bar when the selection changes, all on the UI thread.
Measured

Real-device measurement pending.

Accessibility

Reduced motion
Bars reach their height with a short timing instead of the wave of springs.
Screen readers
Adjustable: swipe up and down to move between bars; each reads its label and value.
Touch target
The whole chart is the scrub area; each bar's column is a target.

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/bar-chart.tsx
import { useEffect, useState } from 'react';
import {
  StyleSheet,
  Text,
  View,
  type AccessibilityActionEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withSpring,
  withTiming,
  type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

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';

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

export type BarChartProps = {
  data: readonly BarDatum[];
  /** Height of the bars' area. Defaults to 180. */
  height?: number;
  /** Bar colour. Defaults to the theme ember. */
  color?: string;
  /** The value the tallest bar reaches. Defaults to the largest value. */
  max?: number;
  formatValue?: (value: number) => string;
  /** Called with the selected bar, or null when the selection clears. */
  onSelect?: (index: number | null) => void;
  /** A tick as the finger moves from bar to bar. Defaults to true. */
  haptics?: boolean;
  /** A summary of what the chart shows, e.g. "Steps per day this week". */
  accessibilityLabel: string;
  style?: StyleProp<ViewStyle>;
};

/** Bars never get wider than this, however few there are. */
const BAR = 28;
/** Room above the bars for the value label. */
const HEADROOM = 30;

/**
 * Bars that spring up one after another. Drag across them to read values:
 * the bar under the finger stays bright, the rest step back, and its value
 * floats above it. Tap the selected bar again to clear.
 */
export function BarChart({
  data,
  height = 180,
  color,
  max,
  formatValue = (v) => String(v),
  onSelect,
  haptics = true,
  accessibilityLabel,
  style,
}: BarChartProps) {
  const { colors, font, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const ink = color ?? colors.ember;
  const n = data.length;
  const top = max ?? Math.max(1, ...data.map((d) => d.value));

  const [width, setWidth] = useState(0);
  const [selected, setSelected] = useState<number | null>(null);
  const index = useSharedValue(-1);
  const start = useSharedValue(-1);

  const choose = (i: number) => {
    const next = i < 0 ? null : i;
    setSelected(next);
    if (next !== null) haptic('tick');
    onSelect?.(next);
  };
  const move = (i: number) => {
    'worklet';
    if (i === index.get()) return;
    index.set(i);
    scheduleOnRN(choose, i);
  };

  const pick = (x: number) => {
    'worklet';
    if (n === 0 || width === 0) return -1;
    return Math.max(0, Math.min(n - 1, Math.floor((x / width) * n)));
  };

  // While dragging, follow the finger; a bar that was just cleared by a tap
  // stays clear until the finger moves to another bar.
  const follow = (x: number) => {
    'worklet';
    const i = pick(x);
    if (i !== start.get() || index.get() >= 0) move(i);
  };

  const pan = Gesture.Pan()
    .withTestId('bar-chart')
    .enabled(n > 0)
    .minDistance(0)
    .activeOffsetX([-2, 2])
    .failOffsetY([-14, 14])
    .onBegin((e) => {
      start.set(index.get());
      const i = pick(e.x);
      // A tap on the selected bar clears the selection.
      move(i === start.get() ? -1 : i);
    })
    .onStart((e) => {
      follow(e.x);
    })
    .onUpdate((e) => {
      follow(e.x);
    });

  const onAccessibilityAction = (e: AccessibilityActionEvent) => {
    if (n === 0) return;
    const current = selected ?? -1;
    const next =
      e.nativeEvent.actionName === 'increment'
        ? Math.min(n - 1, current + 1)
        : Math.max(0, current - 1);
    index.set(next);
    choose(next);
  };

  const focus = selected ?? -1;
  const valueText =
    selected !== null && data[selected]
      ? `${data[selected]!.label}: ${formatValue(data[selected]!.value)}`
      : undefined;

  return (
    <View style={style}>
      <GestureDetector gesture={pan}>
        <View
          collapsable={false}
          accessible
          accessibilityRole="adjustable"
          accessibilityLabel={accessibilityLabel}
          accessibilityValue={valueText ? { text: valueText } : undefined}
          accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
          onAccessibilityAction={onAccessibilityAction}
          onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
          style={[styles.plot, { height: height + HEADROOM, gap: n > 12 ? 4 : 10 }]}
        >
          {data.map((d, i) => (
            <Bar
              key={`${d.label}-${i}`}
              index={i}
              value={d.value}
              max={top}
              height={height}
              color={ink}
              focus={focus}
              selection={index}
              reduced={reduced}
              label={selected === i ? formatValue(d.value) : null}
            />
          ))}
        </View>
      </GestureDetector>
      <View style={[styles.labels, { gap: n > 12 ? 4 : 10, marginTop: space[2] }]}>
        {data.map((d, i) => (
          <Text
            key={`${d.label}-${i}`}
            numberOfLines={1}
            style={[
              styles.label,
              {
                color: selected === i ? colors.text : colors.textSubtle,
                fontSize: font.size.xs,
                fontWeight: selected === i ? font.weight.semibold : font.weight.regular,
              },
            ]}
          >
            {d.label}
          </Text>
        ))}
      </View>
    </View>
  );
}

function Bar({
  index,
  value,
  max,
  height,
  color,
  focus,
  selection,
  reduced,
  label,
}: {
  index: number;
  value: number;
  max: number;
  height: number;
  color: string;
  focus: number;
  selection: SharedValue<number>;
  reduced: boolean;
  label: string | null;
}) {
  const { colors, font, radius } = useKinetikTheme();
  const target = Math.max(0, Math.min(1, value / max)) * height;
  const h = useSharedValue(reduced ? target : 0);

  // Grow in a wave from the left; later changes spring straight to the new value.
  const first = useSharedValue(true);
  useEffect(() => {
    if (reduced) {
      h.set(withTiming(target, timing('quick', true)));
      return;
    }
    const grow = withSpring(target, spring('smooth'));
    h.set(first.get() ? withDelay(index * 55, grow) : grow);
    first.set(false);
  }, [target, reduced, index, h, first]);

  const bar = useAnimatedStyle(() => {
    const s = selection.get();
    const dim = s >= 0 && s !== index;
    return {
      height: Math.max(4, h.get()),
      opacity: withTiming(dim ? 0.28 : 1, { duration: 160 }),
    };
  });
  const tag = useAnimatedStyle(() => ({
    transform: [{ translateY: -Math.max(4, h.get()) - 8 }],
  }));

  return (
    <View style={styles.column}>
      {/* A faint full-height track behind each bar shows how far it could go. */}
      <View
        style={[
          styles.track,
          { height, backgroundColor: colors.track, borderRadius: radius.sm, opacity: 0.55 },
        ]}
      />
      <Animated.View
        style={[styles.bar, { backgroundColor: color, borderRadius: radius.sm }, bar]}
      />
      {label !== null && focus === index ? (
        <Animated.View pointerEvents="none" style={[styles.tag, tag]}>
          <View
            style={{
              backgroundColor: colors.text,
              borderRadius: radius.xs,
              paddingHorizontal: 7,
              paddingVertical: 3,
            }}
          >
            <Text
              style={{
                color: colors.background,
                fontSize: font.size.xs,
                fontWeight: font.weight.bold,
                fontVariant: ['tabular-nums'],
              }}
            >
              {label}
            </Text>
          </View>
        </Animated.View>
      ) : null}
    </View>
  );
}

const styles = StyleSheet.create({
  plot: { flexDirection: 'row', alignItems: 'flex-end' },
  column: { flex: 1, height: '100%', justifyContent: 'flex-end', alignItems: 'center' },
  bar: { width: '100%', maxWidth: BAR },
  track: { position: 'absolute', bottom: 0, width: '100%', maxWidth: BAR },
  tag: { position: 'absolute', bottom: 0, alignItems: 'center', minWidth: 60 },
  labels: { flexDirection: 'row' },
  label: { flex: 1, textAlign: 'center' },
});