Kinetik UI

Line Chart

.md

A smooth line chart that draws itself in; drag across it and a guide, a dot and a label follow the finger.

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

Installation

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

Install manually

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

Usage

import { LineChart } from '@/components/kinetik/line-chart';

<LineChart
  accessibilityLabel="Revenue, last 12 months"
  data={revenue}
  labels={months}
  formatValue={(v) => `$${(v / 1000).toFixed(1)}k`}
  onScrub={(i) => setFocus(i)}
/>;

The chart fills its parent's width. The curve passes through every value without overshooting, so a dip never draws below its real number. onScrub reports the index under the finger (and null when it lifts), which is handy for a headline figure above the chart.

When to use

  • Trends over time: revenue, steps, heart rate, prices.
  • Dashboards where people want to read the exact value at a moment.

When not to use

  • Comparing separate categories. Use Bar Chart.
  • Dense data with thousands of points; down-sample first.

Notes

  • The line draws in whenever the data changes, with the area revealed behind it.
  • The latest point pulses gently at rest, a quiet "this is live".
  • Screen readers step through the points with the adjustable increment and decrement actions.

Props

LineChartProps

PropTypeDefaultDescription
datarequiredreadonly number[]—Values, oldest first. At least two.
accessibilityLabelrequiredstring—A summary of what the chart shows, e.g. "Revenue, last 12 months".
labelsreadonly string[]—One label per value (dates, months). Shown while scrubbing and under the chart's ends.
heightnumber200Height of the chart. Defaults to 200.
colorstring—Line colour. Defaults to the theme ember.
areabooleantrueFill the area under the line with a fading gradient. Defaults to true.
formatValue(value: number) => string(v) => String(Math.round(v * 100) / 100)
onScrub(index: number | null) => void—Called with the index under the finger, and null when it lifts.
hapticsbooleantrueA tick as the finger moves from point to point. Defaults to true.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
The curve is built once per data change; drawing in, scrubbing and the resting pulse are shared values read by one Skia canvas.
Measured

Real-device measurement pending.

Accessibility

Reduced motion
The line appears complete, without drawing in, and the latest point does not pulse.
Screen readers
Adjustable: swipe up and down to step through the points; each reads its label and value.
Touch target
The whole chart is the scrub area.

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/line-chart.tsx
import {
  Canvas,
  Circle,
  DashPathEffect,
  Group,
  Line,
  LinearGradient,
  Path,
  Skia,
  rect,
  vec,
} from '@shopify/react-native-skia';
import { useEffect, useMemo, 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, {
  Easing,
  useAnimatedStyle,
  useDerivedValue,
  useSharedValue,
  withRepeat,
  withTiming,
} 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 { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';

export type LineChartProps = {
  /** Values, oldest first. At least two. */
  data: readonly number[];
  /** One label per value (dates, months). Shown while scrubbing and under the chart's ends. */
  labels?: readonly string[];
  /** Height of the chart. Defaults to 200. */
  height?: number;
  /** Line colour. Defaults to the theme ember. */
  color?: string;
  /** Fill the area under the line with a fading gradient. Defaults to true. */
  area?: boolean;
  formatValue?: (value: number) => string;
  /** Called with the index under the finger, and null when it lifts. */
  onScrub?: (index: number | null) => void;
  /** A tick as the finger moves from point to point. Defaults to true. */
  haptics?: boolean;
  /** A summary of what the chart shows, e.g. "Revenue, last 12 months". */
  accessibilityLabel: string;
  style?: StyleProp<ViewStyle>;
};

/** Room above the plot for the scrub label. */
const TOP = 40;
const BOTTOM = 10;
const SIDE = 6;

type Point = { x: number; y: number };

/**
 * Monotone cubic interpolation (Fritsch–Carlson): a smooth curve through
 * every point that never overshoots, so a dip never draws below its value.
 */
function curve(points: Point[]): string {
  const n = points.length;
  if (n < 2) return '';
  const dx: number[] = [];
  const slope: number[] = [];
  for (let i = 0; i < n - 1; i++) {
    dx.push(points[i + 1]!.x - points[i]!.x);
    slope.push((points[i + 1]!.y - points[i]!.y) / (dx[i] || 1));
  }
  const tangent: number[] = [slope[0]!];
  for (let i = 1; i < n - 1; i++) {
    const a = slope[i - 1]!;
    const b = slope[i]!;
    tangent.push(
      a * b <= 0
        ? 0
        : (3 * (dx[i - 1]! + dx[i]!)) /
            ((2 * dx[i]! + dx[i - 1]!) / a + (dx[i]! + 2 * dx[i - 1]!) / b),
    );
  }
  tangent.push(slope[n - 2]!);
  let d = `M${points[0]!.x} ${points[0]!.y}`;
  for (let i = 0; i < n - 1; i++) {
    const p = points[i]!;
    const q = points[i + 1]!;
    const h = dx[i]! / 3;
    d += ` C${p.x + h} ${p.y + tangent[i]! * h} ${q.x - h} ${q.y - tangent[i + 1]! * h} ${q.x} ${q.y}`;
  }
  return d;
}

/**
 * A smooth line chart that draws itself in. Drag across it to read any point:
 * a guide, a dot and a label follow the finger. At rest the latest point
 * pulses softly.
 */
export function LineChart({
  data,
  labels,
  height = 200,
  color,
  area = true,
  formatValue = (v) => String(Math.round(v * 100) / 100),
  onScrub,
  haptics = true,
  accessibilityLabel,
  style,
}: LineChartProps) {
  const { colors, font, radius, space } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const ink = color ?? colors.ember;

  const [width, setWidth] = useState(0);
  const [active, setActive] = useState<number | null>(null);
  const [labelW, setLabelW] = useState(0);

  // Keyed on the values, not the array: a new array with the same numbers must not redraw.
  const signature = data.join(',');
  const { points, line, fill } = useMemo(() => {
    const n = data.length;
    if (width === 0 || n < 2) return { points: [] as Point[], line: null, fill: null };
    const lo = Math.min(...data);
    const hi = Math.max(...data);
    const pad = (hi - lo) * 0.08 || 1;
    const top = TOP;
    const bottom = height - BOTTOM;
    const pts = data.map((v, i) => ({
      x: SIDE + (i / (n - 1)) * (width - SIDE * 2),
      y: bottom - ((v - (lo - pad)) / (hi + pad - (lo - pad))) * (bottom - top),
    }));
    const d = curve(pts);
    const last = pts[n - 1]!;
    return {
      points: pts,
      line: Skia.Path.MakeFromSVGString(d),
      fill: Skia.Path.MakeFromSVGString(`${d} L${last.x} ${height} L${pts[0]!.x} ${height} Z`),
    };
    // `signature` stands in for `data`.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [signature, width, height]);

  // Draw in whenever the data or the size changes.
  const progress = useSharedValue(reduced ? 1 : 0);
  useEffect(() => {
    if (reduced) {
      progress.set(1);
      return;
    }
    progress.set(0);
    progress.set(withTiming(1, { duration: 1100, easing: Easing.bezier(0.2, 0, 0, 1) }));
  }, [line, reduced, progress]);

  // The latest point breathes while nobody is scrubbing.
  const pulse = useSharedValue(0);
  useEffect(() => {
    if (reduced) return;
    pulse.set(
      withRepeat(withTiming(1, { duration: 1600, easing: Easing.out(Easing.quad) }), -1, false),
    );
  }, [reduced, pulse]);

  const index = useSharedValue(-1);
  const n = points.length;

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

  const report = (i: number) => {
    setActive(i < 0 ? null : i);
    if (i >= 0) haptic('tick');
    onScrub?.(i < 0 ? null : i);
  };
  const move = (i: number) => {
    'worklet';
    if (i === index.get()) return;
    index.set(i);
    scheduleOnRN(report, i);
  };

  const pan = Gesture.Pan()
    .withTestId('line-chart')
    .enabled(n >= 2)
    .minDistance(0)
    .activeOffsetX([-2, 2])
    .failOffsetY([-14, 14])
    .onBegin((e) => {
      move(pick(e.x));
    })
    .onStart((e) => {
      move(pick(e.x));
    })
    .onUpdate((e) => {
      move(pick(e.x));
    })
    .onFinalize(() => {
      move(-1);
    });

  // Scrub marker and resting dot, read on the UI thread.
  const marker = useDerivedValue(() => {
    const i = index.get();
    const p = points[i < 0 ? n - 1 : i];
    return p ?? { x: -100, y: -100 };
  });
  const markerC = useDerivedValue(() => vec(marker.get().x, marker.get().y));
  const guideFrom = useDerivedValue(() => vec(marker.get().x, TOP - 6));
  const guideTo = useDerivedValue(() => vec(marker.get().x, height));
  const scrubbing = useDerivedValue(() => (index.get() >= 0 ? 1 : 0));
  const resting = useDerivedValue(() => (index.get() >= 0 ? 0 : progress.get() >= 0.999 ? 1 : 0));
  const haloR = useDerivedValue(() => 5 + pulse.get() * 12);
  const haloO = useDerivedValue(() => (1 - pulse.get()) * 0.35 * resting.get());
  const reveal = useDerivedValue(() => rect(0, 0, width * progress.get(), height));

  const bubble = useAnimatedStyle(() => {
    const x = marker.get().x - labelW / 2;
    return {
      opacity: withTiming(index.get() >= 0 ? 1 : 0, { duration: 120 }),
      transform: [{ translateX: Math.max(0, Math.min(width - labelW, x)) }],
    };
  });

  const onAccessibilityAction = (e: AccessibilityActionEvent) => {
    if (n < 2) return;
    const current = active ?? n - 1;
    const next =
      e.nativeEvent.actionName === 'increment'
        ? Math.min(n - 1, current + 1)
        : Math.max(0, current - 1);
    setActive(next);
    onScrub?.(next);
  };

  const shown = active ?? n - 1;
  const valueText =
    n > 0
      ? `${labels?.[shown] ? `${labels[shown]}: ` : ''}${formatValue(data[shown]!)}`
      : 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={{ height }}
        >
          {/* Wrapped so touches reach the gesture on the web, where the canvas ignores pointerEvents. */}
          <View pointerEvents="none" style={StyleSheet.absoluteFill}>
            <Canvas style={StyleSheet.absoluteFill}>
              {[0, 1, 2].map((g) => {
                const y = TOP + ((height - BOTTOM - TOP) * g) / 2;
                return (
                  <Line
                    key={g}
                    p1={vec(0, y)}
                    p2={vec(width, y)}
                    color={colors.border}
                    strokeWidth={1}
                  >
                    <DashPathEffect intervals={[3, 5]} />
                  </Line>
                );
              })}
              {area && fill ? (
                <Group clip={reveal}>
                  <Path path={fill}>
                    <LinearGradient
                      start={vec(0, TOP)}
                      end={vec(0, height)}
                      colors={[withAlpha(ink, 0.22), withAlpha(ink, 0)]}
                    />
                  </Path>
                </Group>
              ) : null}
              {line ? (
                <Path
                  path={line}
                  style="stroke"
                  strokeWidth={2.25}
                  strokeCap="round"
                  strokeJoin="round"
                  color={ink}
                  end={progress}
                />
              ) : null}
              <Group opacity={scrubbing}>
                <Line p1={guideFrom} p2={guideTo} color={withAlpha(ink, 0.35)} strokeWidth={1}>
                  <DashPathEffect intervals={[2, 3]} />
                </Line>
                <Circle c={markerC} r={11} color={withAlpha(ink, 0.16)} />
              </Group>
              <Circle c={markerC} r={haloR} color={ink} opacity={haloO} />
              <Group opacity={progress}>
                <Circle c={markerC} r={5.5} color={colors.surface} />
                <Circle c={markerC} r={3.75} color={ink} />
              </Group>
            </Canvas>
          </View>
          <Animated.View
            pointerEvents="none"
            onLayout={(e) => setLabelW(e.nativeEvent.layout.width)}
            style={[
              styles.bubble,
              {
                backgroundColor: colors.text,
                borderRadius: radius.sm,
                paddingHorizontal: space[2],
              },
              bubble,
            ]}
          >
            <Text
              style={{
                color: colors.background,
                fontSize: font.size.xs,
                fontWeight: font.weight.bold,
                fontVariant: ['tabular-nums'],
              }}
            >
              {active !== null ? formatValue(data[active]!) : ' '}
              {active !== null && labels?.[active] ? (
                <Text
                  style={{ fontWeight: font.weight.regular, opacity: 0.7 }}
                >{`  ${labels[active]}`}</Text>
              ) : null}
            </Text>
          </Animated.View>
        </View>
      </GestureDetector>
      {labels && labels.length > 1 ? (
        <View style={[styles.axis, { marginTop: space[2] }]}>
          {[labels[0], labels[Math.floor((labels.length - 1) / 2)], labels[labels.length - 1]].map(
            (l, i) => (
              <Text key={i} style={{ color: colors.textSubtle, fontSize: font.size.xs }}>
                {l}
              </Text>
            ),
          )}
        </View>
      ) : null}
    </View>
  );
}

const styles = StyleSheet.create({
  bubble: { position: 'absolute', top: 4, left: 0, paddingVertical: 5 },
  axis: { flexDirection: 'row', justifyContent: 'space-between' },
});