@fundar/data-scrolly-telling
v0.0.30
Published
A library for building scrollytelling experiences in Svelte in a declarative way.
Downloads
434
Readme
data-scrolly-telling
A library for building scrollytelling experiences in Svelte in a declarative way.
📑 Table of Contents
- data-scrolly-telling
🔧 Requirements
- Node.js >= 18
- pnpm >= 8
Install pnpm if needed:
npm install -g pnpm🚀 Setup
Clone the repo and install dependencies:
# Preparation
pnpm install
# Development and Git workflow
git status
git add .
git commit -m "..."
# NPM workflow
npm login
npm version patch
npm publish --access public🎯 Core Concepts
At a high level, a scrollytelling component in this library is composed of:
- A Scrolly container that orchestrates everything
- A layout (e.g. SideBySide) that defines the spatial structure
- Two main content streams:
- Data → visual layers (Layer)
- Telling → narrative steps (Step)
🔭 Basic Example
<script lang="ts">
import { Scrolly, SideBySide, Layer, Step } from '@fundar/data-scrolly-telling';
import { threshold } from '$lib/stores/threshold';
</script>
{#snippet data()}
<Layer fill>...</Layer>
{/snippet}
{#snippet telling()}
<Step threshold={$threshold}>...</Step>
<Step threshold={$threshold}>...</Step>
<Step threshold={$threshold}>...</Step>
{/snippet}
<Scrolly
{data}
{telling}
layout={SideBySide}
layoutProps={{
inverted: false,
gridColumns: '1fr 3fr',
offsetTop: '64px',
offsetBottom: '0px'
}}
cssClass="example-steps"
/>
<style>
:global(.example-steps) {
...
}
</style>🧠 Mental Model
Scrolly (Orchestrator)
The Scrolly component is the entry point and main orchestrator.
It is responsible for:
- Synchronizing scroll position with the active step
- Connecting telling (Steps) with data (Layers)
- Delegating rendering to a layout component
- Managing global configuration (offsets, CSS hooks, etc.)
Layout
A layout defines how the data and telling streams are arranged on screen.
In our example, SideBySide renders:
- Narrative content (Step) on one side
- Visual content (Layer) on the other
Layouts are:
- Pluggable
- Configurable via layoutProps
data snippet → Layer
The data snippet defines the visual stack.
- Composed of one or more
Layercomponents - Layers are typically:
- Backgrounds
- Images
- Videos
- Charts
- Maps
- Any visual element that reacts to scroll
Think of it as a scene graph that persists across steps.
telling snippet → Step
The telling snippet defines the narrative flow.
- Composed of multiple
Stepcomponents - Each
Steprepresents a scroll-triggered state - Steps can:
- Activate/deactivate layers
- Trigger transitions
- Drive animations via thresholds
This is one way to model your narrative timeline.
⚙️ Scrolly
The Scrolly component is the core orchestrator of the system. It connects scroll position with rendering, coordinates the layout, and exposes a timeline API for reacting to scroll progress.
🔍 Example usage
<Scrolly
{data}
{telling}
layout={SideBySide}
layoutProps={{
inverted: false,
gridColumns: '1fr 3fr',
offsetTop: '64px',
offsetBottom: '0px'
}}
cssClass="example-steps"
/>📦 Props
type ScrollyProps = {
layout: Component<any>;
layoutProps?: Record<string, any>;
telling: Snippet;
data?: Snippet;
timeline?: TimelineConfig;
cssClass?: string;
};layout
A Svelte component responsible for rendering the overall structure.
- Receives
tellinganddatasnippets - Receives an internal onTellingMount callback (used to measure scrollable area)
- May optionally implement setProgress(progress: number) to react to scroll
layoutProps
An object passed directly to the layout component.
Use this to configure layout-specific behavior (e.g. grid, offsets, orientation).
telling
A required snippet that defines the narrative flow.
- Typically composed of
Stepcomponents - Mounted inside the layout
- Used as the reference element to compute scroll progress
data
An optional snippet that defines the visual layer stack.
- Typically composed of
Layercomponents - Rendered alongside
tellingdepending on the layout
cssClass
Optional class applied to the layout container and its layers and steps.
- Useful for scoping styles
- Typically used with
:global(...)when styling from outside
[!NOTE] Details about available css variables are covered here.
timeline
An optional, low-level API for reacting to scroll progress.
While Step provides a declarative way to structure narrative state, timeline allows imperative control over scroll-driven behavior.
type TimelineConfig = TimelineEntry[];
type TimelineEntry =
| { at: number; callback: (progress: number) => void; once?: boolean }
| { on: 'progress'; callback: (progress: number) => void }
| { on: 'intersection'; callback: (progress: number) => void }
| { from: number; to: number; callback: (progress: number) => void };Entry type 1: Trigger (at)
Executes a callback when scroll progress crosses a specific point.
{
at: 0.5,
callback: (progress) => { ... },
once: true // default: true
}- Fires when crossing the threshold
- Supports forward/backward detection
- Can be configured to fire only once
Entry type 2: Interval (from / to)
Executes continuously while progress is within a range.
{
from: 0.2,
to: 0.6,
callback: (localProgress) => { ... }
}localProgressis normalized between0 → 1within the interval- Useful for animations tied to a segment of the scroll
Entry type 3: Progress (on: 'progress')
Executes on every scroll update while measuring how much of the scrolly container is visible in the viewport.
{
on: 'progress',
callback: (progress) => { ... }
}- Receives viewport intersection ratio (0 → 1)
- 0 = component completely outside viewport
- 1 = component fully occupying the viewport (or as much as possible)
- Useful for early entrance animations and viewport-driven effects
Entry type 4: Intersection (on: 'intersection')
Executes on every scroll update.
{
on: 'intersection',
callback: (intersection) => { ... }
}- Receives global progress (
0 → 1) - Lowest-level hook
Scroll Model:
Scrolly computes a normalized scroll progress (0 → 1) based on:
- The bounding box of the
tellingcontent - Viewport height
- Configurable top/bottom offsets (provided by the layout)
This progress value is:
- Propagated to the
timeline - Forwarded to the layout via
setProgress
🗒️ Notes
Scrollydoes not impose a specific storytelling model- You can combine:
- Declarative
Step-based flows - Imperative
timelinelogic
- Declarative
- Layouts remain fully decoupled from scroll computation
🎨 Layer
A Layer represents a visual element within the data stream.
It is the fundamental building block for rendering visuals (charts, images, maps, etc.) that persist and react across scroll.
It is rendered as an absolutely positioned element and participates in a shared visual stack (multiple layers can overlap and be composed to build complex scenes)
🔍 Example usage
Layer components must be defined inside the data snippet.
{#snippet data()}
<Layer fill>
...
</Layer>
{/snippet}Internally, Layer relies on a DataScope context provided by Scrolly. Using it outside this scope will result in an error.
📦 Props
type LayerProps = {
transition?: TransitionConfig;
position?: PositionConfig;
cssClasses?: string;
fill?: boolean;
children?: Snippet;
};children
Content rendered inside the layer.
- Can be any Svelte markup
- Typically visual components (SVG, canvas, images, etc.)
fill
fill?: boolean;If true, the layer expands to fill its container.
- Applies
position: absolutewith full inset - Commonly used for background layers
cssClasses
cssClasses?: string;Additional CSS classes applied to the root element.
- Useful for styling or targeting specific layers
position
position?: PositionConfig;Defines the static visual state of the layer.
type PositionConfig = {
x?: string | number;
y?: string | number;
scale?: number;
rotate?: string | number;
opacity?: number;
blur?: string;
};- Applied immediately on mount
- Typically used as a base state before transitions
transition
transition?: TransitionConfig;Defines how the layer animates.
type TransitionConfig = {
preset?: TransitionPreset;
opacity?: [number, number];
translateX?: [string, string];
translateY?: [string, string];
scale?: [number, number];
rotate?: [string, string];
blur?: [string, string];
duration?: string;
easing?: EasingFunction;
};- Applied via an internal animation adapter
- Enables interpolation between states
- Often driven indirectly by Step or timeline logic
[!NOTE] Details about animation presets and interpolation are covered in a later section.
[!WARNING] Do not use
Layerinside thetellingsnippet. Layers are not inherently tied to steps; they are global within the scrolly instance.
💬 Step
A Step represents a unit of narrative in the telling stream.
It defines when something happens during scroll and provides hooks (callbacks) to react to those moments.
🔍 Example usage
Step components must be defined inside the telling snippet.
{#snippet telling()}
<Step>
<p>First section</p>
</Step>
<Step>
<p>Second section</p>
</Step>
{/snippet}Internally, Step registers itself in the TellingScope, where it is observed and coordinated.
📦 Props
type StepProps = {
threshold?: ThresholdConfig;
symmetric?: SymmetricMode;
onEnter?: (entry: IntersectionObserverEntry) => void;
onExit?: (entry: IntersectionObserverEntry) => void;
onComplete?: (entry: IntersectionObserverEntry) => void;
onInactive?: (entry: IntersectionObserverEntry) => void;
onReturn?: (entry: IntersectionObserverEntry) => void;
onIntersection?: (ratio: number, entry: IntersectionObserverEntry) => void;
onProgress?: (progress: number, entry: IntersectionObserverEntry) => void;
showData?: boolean | 'force-refresh';
dvhHeightFactor?: number;
transition?: SlideTransition | null;
layout?: StepLayout;
container?: StepContainer;
cssClasses?: string;
children?: Snippet;
};threshold
type ThresholdConfig =
| { type: 'percentage'; value: number }
| { type: 'px'; value: number }
| { type: 'rootMargin'; value: string };Defines when the step becomes active (the active region).
percentage→ relative to viewport (e.g.50→ 50% = middle)px→ fixed offsetrootMargin→ full control usingIntersectionObserversemantics
This is the primary mechanism for aligning narrative timing with scroll.
Callbacks
Steps expose a set of lifecycle and continuous callbacks.
Discrete events
onEnter→ step crosses threshold entering into active regiononExit→ leaves threshold regiononComplete→ entire step crosses thresholdonInactive→ entire step moves above viewportonReturn→ re-enters from above (reverse scroll)
Continuous events
onIntersection(ratio)→ raw intersection ratio between [0, 1]. Percentage of the step which is visible and its inside the active region.onProgress(progress)→ normalized progress within the step. This should be 0 when firesonEnterand 1 when firesonInactive.
symmetric
type SymmetricMode = 'onEnter' | 'onInactive' | 'both';Controls how callbacks behave when scrolling backwards.
'onEnter'→ mirror enter behavior of (n)-step with exit behavior of (n+1)-step.'onInactive'→ mirror inactive behavior (n)-step with return behavior of (n+1)-step.'both'→ fully symmetric on enter and inactive behavior.
This is useful for reversible narratives with discrete events.
layout
type StepLayout =
| { type: 'inline' }
| { type: 'centered' }
| { type: 'columns'; columns: number; gap?: string };Defines how content inside the step is arranged.
inline→ default block flowcentered→ vertically + horizontally centered (Useful forSlidelayout).columns→ grid layout (Useful forSlidelayout).
container
type StepContainer = {
maxWidth?: string;
padding?: string;
};Controls the inner content width.
- Applied as a wrapper inside the step
- Useful for readable text layouts
[!NOTE] These props only apply to the
Slidelayout.
showData
showData?: boolean | 'force-refresh';Controls whether the shared data snippet is rendered for the current step.
true→ mounts the shared data layer for this step.'force-refresh'→ same astrue, but also forces a resize/layout refresh after mounting.
'force-refresh' is typically useful for the first step displaying data, ensuring responsive charts and layouts recompute correctly after being portaled into view.
dvhHeightFactor
dvhHeightFactor?: number;Multiplier applied to the layout-level dvhHeightPerStep.
This allows individual steps to occupy more or less virtual scroll height than the default.
Examples:
- 1 → default height
- 2 → step lasts twice as long
- 0.5 → shorter step
This exists primarily for the Slide layout, where sticky slide behavior can make scroll pacing feel less intuitive without fine-grained control.
transition
type SlideTransition = {
enter?: TransitionConfig | null;
exit?: TransitionConfig | null;
};
transition?: SlideTransition | null;Overrides the default enter/exit transitions defined by the Slide layout.
enter→ transition applied when the step becomes activeexit→ transition applied when the step leavesnull→ disables that transition completely
Useful for customizing step-level motion behavior independently from the layout defaults.
🎨 Styling
Steps expose a large set of CSS custom properties for styling.
Example:
:global([data-telling-step]) {
--step-padding-y: 4rem;
--step-max-width: 800px;
--step-background: white;
--step-border-radius: 8px;
}Available categories
- Spacing →
--step-padding-*,--step-margin-* - Typography →
--step-font-size,--step-line-height, etc. - Background →
--step-background,--step-background-color - Border →
--step-border-*,--step-border-radius - Layout →
--step-width,--step-height - Effects →
--step-opacity,--step-filter,--step-box-shadow
All styles are applied to the root <section> element.
You can define custom CSS classes that set these variables and apply them in two ways:
- Globally, by passing a class to
ScrollyviacssClass(useful for scoping styles across all steps) - Per step, by passing a class via the
cssClassesprop onStep.
[!TIP] If you are using TailwindCSS, you can use its classes too.
<script lang="ts">
import { Layer, Scrolly, Step, Overlay } from '@fundar/data-scrolly-telling';
</script>
{#snippet data()}
...
{/snippet}
{#snippet telling()}
...
<Step cssClasses="highlighted-step"> // specific custom css class for this step
...
</Step>
...
<Step cssClasses="bg-blue-500"> // specific tailwind class for this step
...
</Step>
...
<Step cssClasses="highlighted-step my-5"> // or both...
...
</Step>
...
{/snippet}
<Scrolly
{data}
{telling}
timeline={[]}
layout={Overlay}
layoutProps={{
offsetTop: '64px',
offsetBottom: '0px'
}}
cssClass="overlay-steps" // general styling for all steps
/>
<style>
:global(.overlay-steps) {
--step-padding: 1rem; // you can set general step's css variables
--step-color: white; // you can use normal css for this
--step-background-color: var(--color-base-300); // or you can use TailwindCSS variables too
--step-border-radius: 8px;
--step-max-width: 600px;
}
:global(.highlighted-step) {
background-color: gold;
}
</style>🧠 State Model
Each Step is internally modeled as a state machine.
States
idle→ below threshold, not yet activeintersect→ intersecting the threshold (active)completed→ entire step is above threshold and passed completion point (active)inactive→ entire step is above viewport
Transitions
idle → intersectintersect → completedintersect → idleintersect → inactivecompleted → intersectcompleted → inactiveinactive → intersect
FSM
stateDiagram-v2
[*] --> idle
idle --> intersect : onEnter
intersect --> completed : onComplete
intersect --> idle : onExit
intersect --> inactive : onInactive
completed --> intersect :
completed --> inactive : onInactive
inactive --> intersect : onReturn[!NOTE] Steps are observed and registered automatically via context.
[!TIP]
- Multiple callbacks can be combined within a single step.
- Prefer
Stepfor narrative structure, andScrolly'stimelinefor low-level control.
[!WARNING] Do not use
Stepinside thedatasnippet.
🧱 Layouts
A layout defines how data (Layer) and telling (Step) are arranged and rendered on screen.
While Scrolly handles scroll logic and synchronization, the layout is responsible for:
- Structuring the DOM
- Positioning
dataandtelling - Defining how visuals behave during scroll (e.g. sticky, overlay, transitions)
- Providing offsets used to compute scroll progress
Layouts are:
- Pluggable → passed via the
layoutprop inScrolly - Configurable → via
layoutProps - Reactive to scroll progress (optional) → layouts can implement
setProgress(progress)to respond to theScrollyengine and drive custom behaviors (e.g. transitions, slide-based narratives)
📦 All layouts receive the following props:
tellingsnippetdatasnippet (optional)offsetTop(default:'0px')offsetBottom(default:'0px')onTellingMount(callback used byScrolly)
[!NOTE] Layouts define how scroll space is computed via
offsetTop/offsetBottom
[!TIP] Some layouts (like
Slide) implement custom behavior viasetProgress
🧭 Available Layouts
📚 Overlay
Renders data and telling in the same spatial context, layered on top of each other.
datais sticky and remains fixed within the viewporttellingscrolls normally on top- Both layers can overlap using
z-index
Use case:
- Background visualizations with text scrolling above
- Classic scrollytelling pattern (map/chart + narrative)
📦 Specific props:
zIndexData(default:0)zIndexTelling(default:1)
📐 SideBySide
Renders data and telling as two columns in a grid.
datais stickytellingscrolls vertically- Layout is controlled via CSS grid
Use case:
- Analytical layouts
- Text + visualization side-by-side
📦 Specific props:
zIndexData(default:0)zIndexTelling(default:1)inverted(default:false-tellingto the left anddatato the right)gridColumns(default:1fr 1fr- half of the viewport for each snippet)
🎞️ Slide
Transforms the narrative into a sequence of full-screen slides.
- Steps are stacked and transitioned in/out
- Scroll is mapped to discrete slide changes
- Sticky viewport-based storytelling
Use case:
- Presentation-style storytelling
- Full-screen immersive narratives
- Data-driven slide sequences
📦 Specific props:
transition
transition?: SlideTransition;Defines the default transitions used by all steps inside the layout.
type SlideTransition = {
enter?: TransitionConfig | null;
exit?: TransitionConfig | null;
};Both enter and exit transitions can be configured independently.
Example:
transition={{
enter: { preset: 'slide-up' },
exit: { preset: 'fade' }
}}dvhHeightPerStep
dvhHeightPerStep?: number;Defines the default virtual scroll height assigned to each step.
This controls the overall pacing and total scroll length of the slide sequence.
Larger values make each slide remain active longer during scrolling.
[!TIP] Individual
Steps can override both transitions and virtual height using its own props:
transitiondvhHeightFactor
🎬 Transition system:
Transitions are built from composable animation properties.
A transition may use a predefined preset:
type TransitionPreset =
| 'fade'
| 'slide-up'
| 'slide-down'
| 'slide-left'
| 'slide-right'
| 'scale-in'
| 'scale-out'
| 'rotate'
| 'flip-x'
| 'flip-y'
| 'blur';Or fully custom animation values:
type TransitionConfig = {
preset?: TransitionPreset;
opacity?: [number, number];
translateX?: [string, string];
translateY?: [string, string];
scale?: [number, number];
rotate?: [string, string];
blur?: [string, string];
duration?: string;
easing?: EasingFunction;
};This allows combining presets with low-level overrides for fine-grained motion control.
🧩 Patterns
🌄 Parallax
Parallax is not a dedicated layout, but a composition pattern built on top of existing primitives.
It is typically implemented using:
Overlaylayout (for stacking layers)- Multiple
Layers (representing depth planes) - A
timeline(orStepcallbacks) to drive motion
Concept
Parallax works by moving elements at different speeds relative to scroll progress, creating a sense of depth.
- Background elements → move slowly
- Foreground elements → move faster
- Some elements may remain fixed
Guidelines
- Use
Overlayas the base layout to stack visual layers - Define each visual element as a
Layer - Bind scroll progress via
timeline - Map progress (
0 → 1) to visual properties (e.g.yposition,rotate,scale)
[!NOTE] There is no fixed structure: the effect emerges from how layers are orchestrated.
Example
- Import the required components:
<script lang="ts">
import { Layer, Scrolly, Step, Overlay } from '@fundar/data-scrolly-telling';
</script>- Define your
datasnippet using multipleLayers.
In this example, we create a simple space scene:
- A background of randomly generated stars
- Two visual layers (
MoonandEarth) whose positions depend on scroll progress
{#snippet data()}
<svg class="stars" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice">
{#each stars as s}
<circle cx={s.x} cy={s.y} r={s.r} fill="white" opacity={s.opacity} />
{/each}
</svg>
<Layer cssClasses="moon" position={{ y: `${moonY}vh` }}>
<img src="/img/moon.png"/>
</Layer>
<Layer cssClasses="earth" position={{ y: `${earthY}vh` }}>
<img src="/img/earth.png"/>
</Layer>
{/snippet}
<style>
.stars {
position: absolute;
background-color: black;
inset: 0;
width: 100%;
height: 100%;
}
</style>- Derive layer positions from a reactive
currentProgressvalue.
Each layer moves at a different rate, creating the parallax effect.
<script lang="ts">
...
let currentProgress = $state(0);
const moonY = $derived(50 - currentProgress * 20);
const earthY = $derived(30 - currentProgress * 120);
</script>- Define the
tellingsnippet withSteps.
{#snippet telling()}
<Step threshold={$threshold}>...</Step>
<Step threshold={$threshold}>...</Step>
<Step threshold={$threshold}>...</Step>
{/snippet}- Connect scroll progress of
Scrollyto your reactive state.
<Scrolly
{data}
{telling}
timeline={[{ on: 'progress', callback: (p) => currentProgress = p }]}
layout={Overlay}
/>