Slope Chart
Before and after on two axes: lines draw in, rises in blue, and new data slides every end to its new place.
import { Canvas, Circle, Line, vec } from '@shopify/react-native-skia';
import { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type SlopeSeries = {
label: string;
/** Value on the left, e.g. last year. */
from: number;
/** Value on the right, e.g. this year. */
to: number;
};
export type SlopeChartProps = {
series: readonly SlopeSeries[];
/** Heading over the left column. Defaults to 'Before'. */
fromLabel?: string;
/** Heading over the right column. Defaults to 'After'. */
toLabel?: string;
/** Formats values beside the dots. Defaults to whole numbers. */
formatValue?: (value: number) => string;
/** Plot height, without the headings. Defaults to 240. */
height?: number;
/** What the chart shows, read first, e.g. "Share of trips by city". */
accessibilityLabel: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const LABEL_W = 92;
const ROW_H = 18;
const DOT = 4;
/** Spreads label positions apart so none overlap, keeping each as close to its dot as it can. */
function spread(ys: number[], min: number, max: number): number[] {
const order = ys.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y);
const out = order.map((o) => o.y);
for (let k = 1; k < out.length; k++) out[k] = Math.max(out[k]!, out[k - 1]! + ROW_H);
// Pushed past the bottom: walk back up.
const overflow = out.length ? out[out.length - 1]! - max : 0;
if (overflow > 0) for (let k = 0; k < out.length; k++) out[k] = out[k]! - overflow;
for (let k = out.length - 2; k >= 0; k--) out[k] = Math.min(out[k]!, out[k + 1]! - ROW_H);
for (let k = 0; k < out.length; k++) out[k] = Math.max(min, out[k]!);
const result = new Array<number>(ys.length);
order.forEach((o, k) => (result[o.i] = out[k]!));
return result;
}
/**
* Before and after on two axes. Each line draws itself from left to right,
* rising lines in blue and falling ones in grey, and new data slides every
* end to its new place. Tap a label to bring one line forward.
*/
export function SlopeChart({
series,
fromLabel = 'Before',
toLabel = 'After',
formatValue = (v) => String(Math.round(v)),
height = 240,
accessibilityLabel,
testID,
style,
}: SlopeChartProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const [width, setWidth] = useState(0);
const [focus, setFocus] = useState<number | null>(null);
const pad = ROW_H / 2 + 2;
const { yFrom, yTo, labelsFrom, labelsTo } = useMemo(() => {
const values = series.flatMap((s) => [s.from, s.to]);
const lo = Math.min(...values);
const hi = Math.max(...values);
const span = hi - lo || 1;
const y = (v: number) => pad + (1 - (v - lo) / span) * (height - pad * 2);
const a = series.map((s) => y(s.from));
const b = series.map((s) => y(s.to));
return {
yFrom: a,
yTo: b,
labelsFrom: spread(a, ROW_H / 2, height - ROW_H / 2),
labelsTo: spread(b, ROW_H / 2, height - ROW_H / 2),
};
}, [series, height, pad]);
const xL = LABEL_W;
const xR = Math.max(xL + 1, width - LABEL_W);
const summary = series
.map((s) => `${s.label}: ${formatValue(s.from)} to ${formatValue(s.to)}`)
.join('; ');
return (
<View testID={testID} style={style}>
<View style={styles.heads}>
{[fromLabel, toLabel].map((h, i) => (
<Text
key={h + i}
style={[
styles.head,
{
color: colors.textSubtle,
fontSize: font.size.xs,
fontWeight: font.weight.semibold,
},
i === 1 && styles.right,
]}
>
{h}
</Text>
))}
</View>
<View
accessible
accessibilityRole="image"
accessibilityLabel={`${accessibilityLabel}. ${fromLabel} to ${toLabel}. ${summary}`}
style={{ height }}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
>
{width > 0 ? (
<Canvas style={{ width, height }}>
<Line
style="stroke"
p1={vec(xL, 0)}
p2={vec(xL, height)}
color={colors.border}
strokeWidth={1}
/>
<Line
style="stroke"
p1={vec(xR, 0)}
p2={vec(xR, height)}
color={colors.border}
strokeWidth={1}
/>
{series.map((s, i) => (
<SlopeLine
key={s.label}
index={i}
xL={xL}
xR={xR}
yL={yFrom[i]!}
yR={yTo[i]!}
color={s.to >= s.from ? colors.azure : colors.textSubtle}
dimmed={focus !== null && focus !== i}
strong={focus === i}
reduced={reduced}
/>
))}
</Canvas>
) : null}
{width > 0
? series.map((s, i) => {
const rising = s.to >= s.from;
const dimmed = focus !== null && focus !== i;
const change = s.to - s.from;
return [
<Label
key={`${s.label}-l`}
y={labelsFrom[i]!}
side="left"
reduced={reduced}
dimmed={dimmed}
onPress={() => setFocus((f) => (f === i ? null : i))}
text={`${s.label} ${formatValue(s.from)}`}
color={colors.textMuted}
strong={focus === i}
/>,
<Label
key={`${s.label}-r`}
y={labelsTo[i]!}
side="right"
reduced={reduced}
dimmed={dimmed}
onPress={() => setFocus((f) => (f === i ? null : i))}
text={`${formatValue(s.to)} ${change >= 0 ? '+' : '−'}${formatValue(Math.abs(change))}`}
color={rising ? colors.azure : colors.textMuted}
strong={focus === i}
/>,
];
})
: null}
</View>
</View>
);
}
type SlopeLineProps = {
index: number;
xL: number;
xR: number;
yL: number;
yR: number;
color: string;
dimmed: boolean;
strong: boolean;
reduced: boolean;
};
function SlopeLine({ index, xL, xR, yL, yR, color, dimmed, strong, reduced }: SlopeLineProps) {
const a = useSharedValue(yL);
const b = useSharedValue(yR);
const draw = useSharedValue(reduced ? 1 : 0);
const fade = useSharedValue(1);
useEffect(() => {
if (reduced) {
draw.set(1);
return;
}
// Lines draw in one after another the first time.
draw.set(
withDelay(
index * 90,
withTiming(1, { duration: durations.slow * 1.4, easing: easings.standard }),
),
);
}, [index, reduced, draw]);
useEffect(() => {
a.set(withSpring(yL, spring('smooth', reduced)));
b.set(withSpring(yR, spring('smooth', reduced)));
}, [yL, yR, reduced, a, b]);
useEffect(() => {
fade.set(withTiming(dimmed ? 0.18 : 1, { duration: reduced ? 0 : durations.base }));
}, [dimmed, reduced, fade]);
const p1 = useDerivedValue(() => vec(xL, a.get()));
const p2 = useDerivedValue(() => {
const d = draw.get();
return vec(xL + (xR - xL) * d, a.get() + (b.get() - a.get()) * d);
});
const endR = useDerivedValue(() => DOT * Math.max(0, Math.min(1, (draw.get() - 0.85) / 0.15)));
return (
<>
<Line
style="stroke"
p1={p1}
p2={p2}
color={color}
strokeWidth={strong ? 3 : 2}
strokeCap="round"
opacity={fade}
/>
<Circle c={p1} r={DOT} color={color} opacity={fade} />
<Circle c={p2} r={endR} color={color} opacity={fade} />
</>
);
}
type LabelProps = {
y: number;
side: 'left' | 'right';
text: string;
color: string;
strong: boolean;
dimmed: boolean;
reduced: boolean;
onPress: () => void;
};
function Label({ y, side, text, color, strong, dimmed, reduced, onPress }: LabelProps) {
const { font } = useKinetikTheme();
const top = useSharedValue(y);
useEffect(() => {
top.set(withSpring(y, spring('smooth', reduced)));
}, [y, reduced, top]);
const style = useAnimatedStyle(() => ({ top: top.get() - ROW_H / 2 }));
return (
<Animated.View
style={[styles.label, side === 'left' ? styles.labelLeft : styles.labelRight, style]}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
>
<Pressable onPress={onPress} hitSlop={{ top: 13, bottom: 13 }}>
<Text
numberOfLines={1}
style={{
color,
opacity: dimmed ? 0.35 : 1,
fontSize: font.size.xs,
fontWeight: strong ? font.weight.bold : font.weight.medium,
textAlign: side === 'left' ? 'right' : 'left',
fontVariant: ['tabular-nums'],
}}
>
{text}
</Text>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
heads: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
// Each heading sits against its axis, like the labels under it.
head: { width: LABEL_W - 8, textAlign: 'right' },
right: { textAlign: 'left', width: LABEL_W - 8 },
label: { position: 'absolute', height: ROW_H, width: LABEL_W - 10, justifyContent: 'center' },
labelLeft: { left: 0 },
labelRight: { right: 0 },
});Installation
npx kinetik-ui add slope-chartpnpm dlx kinetik-ui add slope-chartbunx kinetik-ui add slope-chartyarn dlx kinetik-ui add slope-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/slope-chart.json
Install manually
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/slope-chart.tsx
Usage
import { SlopeChart } from '@/components/kinetik/slope-chart';
<SlopeChart
accessibilityLabel="Share of trips"
fromLabel="2025"
toLabel="2026"
series={[
{ label: 'Lisbon', from: 24, to: 31 },
{ label: 'Kyoto', from: 30, to: 22 },
]}
formatValue={(v) => `${v}%`}
/>;Each series is one line from its from value on the left to its to value on the right. The right-hand label shows the new value and the change.
When to use
- Comparing two moments for a handful of items: this year against last, before and after a change.
- When the direction and size of each change matters more than the path between.
When not to use
- More than eight or so series. Labels crowd and lines tangle; use a sorted bar chart of the change.
- Values over many points in time. Use Line Chart.
Notes
- Labels on each side are spread apart so they never overlap, while dots stay at their true values.
- Change
seriesand every end slides to its new place on a spring. Lines are keyed bylabel. - Tap a label to bring that line forward and dim the rest; tap it again to clear.
- With reduced motion the lines appear drawn and move without springs.
Props
SlopeChartProps
| Prop | Type | Default | Description |
|---|---|---|---|
seriesrequired | readonly SlopeSeries[] | — | |
accessibilityLabelrequired | string | — | What the chart shows, read first, e.g. "Share of trips by city". |
fromLabel | string | 'Before' | Heading over the left column. Defaults to 'Before'. |
toLabel | string | 'After' | Heading over the right column. Defaults to 'After'. |
formatValue | (value: number) => string | (v) => String(Math.round(v)) | Formats values beside the dots. Defaults to whole numbers. |
height | number | 240 | Plot height, without the headings. Defaults to 240. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One Skia canvas of lines and dots, driven by four shared values per series; labels are plain text placed by springs. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.90× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 154 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- Lines appear drawn and move straight to new values.
- Screen readers
- One image whose name reads the title, both column headings and every series from start to end value.
- Touch target
- Labels take taps with 13 pt of slop above and below, so 44 pt rows.
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/slope-chart.tsx
import { Canvas, Circle, Line, vec } from '@shopify/react-native-skia';
import { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type SlopeSeries = {
label: string;
/** Value on the left, e.g. last year. */
from: number;
/** Value on the right, e.g. this year. */
to: number;
};
export type SlopeChartProps = {
series: readonly SlopeSeries[];
/** Heading over the left column. Defaults to 'Before'. */
fromLabel?: string;
/** Heading over the right column. Defaults to 'After'. */
toLabel?: string;
/** Formats values beside the dots. Defaults to whole numbers. */
formatValue?: (value: number) => string;
/** Plot height, without the headings. Defaults to 240. */
height?: number;
/** What the chart shows, read first, e.g. "Share of trips by city". */
accessibilityLabel: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const LABEL_W = 92;
const ROW_H = 18;
const DOT = 4;
/** Spreads label positions apart so none overlap, keeping each as close to its dot as it can. */
function spread(ys: number[], min: number, max: number): number[] {
const order = ys.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y);
const out = order.map((o) => o.y);
for (let k = 1; k < out.length; k++) out[k] = Math.max(out[k]!, out[k - 1]! + ROW_H);
// Pushed past the bottom: walk back up.
const overflow = out.length ? out[out.length - 1]! - max : 0;
if (overflow > 0) for (let k = 0; k < out.length; k++) out[k] = out[k]! - overflow;
for (let k = out.length - 2; k >= 0; k--) out[k] = Math.min(out[k]!, out[k + 1]! - ROW_H);
for (let k = 0; k < out.length; k++) out[k] = Math.max(min, out[k]!);
const result = new Array<number>(ys.length);
order.forEach((o, k) => (result[o.i] = out[k]!));
return result;
}
/**
* Before and after on two axes. Each line draws itself from left to right,
* rising lines in blue and falling ones in grey, and new data slides every
* end to its new place. Tap a label to bring one line forward.
*/
export function SlopeChart({
series,
fromLabel = 'Before',
toLabel = 'After',
formatValue = (v) => String(Math.round(v)),
height = 240,
accessibilityLabel,
testID,
style,
}: SlopeChartProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const [width, setWidth] = useState(0);
const [focus, setFocus] = useState<number | null>(null);
const pad = ROW_H / 2 + 2;
const { yFrom, yTo, labelsFrom, labelsTo } = useMemo(() => {
const values = series.flatMap((s) => [s.from, s.to]);
const lo = Math.min(...values);
const hi = Math.max(...values);
const span = hi - lo || 1;
const y = (v: number) => pad + (1 - (v - lo) / span) * (height - pad * 2);
const a = series.map((s) => y(s.from));
const b = series.map((s) => y(s.to));
return {
yFrom: a,
yTo: b,
labelsFrom: spread(a, ROW_H / 2, height - ROW_H / 2),
labelsTo: spread(b, ROW_H / 2, height - ROW_H / 2),
};
}, [series, height, pad]);
const xL = LABEL_W;
const xR = Math.max(xL + 1, width - LABEL_W);
const summary = series
.map((s) => `${s.label}: ${formatValue(s.from)} to ${formatValue(s.to)}`)
.join('; ');
return (
<View testID={testID} style={style}>
<View style={styles.heads}>
{[fromLabel, toLabel].map((h, i) => (
<Text
key={h + i}
style={[
styles.head,
{
color: colors.textSubtle,
fontSize: font.size.xs,
fontWeight: font.weight.semibold,
},
i === 1 && styles.right,
]}
>
{h}
</Text>
))}
</View>
<View
accessible
accessibilityRole="image"
accessibilityLabel={`${accessibilityLabel}. ${fromLabel} to ${toLabel}. ${summary}`}
style={{ height }}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
>
{width > 0 ? (
<Canvas style={{ width, height }}>
<Line
style="stroke"
p1={vec(xL, 0)}
p2={vec(xL, height)}
color={colors.border}
strokeWidth={1}
/>
<Line
style="stroke"
p1={vec(xR, 0)}
p2={vec(xR, height)}
color={colors.border}
strokeWidth={1}
/>
{series.map((s, i) => (
<SlopeLine
key={s.label}
index={i}
xL={xL}
xR={xR}
yL={yFrom[i]!}
yR={yTo[i]!}
color={s.to >= s.from ? colors.azure : colors.textSubtle}
dimmed={focus !== null && focus !== i}
strong={focus === i}
reduced={reduced}
/>
))}
</Canvas>
) : null}
{width > 0
? series.map((s, i) => {
const rising = s.to >= s.from;
const dimmed = focus !== null && focus !== i;
const change = s.to - s.from;
return [
<Label
key={`${s.label}-l`}
y={labelsFrom[i]!}
side="left"
reduced={reduced}
dimmed={dimmed}
onPress={() => setFocus((f) => (f === i ? null : i))}
text={`${s.label} ${formatValue(s.from)}`}
color={colors.textMuted}
strong={focus === i}
/>,
<Label
key={`${s.label}-r`}
y={labelsTo[i]!}
side="right"
reduced={reduced}
dimmed={dimmed}
onPress={() => setFocus((f) => (f === i ? null : i))}
text={`${formatValue(s.to)} ${change >= 0 ? '+' : '−'}${formatValue(Math.abs(change))}`}
color={rising ? colors.azure : colors.textMuted}
strong={focus === i}
/>,
];
})
: null}
</View>
</View>
);
}
type SlopeLineProps = {
index: number;
xL: number;
xR: number;
yL: number;
yR: number;
color: string;
dimmed: boolean;
strong: boolean;
reduced: boolean;
};
function SlopeLine({ index, xL, xR, yL, yR, color, dimmed, strong, reduced }: SlopeLineProps) {
const a = useSharedValue(yL);
const b = useSharedValue(yR);
const draw = useSharedValue(reduced ? 1 : 0);
const fade = useSharedValue(1);
useEffect(() => {
if (reduced) {
draw.set(1);
return;
}
// Lines draw in one after another the first time.
draw.set(
withDelay(
index * 90,
withTiming(1, { duration: durations.slow * 1.4, easing: easings.standard }),
),
);
}, [index, reduced, draw]);
useEffect(() => {
a.set(withSpring(yL, spring('smooth', reduced)));
b.set(withSpring(yR, spring('smooth', reduced)));
}, [yL, yR, reduced, a, b]);
useEffect(() => {
fade.set(withTiming(dimmed ? 0.18 : 1, { duration: reduced ? 0 : durations.base }));
}, [dimmed, reduced, fade]);
const p1 = useDerivedValue(() => vec(xL, a.get()));
const p2 = useDerivedValue(() => {
const d = draw.get();
return vec(xL + (xR - xL) * d, a.get() + (b.get() - a.get()) * d);
});
const endR = useDerivedValue(() => DOT * Math.max(0, Math.min(1, (draw.get() - 0.85) / 0.15)));
return (
<>
<Line
style="stroke"
p1={p1}
p2={p2}
color={color}
strokeWidth={strong ? 3 : 2}
strokeCap="round"
opacity={fade}
/>
<Circle c={p1} r={DOT} color={color} opacity={fade} />
<Circle c={p2} r={endR} color={color} opacity={fade} />
</>
);
}
type LabelProps = {
y: number;
side: 'left' | 'right';
text: string;
color: string;
strong: boolean;
dimmed: boolean;
reduced: boolean;
onPress: () => void;
};
function Label({ y, side, text, color, strong, dimmed, reduced, onPress }: LabelProps) {
const { font } = useKinetikTheme();
const top = useSharedValue(y);
useEffect(() => {
top.set(withSpring(y, spring('smooth', reduced)));
}, [y, reduced, top]);
const style = useAnimatedStyle(() => ({ top: top.get() - ROW_H / 2 }));
return (
<Animated.View
style={[styles.label, side === 'left' ? styles.labelLeft : styles.labelRight, style]}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
>
<Pressable onPress={onPress} hitSlop={{ top: 13, bottom: 13 }}>
<Text
numberOfLines={1}
style={{
color,
opacity: dimmed ? 0.35 : 1,
fontSize: font.size.xs,
fontWeight: strong ? font.weight.bold : font.weight.medium,
textAlign: side === 'left' ? 'right' : 'left',
fontVariant: ['tabular-nums'],
}}
>
{text}
</Text>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
heads: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
// Each heading sits against its axis, like the labels under it.
head: { width: LABEL_W - 8, textAlign: 'right' },
right: { textAlign: 'left', width: LABEL_W - 8 },
label: { position: 'absolute', height: ROW_H, width: LABEL_W - 10, justifyContent: 'center' },
labelLeft: { left: 0 },
labelRight: { right: 0 },
});