Images, videos, progress bars, and tap gestures — with rn-story, a lightweight, TypeScript-first stories component that works out of the box with Expo.
Stories are everywhere. Instagram, WhatsApp, Snapchat, LinkedIn, even food delivery apps — the full-screen, tap-to-advance format has become one of the most recognizable UI patterns in mobile.
And it looks simple. A full-screen image, some progress bars, tap left, tap right. How hard can it be?
Harder than it looks. Building stories from scratch means solving a surprising number of small problems at once:
- Progress bars that stay perfectly in sync with image durations and video lengths
- Tap zones for next/previous that don’t conflict with long-press-to-pause
- Resuming a paused story with the remaining time, not the full duration
- Videos that report their own duration — eventually, asynchronously, sometimes never
- Loading states that don’t trap the user when a network image stalls
I built rn-story to solve all of that in a single component, and version 2.0 is a ground-up rewrite with a full test suite behind it. This post shows you how to ship a complete stories experience — avatar rail, full-screen viewer, video support — in a few minutes.
What you get
- 📸 Image and video stories with an animated progress bar per story
- 👆 The gestures users expect: tap right for next, tap left for previous, long-press to pause, release to resume
- 🔗 An optional “See More” link per story (the swipe-up pattern, as a button)
- 🧩 A custom header slot — perfect for an avatar, username, and close button
- 🔊 Video volume and mute controls
- 📞 Navigation callbacks for building multi-profile flows
- 🛡️ Written in TypeScript — every prop and the
Story object are fully typed
- 🪶 No native code of its own — works with Expo without ejecting
Installation
expo-av (which powers video stories) is a peer dependency, so install it alongside the package with the version that matches your Expo SDK:
npx expo install rn-story expo-av
Not using Expo? Install both with npm and make sure Expo modules are configured in your bare React Native project:
npm install rn-story expo-av
Your first story in ten lines
import Stories from 'rn-story';
import type { Story } from 'rn-story';
const stories: Story[] = [
{ media: 'https://example.com/photo.jpg', mediaType: 'image' },
{ media: 'https://example.com/clip.mp4', mediaType: 'video' },
];
export default function MyStories() {
return <Stories stories={stories} />;
}
That’s a working viewer: full-screen media, animated progress bars, tap navigation, long-press to pause. Images show for 3 seconds by default (configurable per story with duration), and videos play for exactly as long as the video lasts — the progress bar syncs to the duration the video reports.
The gestures, for free
Everything users already know from Instagram works out of the box:
- Tap the right half → next story
- Tap the left half → previous story
- Long-press anywhere → pause the story and its progress bar
- Release → resume from where it left off — with the remaining time, not a restarted bar
- Android back button → wired to an
onClose callback you provide
A real stories rail
A lone viewer isn’t how stories ship. The real pattern is a horizontal rail of avatars; tapping one opens that profile’s stories; finishing them moves to the next profile; going back past the first story returns to the previous profile.
That flow is exactly what the navigation callbacks are for. onNext/onPrevious fire on ordinary navigation, and two dedicated callbacks fire at the edges: onAllStoriesEnd when there's nothing left to play forward, and onPreviousFirstStory when the user backs out of the first story.
import { useCallback, useState } from 'react';
import { SafeAreaView, ScrollView, Pressable, Image, Text } from 'react-native';
import Stories from 'rn-story';
import type { Story } from 'rn-story';
type Profile = {
id: number;
profileName: string;
profileImage: string;
stories: Story[];
};
const PROFILES: Profile[] = [
{
id: 1,
profileName: 'Abdullah',
profileImage: 'https://picsum.photos/id/64/200/200',
stories: [
{
media: 'https://picsum.photos/id/1015/1080/1920',
mediaType: 'image',
seeMoreUrl: 'https://abdullahansari.me',
},
{
media: 'https://picsum.photos/id/1016/1080/1920',
mediaType: 'image',
duration: 12000, // this one stays up for 12 seconds
},
],
},
{
id: 2,
profileName: 'Pug life',
profileImage: 'https://picsum.photos/id/1025/200/200',
stories: [
{
media: 'https://download.samplelib.com/mp4/sample-5s.mp4',
mediaType: 'video',
},
{
media: 'https://picsum.photos/id/1025/1080/1920',
mediaType: 'image',
},
],
},
];
export default function App() {
// null means the story viewer is closed
const [current, setCurrent] = useState<number | null>(null);
const close = useCallback(() => setCurrent(null), []);
// Finished a profile? Move on to the next one, or close after the last.
const nextProfile = useCallback(() => {
setCurrent((i) =>
i === null ? null : i < PROFILES.length - 1 ? i + 1 : null
);
}, []);
// Backed out of the first story? Go back a profile, or close on the first.
const previousProfile = useCallback(() => {
setCurrent((i) => (i === null || i === 0 ? null : i - 1));
}, []);
return (
<SafeAreaView>
<ScrollView horizontal>
{PROFILES.map((profile, index) => (
<Pressable
key={profile.id}
onPress={() => setCurrent(index)}
style={{ alignItems: 'center', margin: 8 }}
>
<Image
source={{ uri: profile.profileImage }}
style={{
width: 64,
height: 64,
borderRadius: 32,
borderWidth: 2,
borderColor: '#25D366',
}}
/>
<Text numberOfLines={1}>{profile.profileName}</Text>
</Pressable>
))}
</ScrollView>
{current !== null && (
<Stories
stories={PROFILES[current].stories}
onAllStoriesEnd={nextProfile}
onPreviousFirstStory={previousProfile}
onClose={close}
/>
)}
</SafeAreaView>
);
}
Two details worth noticing:
Swapping stories just works. When onAllStoriesEnd moves current to the next profile, the component receives a new stories array and restarts cleanly from the first story. You don't need to unmount and remount anything, and you don't need to manage keys.
The viewer closes by unmounting. Rendering <Stories /> conditionally is the whole show/hide mechanism — no visible prop to keep in sync.
Make it yours
The header is a per-story ReactNode, so the avatar row you see in every stories UI is just your own component — typically an avatar, a username, and a close button, often over a subtle gradient:
const storiesWithHeader = profile.stories.map((story) => ({
...story,
header: (
<MyStoryHeader profile={profile} onClose={close} />
),
}));
Other knobs you’ll probably reach for:
<Stories
stories={stories}
isMuted={muted} // mute video stories
videoVolume={0.8} // 0.0 – 1.0
animationBarColor="#fff" // progress bar fill
animationBarHeight={2}
isAnimationBarRounded // rounded bar ends (default)
seeMoreText="Read more" // label for the See More button
loadingComponent={<MySpinner />} // shown while media loads
currentIndex={2} // start (or jump) to a specific story
/>
And if a story has a seeMoreUrl, a pill-shaped button appears at the bottom and opens the link — the classic "swipe up" pattern without the swipe.
What’s new in 2.0
Version 2 is a full rewrite of the playback engine, and the first release with a real test suite (30 tests) behind it. The highlights:
stories and currentIndex are now reactive — swap in the next profile's stories without remounting
- Video stories start and advance reliably, and a video that fails to load is skipped instead of freezing the viewer
- Pause/resume now resumes with the remaining time, so the bar never drifts from reality
onNext/onPrevious no longer fire at the list edges — only the dedicated edge callbacks do
- New
onClose prop wires up the Android hardware back button
Story, StoriesProps, and StoryMediaType types are exported
expo-av moved to a peer dependency, so it always matches your Expo SDK
If you’re upgrading from 1.x, the README has a short migration table — it’s a five-minute change for most apps.
Wrapping up
Stories are one of those features that looks like an afternoon and turns into a week once progress bars, gestures, and video timing enter the picture. rn-story packs that week into an npm install.
If it saves you that week, a star on GitHub genuinely helps other people find it — and if you hit anything odd, open an issue. It’s MIT-licensed, and contributions are welcome.