Story Card
Full-bleed stories with progress bars: tap to step, hold to pause, each picture drifting closer as it plays.
import { useEffect, useState, type ReactNode } from 'react';
import {
AccessibilityInfo,
Image,
Platform,
Pressable,
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type GestureResponderEvent,
type ImageSourcePropType,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
cancelAnimation,
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 { durations, spring, timing } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type Story = {
key: string;
image: ImageSourcePropType;
title?: string;
caption?: string;
/** Describes the picture. Defaults to the title. */
accessibilityLabel?: string;
};
export type StoryCardProps = {
stories: readonly Story[];
/** How long each story shows, in ms. Defaults to 5000. */
duration?: number;
/** Story to start on. Defaults to 0. */
initialIndex?: number;
onIndexChange?: (index: number) => void;
/** Called after the last story when `loop` is off. */
onEnd?: () => void;
/** Start again after the last story. Defaults to true. */
loop?: boolean;
/** Hold every story still, e.g. while the card is off screen. */
paused?: boolean;
/** Height divided by width. Defaults to 1.5. */
aspect?: number;
/** Laid over the top of the card, under the progress bars: a source, a time. */
header?: ReactNode;
/** A tick when stepping by hand. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/**
* Full-bleed stories with a progress bar for each one. Tap the right side for
* the next, the left for the previous, and hold anywhere to pause. Each
* picture drifts slowly closer while it shows, and the next fades in over it.
*/
export function StoryCard({
stories,
duration = 5000,
initialIndex = 0,
onIndexChange,
onEnd,
loop = true,
paused = false,
aspect = 1.5,
header,
haptics = true,
testID,
style,
}: StoryCardProps) {
const { colors, font, radius, space, scheme } = useKinetikTheme();
// Scrims are dark in both schemes: the text over them is always light, on a photo.
const ink = scheme === 'dark' ? colors.background : colors.text;
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const screenReader = useScreenReader();
const n = stories.length;
const [width, setWidth] = useState(0);
const [index, setIndex] = useState(() => Math.max(0, Math.min(n - 1, initialIndex)));
const [holding, setHolding] = useState(false);
const [ended, setEnded] = useState(false);
// Bumped whenever a bar restarts, so restarting the same story runs it again.
const [round, setRound] = useState(0);
// Auto-advance stops for screen readers: content must not move on before it is read.
const running = !paused && !holding && !ended && !screenReader && n > 0;
const progress = useSharedValue(0);
const press = useSharedValue(0);
const go = (next: number, byHand: boolean) => {
if (next < 0) {
// Before the first story: start it over.
progress.set(0);
setEnded(false);
setRound((r) => r + 1);
return;
}
if (next >= n) {
if (!loop) {
setEnded(true);
onEnd?.();
return;
}
next = 0;
}
if (byHand) haptic('tick');
progress.set(0);
setEnded(false);
setRound((r) => r + 1);
setIndex(next);
onIndexChange?.(next);
};
// Runs the current story's bar from wherever it stands, and moves on when it fills.
useEffect(() => {
if (!running) {
cancelAnimation(progress);
return;
}
const advance = () => go(index + 1, false);
const left = (1 - progress.get()) * duration;
progress.set(
withTiming(1, { duration: left, easing: Easing.linear }, (done) => {
'worklet';
if (done) scheduleOnRN(advance);
}),
);
// `go` reads the latest props on each render; the bar restarts only when these change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [running, index, round, duration, progress]);
const onPress = (e: GestureResponderEvent) => {
const { locationX } = e.nativeEvent;
// The left third goes back; the rest goes forward, as people expect from stories.
go(locationX < width / 3 ? index - 1 : index + 1, true);
};
const shell = useAnimatedStyle(() => ({ transform: [{ scale: 1 - press.get() * 0.015 }] }));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'increment') go(index + 1, true);
else if (e.nativeEvent.actionName === 'decrement') go(index - 1, true);
};
const story = stories[index];
return (
<Animated.View
testID={testID}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.card,
{ borderRadius: radius.xl, backgroundColor: colors.surfaceSunken },
width > 0 && { height: width * aspect },
style,
shell,
]}
>
{stories.map((s, i) => (
<Layer key={s.key} story={s} active={i === index} progress={progress} still={reduced} />
))}
<Pressable
onPress={onPress}
onLongPress={() => {}}
delayLongPress={220}
onPressIn={() => {
setHolding(true);
press.set(withSpring(1, spring('snappy', reduced)));
}}
onPressOut={() => {
setHolding(false);
press.set(withSpring(0, spring('smooth', reduced)));
}}
accessibilityRole="adjustable"
accessibilityLabel={story?.accessibilityLabel ?? story?.title ?? 'Story'}
accessibilityValue={{ text: `Story ${index + 1} of ${n}` }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
style={[StyleSheet.absoluteFill, styles.touch]}
/>
<View
pointerEvents="box-none"
style={[
styles.top,
{ padding: space[3], gap: space[3] },
gradient(`linear-gradient(180deg, ${withAlpha(ink, 0.5)} 0%, ${withAlpha(ink, 0)} 100%)`),
]}
>
<View style={styles.bars}>
{stories.map((s, i) => (
<Bar
key={s.key}
state={i < index ? 'done' : i > index ? 'next' : 'now'}
progress={progress}
/>
))}
</View>
{header}
</View>
{story?.title || story?.caption ? (
<View
pointerEvents="none"
style={[
styles.bottom,
{ padding: space[5], paddingTop: space[12] },
gradient(
`linear-gradient(180deg, ${withAlpha(ink, 0)} 0%, ${withAlpha(ink, 0.78)} 100%)`,
),
]}
>
<Caption key={story.key} still={reduced}>
{story.title ? (
<Text
style={{
color: colors.highlight,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
letterSpacing: -0.6,
}}
>
{story.title}
</Text>
) : null}
{story.caption ? (
<Text style={{ color: withAlpha(colors.highlight, 0.78), fontSize: font.size.sm }}>
{story.caption}
</Text>
) : null}
</Caption>
</View>
) : null}
</Animated.View>
);
}
/**
* True while a screen reader is running. The web cannot tell (react-native-web
* always answers yes), so there it is always false.
*/
function useScreenReader(): boolean {
const [on, setOn] = useState(false);
useEffect(() => {
if (Platform.OS === 'web') return;
let alive = true;
AccessibilityInfo.isScreenReaderEnabled()
.then((v) => alive && setOn(v))
.catch(() => {});
const sub = AccessibilityInfo.addEventListener('screenReaderChanged', setOn);
return () => {
alive = false;
sub.remove();
};
}, []);
return on;
}
/** One picture: it fades in over the last and drifts closer while its bar fills. */
function Layer({
story,
active,
progress,
still,
}: {
story: Story;
active: boolean;
progress: SharedValue<number>;
still: boolean;
}) {
const shown = useSharedValue(active ? 1 : 0);
useEffect(() => {
if (active) shown.set(withTiming(1, timing('slow', still)));
// The outgoing picture stays under the incoming one until it has fully arrived.
else shown.set(withDelay(still ? 0 : durations.slow, withTiming(0, { duration: 0 })));
}, [active, still, shown]);
const style = useAnimatedStyle(() => ({
opacity: shown.get(),
// The drift belongs to the picture showing; one that has left keeps its last framing.
transform: [{ scale: still ? 1 : 1.02 + (active ? progress.get() : 1) * 0.06 }],
}));
return (
<Animated.View
pointerEvents="none"
// The incoming picture sits on top.
style={[StyleSheet.absoluteFill, { zIndex: active ? 1 : 0 }, style]}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Image source={story.image} style={StyleSheet.absoluteFill} resizeMode="cover" />
</Animated.View>
);
}
function Bar({
state,
progress,
}: {
state: 'done' | 'now' | 'next';
progress: SharedValue<number>;
}) {
const { colors } = useKinetikTheme();
const fill = useAnimatedStyle(() => ({
width: `${(state === 'done' ? 1 : state === 'next' ? 0 : progress.get()) * 100}%`,
}));
return (
<View style={[styles.bar, { backgroundColor: withAlpha(colors.highlight, 0.3) }]}>
<Animated.View style={[styles.barFill, { backgroundColor: colors.highlight }, fill]} />
</View>
);
}
/** The caption rises in with each new story. */
function Caption({ children, still }: { children: ReactNode; still: boolean }) {
const t = useSharedValue(still ? 1 : 0);
useEffect(() => {
t.set(withSpring(1, spring('smooth', still)));
}, [still, t]);
const style = useAnimatedStyle(() => ({
opacity: t.get(),
transform: [{ translateY: (1 - t.get()) * 14 }],
}));
return <Animated.View style={[{ gap: 4 }, style]}>{children}</Animated.View>;
}
const styles = StyleSheet.create({
card: { overflow: 'hidden', width: '100%' },
touch: { zIndex: 2 },
top: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 3 },
bars: { flexDirection: 'row', gap: 4 },
bar: { flex: 1, height: 2.5, borderRadius: 2, overflow: 'hidden' },
barFill: { height: '100%', borderRadius: 2 },
bottom: { position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 3 },
});Installation
npx kinetik-ui add story-cardpnpm dlx kinetik-ui add story-cardbunx kinetik-ui add story-cardyarn dlx kinetik-ui add story-cardThe 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/story-card.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/story-card.tsx
Usage
import { StoryCard } from '@/components/kinetik/story-card';
<StoryCard
stories={[
{ key: 'dunes', image: require('./dunes.jpg'), title: 'Huacachina', caption: 'Day 1' },
{ key: 'sands', image: require('./sands.jpg'), title: 'White Sands', caption: 'Day 2' },
]}
header={<SourceRow />}
/>;The card takes the width it is given and sets its height from aspect (1.5 by default). header is laid over the top under the bars, for a source and a time.
When to use
- Short visual sequences people tap through: trip highlights, product launches, a recap.
- Onboarding told in pictures, where people set the pace.
When not to use
- Content people need to read closely or come back to. Use a list or a carousel they control.
- Long videos. This shows pictures; pair it with a video player for footage.
Notes
- Tap the left third to go back, anywhere else to go forward; hold to pause. Pass
pausedwhile the card is off screen. - While a screen reader runs, stories do not move on by themselves; people step with the adjust gesture.
- Every picture is mounted once and crossfaded, so keep the set short (up to about ten) and the images small.
- With reduced motion pictures switch without the fade or the drift.
Props
StoryCardProps
| Prop | Type | Default | Description |
|---|---|---|---|
storiesrequired | readonly Story[] | — | |
duration | number | 5000 | How long each story shows, in ms. Defaults to 5000. |
initialIndex | number | 0 | Story to start on. Defaults to 0. |
onIndexChange | (index: number) => void | — | |
onEnd | () => void | — | Called after the last story when `loop` is off. |
loop | boolean | true | Start again after the last story. Defaults to true. |
paused | boolean | false | Hold every story still, e.g. while the card is off screen. |
aspect | number | 1.5 | Height divided by width. Defaults to 1.5. |
header | ReactNode | — | Laid over the top of the card, under the progress bars: a source, a time. |
haptics | boolean | true | A tick when stepping by hand. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One timing value drives the bar and the drift on the UI thread; every picture is mounted once and crossfaded, so stepping never waits on a decode.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- Pictures switch without the fade or the drift; the bars still fill.
- Screen readers
- An adjustable element named after the story, valued 'Story 2 of 5'; swipe up or down to step. Stories do not advance on their own while a screen reader runs.
- Touch target
- The whole card: its left third steps back, the rest forward.
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/story-card.tsx
import { useEffect, useState, type ReactNode } from 'react';
import {
AccessibilityInfo,
Image,
Platform,
Pressable,
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type GestureResponderEvent,
type ImageSourcePropType,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
cancelAnimation,
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 { durations, spring, timing } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type Story = {
key: string;
image: ImageSourcePropType;
title?: string;
caption?: string;
/** Describes the picture. Defaults to the title. */
accessibilityLabel?: string;
};
export type StoryCardProps = {
stories: readonly Story[];
/** How long each story shows, in ms. Defaults to 5000. */
duration?: number;
/** Story to start on. Defaults to 0. */
initialIndex?: number;
onIndexChange?: (index: number) => void;
/** Called after the last story when `loop` is off. */
onEnd?: () => void;
/** Start again after the last story. Defaults to true. */
loop?: boolean;
/** Hold every story still, e.g. while the card is off screen. */
paused?: boolean;
/** Height divided by width. Defaults to 1.5. */
aspect?: number;
/** Laid over the top of the card, under the progress bars: a source, a time. */
header?: ReactNode;
/** A tick when stepping by hand. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/**
* Full-bleed stories with a progress bar for each one. Tap the right side for
* the next, the left for the previous, and hold anywhere to pause. Each
* picture drifts slowly closer while it shows, and the next fades in over it.
*/
export function StoryCard({
stories,
duration = 5000,
initialIndex = 0,
onIndexChange,
onEnd,
loop = true,
paused = false,
aspect = 1.5,
header,
haptics = true,
testID,
style,
}: StoryCardProps) {
const { colors, font, radius, space, scheme } = useKinetikTheme();
// Scrims are dark in both schemes: the text over them is always light, on a photo.
const ink = scheme === 'dark' ? colors.background : colors.text;
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const screenReader = useScreenReader();
const n = stories.length;
const [width, setWidth] = useState(0);
const [index, setIndex] = useState(() => Math.max(0, Math.min(n - 1, initialIndex)));
const [holding, setHolding] = useState(false);
const [ended, setEnded] = useState(false);
// Bumped whenever a bar restarts, so restarting the same story runs it again.
const [round, setRound] = useState(0);
// Auto-advance stops for screen readers: content must not move on before it is read.
const running = !paused && !holding && !ended && !screenReader && n > 0;
const progress = useSharedValue(0);
const press = useSharedValue(0);
const go = (next: number, byHand: boolean) => {
if (next < 0) {
// Before the first story: start it over.
progress.set(0);
setEnded(false);
setRound((r) => r + 1);
return;
}
if (next >= n) {
if (!loop) {
setEnded(true);
onEnd?.();
return;
}
next = 0;
}
if (byHand) haptic('tick');
progress.set(0);
setEnded(false);
setRound((r) => r + 1);
setIndex(next);
onIndexChange?.(next);
};
// Runs the current story's bar from wherever it stands, and moves on when it fills.
useEffect(() => {
if (!running) {
cancelAnimation(progress);
return;
}
const advance = () => go(index + 1, false);
const left = (1 - progress.get()) * duration;
progress.set(
withTiming(1, { duration: left, easing: Easing.linear }, (done) => {
'worklet';
if (done) scheduleOnRN(advance);
}),
);
// `go` reads the latest props on each render; the bar restarts only when these change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [running, index, round, duration, progress]);
const onPress = (e: GestureResponderEvent) => {
const { locationX } = e.nativeEvent;
// The left third goes back; the rest goes forward, as people expect from stories.
go(locationX < width / 3 ? index - 1 : index + 1, true);
};
const shell = useAnimatedStyle(() => ({ transform: [{ scale: 1 - press.get() * 0.015 }] }));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'increment') go(index + 1, true);
else if (e.nativeEvent.actionName === 'decrement') go(index - 1, true);
};
const story = stories[index];
return (
<Animated.View
testID={testID}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[
styles.card,
{ borderRadius: radius.xl, backgroundColor: colors.surfaceSunken },
width > 0 && { height: width * aspect },
style,
shell,
]}
>
{stories.map((s, i) => (
<Layer key={s.key} story={s} active={i === index} progress={progress} still={reduced} />
))}
<Pressable
onPress={onPress}
onLongPress={() => {}}
delayLongPress={220}
onPressIn={() => {
setHolding(true);
press.set(withSpring(1, spring('snappy', reduced)));
}}
onPressOut={() => {
setHolding(false);
press.set(withSpring(0, spring('smooth', reduced)));
}}
accessibilityRole="adjustable"
accessibilityLabel={story?.accessibilityLabel ?? story?.title ?? 'Story'}
accessibilityValue={{ text: `Story ${index + 1} of ${n}` }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={onAccessibilityAction}
style={[StyleSheet.absoluteFill, styles.touch]}
/>
<View
pointerEvents="box-none"
style={[
styles.top,
{ padding: space[3], gap: space[3] },
gradient(`linear-gradient(180deg, ${withAlpha(ink, 0.5)} 0%, ${withAlpha(ink, 0)} 100%)`),
]}
>
<View style={styles.bars}>
{stories.map((s, i) => (
<Bar
key={s.key}
state={i < index ? 'done' : i > index ? 'next' : 'now'}
progress={progress}
/>
))}
</View>
{header}
</View>
{story?.title || story?.caption ? (
<View
pointerEvents="none"
style={[
styles.bottom,
{ padding: space[5], paddingTop: space[12] },
gradient(
`linear-gradient(180deg, ${withAlpha(ink, 0)} 0%, ${withAlpha(ink, 0.78)} 100%)`,
),
]}
>
<Caption key={story.key} still={reduced}>
{story.title ? (
<Text
style={{
color: colors.highlight,
fontSize: font.size['2xl'],
fontWeight: font.weight.bold,
letterSpacing: -0.6,
}}
>
{story.title}
</Text>
) : null}
{story.caption ? (
<Text style={{ color: withAlpha(colors.highlight, 0.78), fontSize: font.size.sm }}>
{story.caption}
</Text>
) : null}
</Caption>
</View>
) : null}
</Animated.View>
);
}
/**
* True while a screen reader is running. The web cannot tell (react-native-web
* always answers yes), so there it is always false.
*/
function useScreenReader(): boolean {
const [on, setOn] = useState(false);
useEffect(() => {
if (Platform.OS === 'web') return;
let alive = true;
AccessibilityInfo.isScreenReaderEnabled()
.then((v) => alive && setOn(v))
.catch(() => {});
const sub = AccessibilityInfo.addEventListener('screenReaderChanged', setOn);
return () => {
alive = false;
sub.remove();
};
}, []);
return on;
}
/** One picture: it fades in over the last and drifts closer while its bar fills. */
function Layer({
story,
active,
progress,
still,
}: {
story: Story;
active: boolean;
progress: SharedValue<number>;
still: boolean;
}) {
const shown = useSharedValue(active ? 1 : 0);
useEffect(() => {
if (active) shown.set(withTiming(1, timing('slow', still)));
// The outgoing picture stays under the incoming one until it has fully arrived.
else shown.set(withDelay(still ? 0 : durations.slow, withTiming(0, { duration: 0 })));
}, [active, still, shown]);
const style = useAnimatedStyle(() => ({
opacity: shown.get(),
// The drift belongs to the picture showing; one that has left keeps its last framing.
transform: [{ scale: still ? 1 : 1.02 + (active ? progress.get() : 1) * 0.06 }],
}));
return (
<Animated.View
pointerEvents="none"
// The incoming picture sits on top.
style={[StyleSheet.absoluteFill, { zIndex: active ? 1 : 0 }, style]}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Image source={story.image} style={StyleSheet.absoluteFill} resizeMode="cover" />
</Animated.View>
);
}
function Bar({
state,
progress,
}: {
state: 'done' | 'now' | 'next';
progress: SharedValue<number>;
}) {
const { colors } = useKinetikTheme();
const fill = useAnimatedStyle(() => ({
width: `${(state === 'done' ? 1 : state === 'next' ? 0 : progress.get()) * 100}%`,
}));
return (
<View style={[styles.bar, { backgroundColor: withAlpha(colors.highlight, 0.3) }]}>
<Animated.View style={[styles.barFill, { backgroundColor: colors.highlight }, fill]} />
</View>
);
}
/** The caption rises in with each new story. */
function Caption({ children, still }: { children: ReactNode; still: boolean }) {
const t = useSharedValue(still ? 1 : 0);
useEffect(() => {
t.set(withSpring(1, spring('smooth', still)));
}, [still, t]);
const style = useAnimatedStyle(() => ({
opacity: t.get(),
transform: [{ translateY: (1 - t.get()) * 14 }],
}));
return <Animated.View style={[{ gap: 4 }, style]}>{children}</Animated.View>;
}
const styles = StyleSheet.create({
card: { overflow: 'hidden', width: '100%' },
touch: { zIndex: 2 },
top: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 3 },
bars: { flexDirection: 'row', gap: 4 },
bar: { flex: 1, height: 2.5, borderRadius: 2, overflow: 'hidden' },
barFill: { height: '100%', borderRadius: 2 },
bottom: { position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 3 },
});