@matthesketh/react-storyteller
v1.2.1
Published
React 19 scroll-driven storytelling. Three layouts, step callbacks, smooth progress tracking, zero dependencies.
Maintainers
Readme
@matthesketh/react-storyteller
Scroll-driven storytelling for React 19. Three layout modes, step callbacks, smooth progress tracking -- zero runtime dependencies.
Features
- Three layouts:
side-by-side,overlay, andstacked - Step callbacks:
onEnterandonExitper step or globally - Progress tracking: smooth 0–1 value within the active step
- Zero dependencies: no runtime deps beyond React itself
- TypeScript-first: full type definitions included
- React 19 native: built against React 19 with no compatibility shims
- Minimal CSS: opt-in stylesheet, fully overridable
Install
npm install @matthesketh/react-storytellerQuick Start
import { Storyteller } from '@matthesketh/react-storyteller';
import '@matthesketh/react-storyteller/styles';
const steps = [
{ content: <p>Step one: introduce the subject.</p> },
{ content: <p>Step two: show the change.</p> },
{ content: <p>Step three: reveal the outcome.</p> },
];
export function MyStory() {
return (
<Storyteller
steps={steps}
graphic={({ stepIndex }) => (
<div className="graphic">
<strong>Step {stepIndex + 1}</strong>
</div>
)}
/>
);
}API
StorytellerProps
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | StorytellerStep[] | — | Array of step definitions |
| graphic | (props: GraphicRenderProps) => ReactNode | — | Render prop for the sticky graphic panel |
| layout | 'side-by-side' \| 'overlay' \| 'stacked' | 'side-by-side' | Layout mode |
| progress | boolean | false | Enable per-step scroll progress tracking |
| snap | boolean \| 'proximity' \| 'mandatory' | false | Enable CSS scroll-snap on steps |
| hashSync | boolean \| string | false | Sync active step to URL hash (#step-1) |
| offset | number | 0.5 | Viewport trigger point (0 = top, 1 = bottom) |
| onStepEnter | (context: StepContext) => void | — | Fires when any step enters the trigger point |
| onStepExit | (context: StepContext) => void | — | Fires when any step exits the trigger point |
| className | string | — | Class added to the outer container element |
| graphicClassName | string | — | Class added to the graphic panel element |
| stepClassName | string | — | Class added to every step element |
| activeStepClassName | string | 'is-active' | Class added to the currently active step element |
StorytellerStep
| Property | Type | Required | Description |
|---|---|---|---|
| content | ReactNode | Yes | The content rendered inside the step |
| onEnter | (context: StepContext) => void | No | Fires when this step enters the trigger point |
| onExit | (context: StepContext) => void | No | Fires when this step exits the trigger point |
| className | string | No | Additional class applied to this step's element |
GraphicRenderProps
| Property | Type | Description |
|---|---|---|
| stepIndex | number | Index of the currently active step (-1 before any step is active) |
| progress | number | Scroll progress within the active step (0–1); requires progress prop |
| direction | ScrollDirection | Last scroll direction: 'up' or 'down' |
StepContext
| Property | Type | Description |
|---|---|---|
| index | number | Index of the step that triggered the callback |
| direction | ScrollDirection | Scroll direction at the time of the event |
| progress | number | Scroll progress at the time of the event |
Layouts
side-by-side (default)
The graphic is sticky on one side while steps scroll past on the other. Best for data visualisations and annotated diagrams.
<Storyteller
layout="side-by-side"
steps={steps}
graphic={({ stepIndex }) => <Chart activeIndex={stepIndex} />}
/>overlay
The graphic fills the full viewport and the steps scroll over it. Best for full-bleed imagery or video backgrounds.
<Storyteller
layout="overlay"
steps={steps}
graphic={({ stepIndex }) => <FullBleedImage src={images[stepIndex]} />}
/>stacked
The graphic sits above the steps in normal document flow — no sticky positioning. Best for mobile-first layouts or simpler stories where a fixed panel is not needed.
<Storyteller
layout="stacked"
steps={steps}
graphic={({ stepIndex }) => <Summary index={stepIndex} />}
/>Progress Tracking
Enable the progress prop to receive a continuous 0–1 value representing how far the user has scrolled through the active step. Use it to drive animations tied directly to scroll position.
<Storyteller
steps={steps}
progress
graphic={({ stepIndex, progress }) => (
<div
style={{
opacity: progress,
transform: `scale(${0.8 + progress * 0.2})`,
}}
>
<Chart activeIndex={stepIndex} />
</div>
)}
/>progress is 0 at the start of the step and 1 at the end. It is only calculated for the currently active step.
Snap Scrolling
Enable the snap prop to make steps snap into position as the user scrolls, similar to Apple's product pages. Each step locks into the viewport centre, ensuring clean transitions between steps.
<Storyteller
steps={steps}
snap
graphic={({ stepIndex }) => <Chart activeIndex={stepIndex} />}
/>snap accepts three values:
trueor'proximity'-- snaps when the user stops scrolling near a step boundary (recommended)'mandatory'-- always snaps to the nearest step, even mid-scroll
Under the hood this sets scroll-snap-type on <html> and scroll-snap-align: center on each step. The style is cleaned up when the component unmounts.
URL Hash Sync
Enable the hashSync prop to sync the active step to the URL hash. Users can share deep links to specific points in a story, and the page will scroll to the correct step on load.
<Storyteller
steps={steps}
hashSync
graphic={({ stepIndex }) => <Chart activeIndex={stepIndex} />}
/>As the user scrolls, the URL updates to #step-1, #step-2, etc. On page load, if the URL already contains a matching hash, the page scrolls to that step automatically.
Pass a string to customise the prefix:
<Storyteller hashSync="scene-" ... />
// URL becomes #scene-1, #scene-2, etc.Uses history.replaceState so it doesn't pollute browser history.
Tailwind CSS
If you use Tailwind, you can replace the base stylesheet with the Tailwind plugin. It registers the same structural classes as Tailwind components and utilities.
// tailwind.config.js
import { storytellerPlugin } from '@matthesketh/react-storyteller/tailwind';
export default {
plugins: [storytellerPlugin()],
};Then remove the CSS import -- the plugin provides all rst-* classes via Tailwind's layer system. You can extend them with Tailwind utilities as normal:
<Storyteller
className="max-w-7xl mx-auto"
stepClassName="px-8 py-12"
graphicClassName="rounded-xl overflow-hidden"
steps={steps}
graphic={graphic}
/>The plugin includes:
- Components:
.rst-side-by-side,.rst-overlay,.rst-stacked,.rst-graphic--sticky,.rst-step,.rst-steps,.rst-sr-only - Utilities:
.rst-step--snap,.rst-snap-proximity,.rst-snap-mandatory
Framer Motion
The graphic render prop works naturally with framer-motion. Wrap your graphic content in motion.div and use stepIndex as the animation key:
import { motion, AnimatePresence } from 'framer-motion';
<Storyteller
steps={steps}
graphic={({ stepIndex }) => (
<AnimatePresence mode="wait">
<motion.div
key={stepIndex}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.4 }}
>
<Chart step={stepIndex} />
</motion.div>
</AnimatePresence>
)}
/>For scroll-linked animations, combine progress with motion.div style props:
<Storyteller
progress
steps={steps}
graphic={({ stepIndex, progress }) => (
<motion.div
animate={{ scale: 0.8 + progress * 0.2, opacity: 0.5 + progress * 0.5 }}
transition={{ type: 'tween', duration: 0 }}
>
<Chart step={stepIndex} />
</motion.div>
)}
/>No additional dependencies are required from this package -- framer-motion is optional and only needed if you choose to use it.
Styles
Import the bundled stylesheet once at your app entry point:
import '@matthesketh/react-storyteller/styles';The stylesheet provides:
- Layout structure for all three modes (
rst-side-by-side,rst-overlay,rst-stacked) - Sticky positioning for the graphic panel in
side-by-sideandoverlaymodes - Sensible defaults for step spacing
To override styles, target the BEM-style class names directly in your own CSS:
.rst-side-by-side {
gap: 4rem;
}
.rst-graphic {
background: #f5f5f5;
}
.rst-step.is-active {
font-weight: bold;
}Comparison
| Feature | react-storyteller | react-scrollama | @bsmnt/scrollytelling | |---|---|---|---| | React 19 | Yes | No | No | | TypeScript | Native | Separate types | Partial | | Built-in layouts | Yes (3) | No | No | | Snap scrolling | Yes | No | No | | URL hash sync | Yes | No | No | | Tailwind plugin | Yes | No | No | | Progress tracking | Yes | Yes | Yes | | Runtime deps | None | d3-selection | GSAP | | Config-driven steps | Yes | No | No |
License
MIT
