Timeline
Events along a rail: each node pops in turn, the line draws down to the next, and the text rises in beside it.
import { useEffect, type ReactNode } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type TimelineEvent = {
key: string;
title: string;
/** When it happened, already formatted, e.g. "09:41" or "Yesterday". */
time: string;
description?: string;
/** `current` rings the node in blue; `upcoming` leaves it hollow. Defaults to `done`. */
state?: 'done' | 'current' | 'upcoming';
/** Extra content under the description, e.g. a thumbnail or a button. */
children?: ReactNode;
};
export type TimelineProps = {
events: readonly TimelineEvent[];
/** Replays the entrance when it changes. */
playKey?: string | number;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const RAIL = 24;
const NODE = 12;
/** Time between one event arriving and the next. */
const STEP = 140;
/**
* Events in order along a rail. Each node pops in turn, the line draws down
* from it to the next, and the event's text rises in beside it, so the eye
* reads the story from the top.
*/
export function Timeline({ events, playKey, testID, style }: TimelineProps) {
const reduced = useReduceMotion();
return (
<View testID={testID} style={style}>
{events.map((event, i) => (
<Row
key={event.key}
event={event}
index={i}
last={i === events.length - 1}
reduced={reduced}
playKey={playKey}
/>
))}
</View>
);
}
type RowProps = {
event: TimelineEvent;
index: number;
last: boolean;
reduced: boolean;
playKey?: string | number;
};
function Row({ event, index, last, reduced, playKey }: RowProps) {
const { colors, font, space } = useKinetikTheme();
const state = event.state ?? 'done';
const node = useSharedValue(reduced ? 1 : 0);
const line = useSharedValue(reduced ? 1 : 0);
const text = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (reduced) {
node.set(1);
line.set(1);
text.set(1);
return;
}
node.set(0);
line.set(0);
text.set(0);
const at = index * STEP;
node.set(withDelay(at, withSpring(1, spring('bouncy'))));
text.set(withDelay(at + 40, withSpring(1, spring('smooth'))));
line.set(withDelay(at + 80, withTiming(1, { duration: STEP * 2, easing: easings.standard })));
}, [index, reduced, playKey, node, line, text]);
const dot = useAnimatedStyle(() => ({ transform: [{ scale: node.get() }] }));
const ring = useAnimatedStyle(() => ({
opacity: node.get() * 0.5,
transform: [{ scale: 0.5 + node.get() * 0.5 }],
}));
const fill = useAnimatedStyle(() => ({ height: `${Math.max(0, line.get()) * 100}%` }));
const body = useAnimatedStyle(() => ({
opacity: Math.min(1, text.get()),
transform: [{ translateX: (1 - text.get()) * 10 }],
}));
const nodeColor =
state === 'current' ? colors.azure : state === 'upcoming' ? 'transparent' : colors.ember;
const status = state === 'current' ? ', now' : state === 'upcoming' ? ', coming up' : '';
return (
<View style={styles.row}>
<View style={styles.rail}>
<View style={styles.nodeBox}>
{state === 'current' ? (
<Animated.View style={[styles.ring, { backgroundColor: colors.azureSoft }, ring]} />
) : null}
<Animated.View
style={[
styles.node,
{
backgroundColor: nodeColor,
borderColor: state === 'upcoming' ? colors.borderStrong : nodeColor,
},
dot,
]}
/>
</View>
{!last ? (
<View style={[styles.line, { backgroundColor: colors.border }]}>
<Animated.View
style={[
styles.lineFill,
{ backgroundColor: state === 'upcoming' ? colors.border : colors.borderStrong },
fill,
]}
/>
</View>
) : null}
</View>
<Animated.View style={[styles.body, { paddingBottom: last ? 0 : space[5] }, body]}>
{/* The text is one element; children stay outside it so their own controls stay reachable. */}
<View
accessible
accessibilityLabel={`${event.time}, ${event.title}${event.description ? `. ${event.description}` : ''}${status}`}
style={{ gap: 2 }}
>
<View style={styles.head}>
<Text
style={{
flex: 1,
color: state === 'upcoming' ? colors.textMuted : colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{event.title}
</Text>
<Text
style={{
color: state === 'current' ? colors.azure : colors.textSubtle,
fontSize: font.size.xs,
fontWeight: font.weight.medium,
fontVariant: ['tabular-nums'],
}}
>
{event.time}
</Text>
</View>
{event.description ? (
<Text style={{ color: colors.textMuted, fontSize: font.size.sm, lineHeight: 20 }}>
{event.description}
</Text>
) : null}
</View>
{event.children ? <View style={{ marginTop: space[2] }}>{event.children}</View> : null}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', gap: 12 },
rail: { width: RAIL, alignItems: 'center' },
nodeBox: { width: RAIL, height: 22, alignItems: 'center', justifyContent: 'center' },
ring: { position: 'absolute', width: RAIL, height: RAIL, borderRadius: RAIL / 2 },
node: { width: NODE, height: NODE, borderRadius: NODE / 2, borderWidth: 2 },
line: { flex: 1, width: 2, marginVertical: 4, borderRadius: 1, overflow: 'hidden' },
lineFill: { width: '100%' },
body: { flex: 1 },
head: { flexDirection: 'row', alignItems: 'baseline', gap: 8, minHeight: 22 },
});Installation
npx kinetik-ui add timelinepnpm dlx kinetik-ui add timelinebunx kinetik-ui add timelineyarn dlx kinetik-ui add timelineThe 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/timeline.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/timeline.tsx
Usage
import { Timeline } from '@/components/kinetik/timeline';
<Timeline
events={[
{ key: 'placed', title: 'Order placed', time: 'Mon 09:12' },
{ key: 'packed', title: 'Packed', time: 'Mon 16:40', description: 'Two prints and a frame.' },
{ key: 'transit', title: 'On its way', time: 'Today', state: 'current' },
{ key: 'delivered', title: 'Delivered', time: 'Thu', state: 'upcoming' },
]}
/>;Events are drawn in the order given. state sets the node: done (the default) is solid, current is blue with a soft ring, upcoming is hollow.
When to use
- The history of one thing: an order, a ticket, a trip, a document's versions.
- Short sequences, up to about a dozen events, read from top to bottom.
When not to use
- Long activity feeds. Use a list grouped by day.
- Steps people complete themselves. Use Step Tracker.
Notes
timeis shown as given, so format it the way your app formats dates.childrenadds content under an event, such as a thumbnail or a button; it keeps its own touch targets.- The entrance plays once; pass a new
playKeyto replay it. - With reduced motion every event is in place from the start.
Props
TimelineProps
| Prop | Type | Default | Description |
|---|---|---|---|
eventsrequired | readonly TimelineEvent[] | — | |
playKey | string | number | — | Replays the entrance when it changes. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Plain views with three shared values per event, played once on mount. 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 · 147 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- Every event is in place from the start.
- Screen readers
- Each event is one element that reads its time, title, description and whether it is happening now or coming up.
- Touch target
- Not interactive itself; content passed as children keeps its own targets.
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/timeline.tsx
import { useEffect, type ReactNode } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type TimelineEvent = {
key: string;
title: string;
/** When it happened, already formatted, e.g. "09:41" or "Yesterday". */
time: string;
description?: string;
/** `current` rings the node in blue; `upcoming` leaves it hollow. Defaults to `done`. */
state?: 'done' | 'current' | 'upcoming';
/** Extra content under the description, e.g. a thumbnail or a button. */
children?: ReactNode;
};
export type TimelineProps = {
events: readonly TimelineEvent[];
/** Replays the entrance when it changes. */
playKey?: string | number;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const RAIL = 24;
const NODE = 12;
/** Time between one event arriving and the next. */
const STEP = 140;
/**
* Events in order along a rail. Each node pops in turn, the line draws down
* from it to the next, and the event's text rises in beside it, so the eye
* reads the story from the top.
*/
export function Timeline({ events, playKey, testID, style }: TimelineProps) {
const reduced = useReduceMotion();
return (
<View testID={testID} style={style}>
{events.map((event, i) => (
<Row
key={event.key}
event={event}
index={i}
last={i === events.length - 1}
reduced={reduced}
playKey={playKey}
/>
))}
</View>
);
}
type RowProps = {
event: TimelineEvent;
index: number;
last: boolean;
reduced: boolean;
playKey?: string | number;
};
function Row({ event, index, last, reduced, playKey }: RowProps) {
const { colors, font, space } = useKinetikTheme();
const state = event.state ?? 'done';
const node = useSharedValue(reduced ? 1 : 0);
const line = useSharedValue(reduced ? 1 : 0);
const text = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
if (reduced) {
node.set(1);
line.set(1);
text.set(1);
return;
}
node.set(0);
line.set(0);
text.set(0);
const at = index * STEP;
node.set(withDelay(at, withSpring(1, spring('bouncy'))));
text.set(withDelay(at + 40, withSpring(1, spring('smooth'))));
line.set(withDelay(at + 80, withTiming(1, { duration: STEP * 2, easing: easings.standard })));
}, [index, reduced, playKey, node, line, text]);
const dot = useAnimatedStyle(() => ({ transform: [{ scale: node.get() }] }));
const ring = useAnimatedStyle(() => ({
opacity: node.get() * 0.5,
transform: [{ scale: 0.5 + node.get() * 0.5 }],
}));
const fill = useAnimatedStyle(() => ({ height: `${Math.max(0, line.get()) * 100}%` }));
const body = useAnimatedStyle(() => ({
opacity: Math.min(1, text.get()),
transform: [{ translateX: (1 - text.get()) * 10 }],
}));
const nodeColor =
state === 'current' ? colors.azure : state === 'upcoming' ? 'transparent' : colors.ember;
const status = state === 'current' ? ', now' : state === 'upcoming' ? ', coming up' : '';
return (
<View style={styles.row}>
<View style={styles.rail}>
<View style={styles.nodeBox}>
{state === 'current' ? (
<Animated.View style={[styles.ring, { backgroundColor: colors.azureSoft }, ring]} />
) : null}
<Animated.View
style={[
styles.node,
{
backgroundColor: nodeColor,
borderColor: state === 'upcoming' ? colors.borderStrong : nodeColor,
},
dot,
]}
/>
</View>
{!last ? (
<View style={[styles.line, { backgroundColor: colors.border }]}>
<Animated.View
style={[
styles.lineFill,
{ backgroundColor: state === 'upcoming' ? colors.border : colors.borderStrong },
fill,
]}
/>
</View>
) : null}
</View>
<Animated.View style={[styles.body, { paddingBottom: last ? 0 : space[5] }, body]}>
{/* The text is one element; children stay outside it so their own controls stay reachable. */}
<View
accessible
accessibilityLabel={`${event.time}, ${event.title}${event.description ? `. ${event.description}` : ''}${status}`}
style={{ gap: 2 }}
>
<View style={styles.head}>
<Text
style={{
flex: 1,
color: state === 'upcoming' ? colors.textMuted : colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{event.title}
</Text>
<Text
style={{
color: state === 'current' ? colors.azure : colors.textSubtle,
fontSize: font.size.xs,
fontWeight: font.weight.medium,
fontVariant: ['tabular-nums'],
}}
>
{event.time}
</Text>
</View>
{event.description ? (
<Text style={{ color: colors.textMuted, fontSize: font.size.sm, lineHeight: 20 }}>
{event.description}
</Text>
) : null}
</View>
{event.children ? <View style={{ marginTop: space[2] }}>{event.children}</View> : null}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', gap: 12 },
rail: { width: RAIL, alignItems: 'center' },
nodeBox: { width: RAIL, height: 22, alignItems: 'center', justifyContent: 'center' },
ring: { position: 'absolute', width: RAIL, height: RAIL, borderRadius: RAIL / 2 },
node: { width: NODE, height: NODE, borderRadius: NODE / 2, borderWidth: 2 },
line: { flex: 1, width: 2, marginVertical: 4, borderRadius: 1, overflow: 'hidden' },
lineFill: { width: '100%' },
body: { flex: 1 },
head: { flexDirection: 'row', alignItems: 'baseline', gap: 8, minHeight: 22 },
});