057

Editorial Scrollytelling

£10 or the whole Vault, £99

A sticky editorial media rail that morphs between section-defined aspect ratios with Flip, directional image wipes, layered drift and a velocity-reactive settle while the prose stays in normal document flow.

ScrollTriggerFlip Lenis advanced
4 more details
flip-layoutsticky-scroll-storymask-revealvelocity

This demo reads better at your own screen size than in the frame below:

Updated

About this effect

A reusable editorial scrollytelling effect built around a media handoff rather than a crossfade. The typography, palette and article copy can be replaced; the product is the sticky media rail, shape morph, directional wipe and layered settle attached through documented data hooks. The prose remains in ordinary document flow, so a section can carry several real paragraphs rather than a single caption.

Read the full effect overview

When the next section takes over, the picture panel does not fade. Every section declares a different shape, and GSAP Flip morphs the panel's real width and height from the outgoing shape into the incoming one, so the photograph reflows into the new frame instead of being stretched into it. The demo opens on a 21:9 panorama and steps through portrait, 3:2 and square, so the first handoff a reader sees is the largest change the effect makes.

The morph overshoots its target and relaxes into it, the new photograph wipes in behind a moving mask whose direction follows the scroll direction, and the section markers move on the frame the shape lands. Scrolling back reverses all three.

Nothing is pinned and nothing is clipped, so the article is readable at any resting scroll position, prints, and survives with no JavaScript at all. Scroll velocity adds a clamped skew and stretch to the panel that springs back out. Section count is read from the DOM, so adding or removing a section re-fits the article on refresh.

Editorial Scrollytelling - GSAP animation effect preview

What's included

8 items
  • A sticky media rail beside prose in ordinary document flow, holding each photograph for however long its section takes to read
  • Shape-to-shape handoffs with Flip animating real width and height, so each photograph reflows into its new crop instead of stretching
  • Direction-aware mask wipes, a moving accent edge and a slower image drift that settles after the frame for a layered transition
  • One replaceable image, picture or video slot per section, with six aspect-ratio presets and support for custom CSS-defined shapes
  • One ScrollTrigger per section with function-based handoff points, so variable-length chapters and scroll reversals need no fixed timeline
  • Velocity-reactive skew, stretch and lag on fine pointers, clamped and released with an elastic settle
  • A stacked mobile composition with the same shape morph, plus keyboard-operable section controls and an aria-live announcement
  • Complete reduced-motion and no-JavaScript fallbacks that preserve every photograph and paragraph in readable document order

Perfect for

5 use cases
  • News features and long-reads where each beat is carried by a photograph in a different crop, opening on a panorama
  • Brand and magazine storytelling that needs several paragraphs per image rather than a caption
  • Case studies and annual reports where the supporting image changes format from section to section
  • Documentary or campaign pages built around a commissioned photography set
  • Editorial product stories where the writing has to stay readable, printable and indexable

How it works

4 sections

The article is ordinary document flow and the picture column is a `position: sticky` block beside it, so the photograph holds for exactly as long as its section's prose takes to pass. Nothing is pinned, nothing is translated and nothing is clipped, which is what allows several paragraphs per section and what makes the page correct with no JavaScript at all.

One ScrollTrigger per section spans that section's own prose, starting and ending at a handover line measured from the viewport. A section takes the picture over when its first line crosses that line going down and takes it back when its last line crosses it going up, so a reversal is the same code path as an advance, and a section that is four paragraphs long simply holds for longer. The line is recalculated on refresh, so a resize or a mobile address bar sliding away re-derives it rather than reusing a cached number.

On a change, Flip records the panel's live position and size, any half-finished morph is killed and its inline styles cleared, the new shape's data attribute is applied, and Flip animates the frame's width and height into place with a soft overshoot. The incoming photograph layer starts fully clipped from the side the scroll came from and wipes open beneath the accent edge, while the image itself drifts in from the same direction and settles just after the frame. That layered timing is what makes the handoff feel composed, and a reversal wipes and drifts back rather than replaying forwards.

Nothing is cached in pixels. The stuck column is a size container and each shape's height is min(a share of that column's height, the height at which that shape would exactly fill its width), so every aspect ratio stays true at any window size. Scroll speed is clamped, mapped to degrees and eased onto the panel as one number, driving a skew, a stretch and a lag together; a watchdog guarantees the release, since scroll events stop firing the moment the page settles.

Plugins ScrollTrigger, Flip
Difficulty Advanced
Smooth scroll Lenis integration
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid effect

Purchase to unlock the code.

Buying Editorial Scrollytelling opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse free effects

Or the whole library: the Vault, £99, one payment.

Documentation

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add the section to your <body>:

Code snippet omitted: it ships with the download.

3. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

The script finds every [data-rail] section, counts the sections in the DOM, moves each section's media into the frame, and morphs the frame from shape to shape as each section reaches the handover line. The picture column does the sticking itself, in CSS.

Using It With Your Own Design

What the effect needs from your markup. Documented data- hooks. The script reads these hooks for its elements. Keep .rail--live, .is-active and .rail__mark mapped in your CSS: the script adds those state and control classes. Other demo classes can be renamed alongside their CSS.

Hook Required What it is
data-rail yes The section. One per rail; a page can hold several
data-rail-frame yes The box whose width and height morph
data-rail-canvas yes Empty container inside the frame; the media blocks are moved into it
data-rail-chapter yes One section of the article. Two or more, or the script leaves the page alone
data-rail-media yes The one media slot for that section
data-rail-stage no The box the velocity smear is applied to, and the size container the shapes measure against
data-rail-wipe no The accent edge that rides the mask
data-rail-marks no Container the script fills with one jump button per section
data-rail-caption no Optional visible section caption, updated on each landing
data-rail-status no An aria-live region announcing the section number and heading

Section headings are read with querySelector('h1, h2, h3, h4') for the jump buttons' labels and the live region.

What is only the demo's styling. The whole visual world: the palette, the fonts, the article metadata (.rail__kicker, .rail__dateline, .rail__byline) and the picture plates (.plate--*). The plates are a slot, and the intended use is your own photography, one image per section.

CSS the effect depends on.

  • Nothing is pre-hidden. The base document is the complete article: every section's prose and every photograph, in order. The sticky picture column is the only thing gated behind .rail--live, a class the script adds when and only when motion is allowed. Keep that gate if you restructure the CSS, and do not add an opacity: 0 start state to any prose: this page has to be right before the script runs, not after.
  • align-items: start on the two-column grid. A stretched grid item is as tall as its row, and a sticky element with no spare room in its containing block never moves. This one line is the difference between the picture holding and the picture scrolling away.
  • No overflow: hidden on any ancestor of the sticky column, including <html> and <body>. It turns that element into a scroll container and position: sticky then has nothing to stick to. The demo uses overflow-x: clip for exactly this reason.
  • The frame's size must come from CSS, not from inline styles. Each shape sets --fh (a percentage of the stage's height), --fa (an aspect-ratio) and --far (the same ratio as a bare number). Flip writes inline width/height while it animates and clears them at the end; if you hard-code a pixel width on the frame, the morph has nothing to morph to.
  • The stage is a size container (container-type: size) with a definite height, and the frame's height is min(var(--fh) * 1cqh, var(--fw) * 1cqw / var(--far)): the smaller of a share of the stage's height and the height at which this shape would be exactly as wide as the stage. This is what keeps every ratio true. It also means the stuck column's height never changes as the shape does, so a morph reflows nothing on the page.
  • overflow: hidden on the frame is what turns the shape change into a crop rather than a squash, and it is what the mask wipes inside.
  • .rail__canvas .media { position: absolute; inset: 0 } stacks the photograph layers on top of each other. Each layer needs its own opaque background, or the outgoing layer shows through the incoming one and the wipe reads as a crossfade.
  • [data-rail-wipe] needs a z-index above the layers (the demo uses 5), because the script gives the active layers a z-index of their own.
  • The picture panel is anchored to the start of its column (place-items: center start), not centred in it. Centred, every morph moves both edges at once and the pair stops reading as a pair; anchored, it shares the headline's left edge and the shape change reads as growth from a fixed edge.
  • The stuck column needs an opaque ground on narrow viewports. Stacked, the prose scrolls underneath it, so .rail__sticky carries the page's own background plus a short gradient below it, or a line of text is sliced by the edge instead of fading under it.

The Reading Model

This is the part worth understanding before you restructure anything.

The copy column is ordinary flow. No pin, no translate, no fixed-height band, no overflow: hidden. The article is as tall as the article. That is what lets a section hold four paragraphs, and it is why no line of prose can ever be clipped at a resting scroll position.

The picture column sticks. position: sticky on .rail__sticky, with align-items: start on the grid so the item is not stretched to the row height and actually has room to travel. It holds from the top of the article to the bottom, and each section's photograph is what is showing while that section is being read.

The handover is one ScrollTrigger per section, spanning that section's own prose. A section takes the picture over when its first line crosses the handover line going down (onEnter) and takes it back when its last line crosses the same line going up (onEnterBack). A reversal is therefore the same code path as an advance, and a long section simply holds for longer than a short one. No index arithmetic, no scroll budget to tune per section.

On a phone the two columns become one. The picture sticks to the top of the window with the page's own ground behind it and the prose scrolls underneath, fading out at the edge rather than being cut through by it. The morph is identical; only the velocity smear is dropped, because touch inertia makes the reading noisy.

Dropping In Your Own Images

This is the change almost every buyer makes first, so it is one line.

Each section has exactly one media slot, [data-rail-media]. Put an <img> straight in it:

Code snippet omitted: it ships with the download.

The shape's aspect-ratio sizes the slot and object-fit: cover crops the image into it, from this rule in style.css:

Code snippet omitted: it ships with the download.

<picture> and <video> work the same way. The morph does not care what is inside a slot: it animates the frame, and the image reflows into the new shape rather than being stretched into it.

The demo wraps each image in a .plate, which is worth keeping for one reason: the plate carries a dark background-color underneath the image, so a slow or failed download never flashes a pale box into the middle of a dark set.

Code snippet omitted: it ships with the download.

Give the image an empty alt only if the prose beside it already says what it shows. Keep width and height on it either way: the base document lays the article out before the images arrive, and without them it reflows as each one lands.

The Photography

What is in the download. Bundled, optimised WebP derivatives at assets/img/, one per section: lead-panorama.webp (21:9), shift-portrait.webp (3:4), depot-landscape.webp (3:2) and desk-square.webp (1:1). They are baked, not hotlinked, so the demo has no third-party image request in it and nothing to go missing later.

The images are generated. There is no photographer to credit, no stock licence to carry and no attribution list to keep with the files. Use them, recolour them, crop them or throw them away.

Regenerating them. assets/img-manifest.json is the recipe: source file, output name, output size, quality, crop and the night grade (saturation eased down so the sodium and the LED read as one night, gamma up to hold the shadows open, and per-image brightness because the set mixes an exterior street, a backlit apron, a lit interior and a close desk). To rebuild every derivative from the masters:

Code snippet omitted: it ships with the download.

The masters live in assets/img-src/, a working folder that is not part of the download: only the baked assets/img/*.webp ship. Alongside them, PROMPTS.md carries the generation prompts and the shared style block that keeps the four frames looking like one night in one city.

Swapping in your own. Drop your file at assets/img-src/<name>.jpg under the same name and re-run the command above, and the crop, the size and the grade are applied for you so a new picture arrives matching the rest of the set. Or ignore the manifest entirely and put your image straight into the slot, as above.

Options

All set on the [data-rail] section, except data-rail-shape, which goes on each media slot.

Attribute Values Default Description
data-rail-shape portrait (3:4), landscape (16:9), classic (3:2), split (4:3), square (1:1), wide (21:9) landscape The shape this section's media gives the frame. An unknown value falls back to the frame's default size, so two sections in a row with the same shape produce no visible morph
data-rail-line 0.15 to 0.85 0.5 Where the handover happens, as a share of the viewport height from the top. A section takes the picture over when its first line crosses this line, so it wants to sit near the middle of the picture: much above 0.3 and the picture changes before the reader reaches the new heading, much below 0.7 and it changes long after
data-rail-morph seconds 0.72 Length of one shape morph. Below about 0.35 the overshoot stops reading; above about 1.2 the panel visibly lags the copy
data-rail-overshoot number 1.45 back.out strength on the morph. 0 removes the overshoot and the shape change reads mechanical; past about 2.5 the panel wobbles on arrival
data-rail-smear true, false true Velocity smear on the panel. Always off on coarse pointers, whatever this says
data-rail-smear-max degrees 5 Cap on the smear. Past about 10 the panel looks broken rather than fast

Section count is never configured: it is counted from the DOM. Add or remove a [data-rail-chapter] and the markers, the announcements and the handover triggers all follow on the next ScrollTrigger.refresh().

A few more knobs live in CSS rather than in data attributes, because they are proportions of the layout rather than settings the script reads:

Property Default Description
--stage-h min(58svh, 35rem, calc(var(--content-w) * 0.56)) (min(33svh, 14rem) under 860px) How tall the picture is allowed to be while it holds. Capped against the shell as well as the window, so a very wide window does not turn the picture into the page
--stick-top max(1.25rem, calc((100svh - var(--stage-h) - var(--marks-h)) / 2)) (0 under 860px) Where the stuck column parks. Derived from the heights above, so the picture is vertically centred in the window at any size. On a phone it is flush with the top of the window on purpose: a gap above a stuck picture is a letterbox the prose shows through, one clipped line at a time
media/copy column split 0.95fr / 1fr How the pair's width is shared. The near-even split gives the long-read text roughly 80px more room in the 1200px product preview
--measure 54ch The prose measure

Accessibility

  • Reduced motion: the gsap.matchMedia branch builds nothing at all, and the section never enters live mode. The page stays what the markup is: the complete article, every section's prose and every photograph, in order, nothing stuck and nothing hidden.
  • No JavaScript: identical, minus the jump buttons.
  • Keyboard: the section markers are real <button> elements, tab-reachable, activated by Enter and Space. Their :focus-visible styling matches :hover and adds an outline. Each numbered control is at least 32px tall and widens across the phone viewport.
  • Screen readers: [data-rail-status] announces each section change using that section's own heading. Prose stays in normal heading and paragraph markup; the buttons carry the heading text in their labels.
  • Motion safety: the velocity smear is capped in degrees and always released back to zero by a ticker watchdog, so it can never leave the panel skewed.
  • Read time: the byline's read time is derived from the article's real word count at a stated words-per-minute and the derivation is written next to it in index.html. Recount it when you change the copy.

How It Works

The article is ordinary document flow and the picture column is a position: sticky block beside it, so the photograph holds for exactly as long as its section's prose takes to pass. Nothing is pinned, nothing is translated and nothing is clipped.

One ScrollTrigger per section spans that section's own prose, starting at top <line>px and ending at bottom <line>px, both function values under invalidateOnRefresh so a resize or a mobile address bar sliding away re-measures them. onEnter gives that section the picture, onEnterBack gives it back on the way up, which makes a reversal the same code path as an advance and makes a four-paragraph section simply hold for longer than a one-paragraph one.

On a change, Flip.getState records the frame's live rect, any half-finished morph is killed and its inline styles cleared, the new shape's data-shape is applied, and Flip.from animates the frame's real width and height into place with back.out. Because it animates size rather than scale, the photograph reflows into the new shape instead of stretching. The incoming layer starts fully clipped from the side the scroll came from and tweens its clip-path open underneath the accent edge, so scrolling back wipes back. Its image also starts slightly enlarged and offset in the wipe direction, then eases to rest after the frame has landed; the outgoing image makes a shorter movement the other way.

Scroll velocity is clamped, mapped to degrees and pushed through gsap.quickTo with a back.out ease, driving a skew, a stretch and a lag on the panel from one number. A ticker watchdog releases it, because scroll events stop firing the moment the page settles and nothing else would ever return the last reading to zero.

Performance Notes

  • One ScrollTrigger per section plus one for the velocity reading, with no fixed scroll timeline.
  • The sticking is CSS. There is no scroll handler moving the picture, and no pin spacer to re-measure.
  • Flip animates width and height on a single element for well under a second per handoff; everything else is transforms and clip-path.
  • Only two photograph layers are visible at any moment; the rest are visibility: hidden, so they are not painted.
  • The image drift uses transforms only and is killed or reset before a rapid reversal.
  • The stuck column's height never changes, so a morph never reflows the page around it.
  • The velocity smear is off on coarse pointers, where touch inertia would make it noisy rather than expressive.

Dependencies

Required:

  • GSAP 3.12+
  • ScrollTrigger
  • Flip

Optional:

  • Lenis 1.x for smooth scrolling. If present, the script runs Lenis from GSAP's ticker so both systems share one clock.

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.