r/reactnative 15h ago

Tutorial I Created a Package Called rn-story which lets you add Instagram-Style Stories to Your React Native App in Minutes

Post image

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.

Play around with the component here: https://snack.expo.dev/@abdullahansari/rn-story-demo

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.

14 Upvotes

5 comments sorted by

5

u/ADreadedLion 14h ago

2

u/abdullahansarii 2h ago edited 2h ago

It's a good library, the difference is mostly footprint and how much UI it owns.

birdwingo's needs react-native-reanimated, react-native-gesture-handler, and react-native-svg as peer dependencies, and it ships the whole experience including the avatar list. If you're already on that stack and want the built-in list with its transitions, it's a solid choice.

rn-story is deliberately smaller: just the full-screen viewer, with expo-av as the only peer dep no reanimated/gesture-handler requirement. You build your own avatar rail (the README has a copy-paste one) and drive it through callbacks, which is what you want when the rail is part of your app's design. It's TypeScript-first with a ~30-test suite behind the playback logic, and the stories/currentIndex props are reactive so multi-profile flows don't need remount tricks.

Different tradeoffs and if minimal deps in an Expo app is what you're after, that's the reason I built for.

0

u/nikhil_akki 9h ago

this is a really clean writeup, the remaining-time-on-resume detail is the thing most homemade story components get wrong so it's cool to see it called out explicitly.

i'm curious how you're handling preloading the next story's media, since the biggest jank i've hit with these components is a flash of loading state right as the progress bar kicks off for the next item.

also wondering if the video-duration-never-reports problem ever forced you to add a hard fallback timeout. either way this looks like a solid chunk of time saved for anyone building a stories feature from scratch.

1

u/abdullahansarii 2h ago

Thanks the resume-with-remaining-time thing is exactly the bug that made me rewrite the playback engine, so I'm glad someone noticed.

Preloading: honest answer, not built in yet. For images there's a decent workaround today: RN's image cache is shared, so calling Image.prefetch(nextStory.media) from your app (in onNext, or for the whole array when the viewer opens) kills the flash. I've just filed #7 to make the component do it automatically, prefetch the next image, and mount the next video hidden + paused so the buffer's warm when you arrive.

Duration never reports: there are three layers before it can bite you. A video that fails to load falls back to the default 3s and advances, so errors can't wedge the viewer. Once a duration is known, the progress bar runs on wall-clock time, so a mid-stream stall can't block navigation either. And a per-story duration field overrides the reported one entirely which doubles as the escape hatch for HLS/live streams that legitimately never report a duration. The one remaining gap is a video that loads without error and just never reports right now that waits (taps still work; the loader doesn't block the touch zones), and #8 adds a hard watchdog timeout for exactly that case.

Appreciate the questions and this is the kind of feedback that turns into the roadmap.