Scrollytelling Template

£10

Scrollytelling page for any long-form piece that argues from evidence: journalism, research, policy, campaign reports. Three scroll-locked stages pin a media column that crossfades photograph to photograph as the reporting scrolls past, documents whose redaction bars wipe off as you read, and a right-of-reply block.

ScrollTrigger Lenis advanced
7 more details
pinningscrubcrossfadescroll-revealvideo-playbackload-sequence 2 colour palettes
Scrollytelling Template - website template preview

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

About this template

A production-ready one-page site for a single piece of investigative journalism, built for the kind of story that has to carry evidence: figures, documents, a laboratory result and a right of reply, in an order a reader can follow. It ships as a finished article for a fictional outlet, Field & Record, so what you open is a real piece of reporting rather than a skeleton of placeholder headings.

Read the full template overview

The page is one cool graphite ground with a green cast, one raised plate for anything that counts as evidence, bone type and a single marker orange that only ever means three things: the step you are on, a link, or a redaction lifting. Type is condensed grotesque over reading serif, with a monospace confined to datelines, figure numbers and exhibit stamps so nothing in the reading column competes with the prose.

Two things carry it. Three locked stages each pin a media column for the length of the section while three steps of reporting scroll past: as a step becomes current the media crossfades to that step's photograph, that step's type goes to full strength while the others sit readable but dimmed, and a clip in that frame plays only while its own step is current. Then the evidence section hands you three documents whose black bars flare orange and wipe off to the right under your scroll, revealing the permit reference, the hours and the line somebody did not want in writing.

Making it yours is a copy job and a four-value repaint. Replace the article text, the ten photographs and the two clips; change four colour tokens and their RGB triples and the whole page reskins with no stray literals left behind. Every section is found by a data attribute and guarded, so delete the lead, a whole stage, the documents or the statement and the rest still runs.

What's included

11 items
  • Three scroll-locked stages: a media column that pins for the length of the section while three steps of reporting scroll past it, crossfading photograph to photograph as each step becomes current
  • A full-bleed lead clip behind the headline that starts only when the section is on screen, pauses when it leaves, and holds its poster frame under reduced motion or with no JavaScript
  • Redacted documents whose bars flare to the accent and wipe off to the right, scrubbed against the section so the reader controls the reveal, drawn in CSS with no images and no plugins
  • Real video handled properly: muted, playsinline, poster-backed, preloading metadata only, playing solely while its own step is current, and paused when the tab is hidden
  • A designed plate behind every media slot - graded gradient, hairline weave and a monospace frame label - so a photograph that has not landed yet leaves the page looking art-directed instead of broken
  • Complete newsroom furniture: masthead, kicker, standfirst, a four-cell byline rule, numbered findings, exhibit stamps, a filed-return table, a right-of-reply statement block and a five-part method note
  • Four-token palette with RGB triples and every measured contrast ratio written into the stylesheet header, including which surface the accent is banned from carrying type on and why
  • Bundled optimised WebP photography with a manifest and a one-command regeneration step, so swapping the picture set is a file drop rather than a rebuild
  • Lenis smooth scrolling wired in on a shared clock with ScrollTrigger, anchors routed through it, and entirely optional
  • Accessible throughout: the stages degrade to plain stacked figures and paragraphs with no JavaScript, under reduced motion and on landscape phones; inactive steps stay readable rather than being faded out; redacted text is present for screen readers at all times
  • Two measured palettes, each with its own display face (condensed grotesque, didone), swapped from one data-palette attribute

Perfect for

6 use cases
  • Investigations and long-form journalism that need documents, figures and a right of reply in one page
  • Research and policy write-ups where each finding has its own evidence to show alongside it
  • Non-profit and campaign reports that argue a case from data rather than from sentiment
  • Case studies and post-incident write-ups told as a sequence of stages with photography
  • Documentary and film companion pages carrying clips, stills and source notes
  • Annual impact reports and white papers that want the method published next to the conclusion

How it works

5 sections

Structure

One script initialises every section with a single setup and teardown path and one motion branch, keyed on motion preference and viewport height only, so exactly one branch runs on every device including a phone. Each block is found by a data attribute and guarded, so deleting the lead, a stage, the documents or the statement leaves everything else working. The whole page is a complete static document before any script runs.

The locked stage

Each stage pins its media column from the top of the section to the bottom with pinSpacing off, because the section already owns its height: every step is budgeted just over a viewport of scroll travel, which is what stops one hard wheel flick clearing the whole stage in a single gesture. The step bands are contiguous, so exactly one step is ever current and a mid-page reload still lands inside a band. Above 900px the rig is two columns; below it the media pins to the top of the viewport and the steps scroll beneath.

The redaction wipe

Each bar is an aria-hidden overlay on a span whose text stays in the document, so the redacted content is always present for a screen reader and for search. A scrubbed timeline flares each bar to the accent and then wipes it off to the right on scaleX - the same property the CSS start state sets, which is what stops a parsed pixel offset being left behind. The bars are painted only under the JavaScript gating class, so a reader without JavaScript gets the documents plainly.

Clips that behave

Neither clip carries an autoplay attribute; the script starts them, which makes the poster frame the honest default rather than something JavaScript has to switch off. The lead clip runs only while the lead is on screen. The stage clip runs only while its own step is current and is paused the moment the step changes, and everything stops when the tab is hidden. Under reduced motion nothing plays at all and both posters stand.

Without JavaScript

The gating class goes on in the head before first paint, so reveal start states apply to the first frame and nothing is painted visible and then hidden; the script removes that class if GSAP is missing, so a blocked CDN degrades to the plain document. With scripting off each stage is three figures stacked above three paragraphs, the documents are unredacted, both clips show their posters, and a media file that fails to load hides itself so the designed plate behind it shows instead of a broken-image hole.

Plugins ScrollTrigger
Difficulty Advanced
Smooth scroll Lenis integration
Colour palettes 2 ready-made palettes, each measured for AA contrast. Switch with one attribute, or copy the values you want into :root. Try them in the preview above.
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Questions about this template

Can I put real video clips in it?

Yes, and video is handled properly rather than dropped in: muted, playsinline, poster-backed, preloading metadata only, playing solely while its own step is current and paused when the tab is hidden. Under reduced motion or with JavaScript off it holds its poster frame.

What happens if my photographs are not ready?

The page still looks art-directed. A designed plate sits behind every media slot, a graded gradient with a hairline weave and a monospace frame label, so an unlanded photograph reads as a designed frame rather than a broken image. Bundled WebP ships with a manifest and a one-command regeneration step.

Is the redacted text hidden from screen readers?

No, it is present for screen readers at all times. The bars are a CSS treatment that flares and wipes off as you scrub the section, drawn with no images and no plugins. Hiding evidence text from assistive technology on an investigation page would be the wrong call in every sense.

Does it work on a landscape phone, where pinned stages usually break?

Yes. The stages degrade to plain stacked figures and paragraphs on landscape phones, as well as with no JavaScript and under reduced motion. Inactive steps stay readable rather than being faded out, so nothing in the reporting is dimmed away from someone reading it.

Is it only for journalism?

No. Research and policy write-ups, non-profit reports arguing from data, post-incident case studies, documentary companion pages and annual impact reports all use the same shape: staged evidence, findings, a method note and a right of reply. The newsroom furniture is the part you re-label.

Paid template

Purchase to unlock the code.

Buying Scrollytelling Template 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.

£10 Standard license, unlimited projects

Browse free effects

Documentation

Quick Start

1. Copy the folder and open index.html. That is the whole site.

2. Replace the copy. Everything is plain markup in index.html, in the order it appears on the page: masthead, lead, findings, three stages, evidence, response, method, footer.

3. Replace the photography and the clips. Ten images and two clips, listed in Photography and clips below.

4. Repaint the four colour tokens at the top of assets/style.css. See Palette.

5. Ship assets/script.min.js instead of assets/script.js if you want the smaller file. Change the last <script> tag in index.html:

Code snippet omitted: it ships with the download.

The three CDN tags above it (GSAP, ScrollTrigger, Lenis) can stay as they are or be swapped for local copies.


Sections and options

Every section is found by a data-* attribute and guarded. Delete any section outright and the rest of the page keeps working — the script skips anything it cannot find.

Section id Hook Delete it and…
Masthead masthead nothing else changes; it is plain markup
Lead lead [data-lead] the clip logic is skipped
Findings findings [data-reveal] items nothing else changes
Stage one stage-1 [data-stage] the other two stages still run
Stage two stage-2 [data-stage] the clip logic goes with it
Stage three stage-3 [data-stage]
Plate band band [data-band] the parallax is skipped
Evidence evidence [data-evidence] the redaction wipe is skipped
Response response [data-reveal] items nothing else changes
Method method [data-reveal] items nothing else changes
Footer footer plain markup

The plate band

The one full-bleed image on the page, sitting between the last stage and the documents. Every other photograph lives in the same portrait frame in the same column, and after three stages that rhythm needs breaking.

Swap the photograph by changing the src on [data-band-media], or by editing the band-chamber entry in assets/img-manifest.json and re-running the asset build. It is a wide crop, so give it something that reads at roughly 2.3:1.

The image is deliberately 118% of the band's height, offset upwards by the 9% overhang that leaves. The script drifts it against the scroll by exactly that overhang and no more, so an edge can never appear. If you make the band taller, leave those two numbers alone — they are what keeps the drift safe. With no JavaScript, or under reduced motion, the image simply sits centred and nothing moves.

The stages

Each stage is one <section class="stage" data-stage> containing two things:

Code snippet omitted: it ships with the download.

Attribute Values Default What it does
data-stage Marks a section as a locked stage
data-stage-pin The element that pins. One per stage
data-frame One media frame. Two or more required, or the stage is left as a plain stacked figure list
data-step One step of prose
data-media frame index, 0-based 0 Which frame this step shows. Out of range falls back to 0
data-frame-media The <img> or <video> inside a frame that takes the crossfade scale
data-frame-clip Marks a frame's <video> as a clip to play while its step is current
data-stage-count Optional counter; the script writes 01 / 03 into it

Steps and frames are independent. Add a fourth step by copying a .step block; add a fourth photograph by copying a .frame. Two steps can point at the same frame (data-media="1" twice) if a photograph carries two beats of the story, and a stage works fine with two frames and four steps.

Scroll travel per step is set in assets/style.css, not in JavaScript:

Code snippet omitted: it ships with the download.

Keep it well above one viewport, and measure the trigger, not the box. The pin runs top topbottom bottom, so the travel a reader actually gets is the stage's height minus one viewport, shared across the steps: 150svh per step works out at about 1.2 viewports of real travel each. At 118svh it was 0.88, and a single hard wheel flick under smooth scroll carries roughly 3,600px — enough to clear an entire stage in one gesture and arrive with the media already on its last frame, which reads as the page leaping rather than as a sequence.

The redaction

Code snippet omitted: it ships with the download.

The text stays in the document — it is always there for a screen reader, for search and for anyone reading with JavaScript off. The bar is an aria-hidden overlay painted only under the has-js class, and the script wipes it away as the section scrolls. Bars wipe in document order, one after another; add or remove them freely and the timing redistributes itself.

Timing lives in assets/script.js in initRedaction(): start: 'top 72%' and end: 'bottom 82%' set which part of the scroll drives the wipe, and scrub: 0.6 sets how closely the bars follow the wheel.


Palette

Four tokens and their RGB triples, at the top of assets/style.css. Every translucent value on the page resolves from the triples, so a reskin is these eight values and nothing else — there are no rgba() literals to hunt down.

Code snippet omitted: it ships with the download.

Measured composited, as used:

Pair Ratio Carries
bone on deep 14.0:1 headlines, prose
bone @ .78 on deep 8.9:1 standfirst, captions
bone @ .62 on deep 6.1:1 labels, inactive steps
bone on panel 12.6:1 evidence and statement copy
bone @ .62 on panel 5.7:1 document sources, table heads
signal on deep 7.7:1 links, counters, numerals
signal on panel 6.9:1 the same, with headroom

The accent is a discipline, not a contrast problem. At 7.7:1 and 6.9:1 the blue clears comfortably on both surfaces, so a dimmed or tinted variant is available if a section ever needs one. It still marks only the three things listed above — current step, link, redaction bar lifting — because the moment it starts appearing on rules and icons for texture, the page stops telling the reader where to look. If you repaint, re-measure signal on panel first: that is the pair with the least room, and the burnt orange this replaced sat at 4.7:1 there.

Keep --panel within a couple of steps of --deep. It is meant to read as a plate lifted off the ground; once the two diverge, the evidence section starts looking like a different site.

The light palette

A second palette ships with the template, and it carries its own display face (Bodoni Moda, a high-contrast didone, in place of Antonio) so the two read as two identities rather than one design in two colours. Put data-palette="light" on the <html> element to use it:

Code snippet omitted: it ships with the download.

Or, if light is the only one you want, copy its eight values over the ones in :root and delete the :root[data-palette="light"] block entirely.

Code snippet omitted: it ships with the download.

Pair Ratio Carries
ink on paper 14.9:1 headlines, prose
ink @ .78 on paper 7.8:1 standfirst, ledes
ink @ .68 on paper 5.5:1 labels, inactive steps
ink on panel 16.2:1 evidence and statement copy
ink @ .68 on panel 5.8:1 document sources, table heads
signal on paper 6.5:1 links, counters
signal on panel 7.0:1

Two things about it are worth understanding before you make a third palette.

The alphas are re-measured, not inherited. --bone-dim is .62 in the dark palette and .68 in the light one, because dark ink at .62 on this paper is 4.60:1 — it passes by a hair and leaves nothing for a smaller size. Alphas do not carry across grounds; measure them again every time.

The accent had to move. The dark palette's #6FB3D2 is 2.4:1 on paper and fails outright as text, so the light palette runs a deep petrol. It reads as the same cold marker in context and still does the same three jobs and nothing else.

What a palette must not repaint

Everything that sits on top of a photograph resolves from a separate, fixed set of tokens that no palette overrides:

Code snippet omitted: it ships with the download.

The photography is graded dark and desaturated. Flip the scrims with the ground and you get pale type on a pale wash over a dark photograph, where legibility depends on which frame happens to be showing — which is not something a stylesheet can promise. A light page carrying dark-chromed photography is also simply what print does.

The hero is the one exception, and the light palette overrides it deliberately (:root[data-palette="light"] .lead-scrim). A dark band directly under a paper masthead reads as two sites stacked, so the lead goes paper too. It can afford to because of the shape of its scrim rather than its strength: the gradient is heavy on the left where the type sits and clears completely by 76%, so the photograph is never asked to carry type — it simply runs at close to full strength on the right against paper. The stage captions and the band caption sit on their images with nowhere to escape to, which is why those keep the dark chrome. If you add a palette and want the same treatment, copy that block and re-measure; keep anything carrying type left of 55%.

The redaction bars are in the same category: they resolve from --scrim-rgb in both the CSS and the script, because a redaction is a black bar whatever colour the page is.

Typefaces

Three, loaded from Google Fonts in <head>:

  • Antonio — masthead, headlines, section and stage titles, finding numerals (the light palette sets Bodoni Moda 700 in the same role)
  • Faustina — every word meant to be read: standfirst, prose, quotes
  • Lekton — furniture only: datelines, bylines, captions, figure and exhibit labels, step counters, the document lines

Swap them in the one <link> tag and the three --font-* tokens. The pairing logic is condensed grotesque over reading serif; if you replace Antonio, replace it with something equally condensed or the headline scale will need rebalancing.

Palette Display face
Newsroom (default) Antonio 700, a condensed grotesque
Light Bodoni Moda 700, a high-contrast didone

The face and the colours are independent tokens (--font-display and --font-display-weight sit in the light block next to the colours), so keep the palette and swap the face, or the other way round. Both faces are in the Google Fonts <link> in index.html; a browser only downloads the one the page uses, but once you have settled, trim the link to the family you kept. Faustina and Lekton are the same in both palettes.


Photography and clips

Images are bundled, optimised WebP derivatives with the template's grade baked into the pixels, not hotlinked originals. Sources live in assets/img-src/ and assets/img-manifest.json records the output name, dimensions, quality and grade for each one.

Regenerate the whole set with:

Code snippet omitted: it ships with the download.

To swap a photograph, drop a new file at assets/img-src/<name>.jpg and re-run that command, or edit the src in the manifest first. brightness is set per image on purpose: the set mixes riverbank daylight, a laboratory interior and a dusk exterior, and matching perceived brightness is what makes them read as one commission.

File Where Ratio
assets/img/lead-poster.webp poster for the lead clip 16:9
assets/img/stage-1-a-outfall.webp stage 1, step 1 4:5
assets/img/stage-1-b-pipe.webp stage 1, step 2 4:5
assets/img/stage-1-c-bank.webp stage 1, step 3 4:5
assets/img/stage-2-a-lab.webp stage 2, step 1 4:5
assets/img/stage-2-b-poster.webp poster for the stage 2 clip 4:5
assets/img/stage-2-c-bottles.webp stage 2, step 3 4:5
assets/img/stage-3-a-weir.webp stage 3, step 1 4:5
assets/img/stage-3-b-anglers.webp stage 3, step 2 4:5
assets/img/stage-3-c-ashcombe.webp stage 3, step 3 4:5

Two clips, both H.264 MP4, muted, with no audio track at all:

File Where Notes
assets/sennet-outfall.mp4 full-bleed behind the headline loops; 10-20s is plenty
assets/sample-draw.mp4 stage 2, step 2 loops while that step is current

The lead clip autoplays from the markup (autoplay loop muted playsinline). It used to be started by the script instead, on the reasoning that this kept the poster as the default; in practice a scripted play() is refused often enough — no gesture yet, a data saver, a browser that blocks autoplay by default — that real visitors sat looking at the poster and reasonably concluded the hero was a still. muted + playsinline is what every autoplay policy actually asks for, and an attribute is honoured before any JavaScript runs.

Reduced motion is therefore handled the other way round: the script removes the attribute, pauses, and holds a play listener that pauses again. That second part is not optional — the browser starts playback when it has data, which is usually after your code has run, so a single pause() gets overridden a moment later and the clip plays on regardless.

The stage-2 clip has no autoplay: it is only supposed to run while its step is current, so the script owns it entirely.

Both use preload="metadata", so a visitor downloads the poster and a few kilobytes of header, not the film.

Every media slot has a designed plate behind it — a graded ground, a light source, a vignette, a fine rule texture and a Lekton frame label, all in the palette. If a file is missing or fails to load it hides itself and that plate shows, so the page never renders a broken-image hole. There are four plate variants; put frame--p1 through frame--p4 on a <figure class="frame"> to pick one.

That last bit is handled by the small inline script in <head>, which does two things and has to be in the head to do either: it adds the has-js class before first paint (so reveal start states apply to the first frame and nothing flashes), and it listens for media load errors in the capture phase. Media errors fire while the body is still parsing, long before assets/script.js runs at the end of it, which is why that listener cannot live in the main file. If you move scripts around, keep those seven lines where they are.


Accessibility

  • Reduced motion gets the complete page with nothing pinned, nothing moving and no clip playing: each stage becomes three figures stacked above three paragraphs, both clips hold their posters, and the redaction bars are simply never painted. Handled in both the CSS media query and the script's matchMedia branch.
  • No JavaScript gets the same plain document. The gating class is added in <head> before first paint and removed by the script if GSAP fails to load, so a blocked CDN degrades to the plain page rather than hiding content behind a script that never ran.
  • Inactive steps are dimmed, not hidden. They sit at 6.1:1, which is readable, so somebody can look ahead or behind at any point.
  • Redacted text is always in the document for screen readers and for search; the bar over it is aria-hidden.
  • Every photograph has a real alt, explicit width/height to reserve its space, and loading="lazy".
  • Figures use <figure>/<figcaption>, the return is a real <table> with a caption and row headers, and the byline is a <dl>.
  • Landscape phones (under 521px of viewport height) get the plain stacked layout instead of a pinned frame with no room beside it.

Dependencies

Code snippet omitted: it ships with the download.

  • GSAP 3.15.0 and ScrollTrigger — required.
  • Lenis 1.3.17 — optional. Delete the tag and the page works exactly the same with native scroll; the script checks for it.

If you keep Lenis, keep it on GSAP's clock. The script already does this, and it is not optional decoration: Lenis runs with autoRaf: false, pushes every scroll into ScrollTrigger.update, and is driven from gsap.ticker with lagSmoothing(0). Left on its own rAF, Lenis moves the page while ScrollTrigger is still reading the previous frame, and the pinned media column jitters a frame behind the prose beside it.

Two related rules if you extend the page:

  • Never add anticipatePin. It pins early in proportion to scroll velocity to cover a gap that native scroll has and Lenis does not, so with Lenis driving it is pure error — the media column gets yanked upward by up to 200px exactly as the stage arrives.
  • Never put data-lenis-prevent on a horizontally scrolling element. Lenis drives the vertical axis; handing the wheel to something that can only consume horizontal movement stalls the page for as long as the pointer is over it. The attribute is for vertically scrolling panels only.

Browser support

Current Chrome, Firefox, Safari and Edge. Uses svh units with vh fallbacks, aspect-ratio, CSS custom properties and clamp().

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

Your cart

Your cart is empty

The Vault £99

Everything in the catalogue, plus everything we release next.