ScrollFree

Introduction

A 280 px scrolling container, a 4 px track pinned to the top with position: sticky, and four lines of JavaScript writing scrollTop ÷ (scrollHeight − clientHeight) into its width: 0% at the top, 100% at the last pixel, measured at the exact proportion. The percentage is computed but never displayed — printing it as text takes one line.

CSSJavaScriptUX

Introduction

Le scrollytelling est une technique narrative qui utilise le defilement de la page pour guider l'utilisateur a travers une histoire visuelle immersive.

Chapitre 1

Les animations declenchees par le scroll permettent de reveler progressivement le contenu, creant un rythme naturel de lecture qui captive l'attention.

Chapitre 2

En combinant parallaxe, transitions et effets visuels, chaque section devient une scene dans un recit plus large, transformant un simple site web en experience narrative.

Chapitre 3

Les meilleurs exemples de scrollytelling equilibrent esthetique et fonctionnalite, guidant l'utilisateur sans le submerger d'effets excessifs.

Conclusion

Maitrisez ces techniques pour creer des experiences web memorables qui racontent votre histoire avec elegance et fluidite.

Scrollez pour explorer
Hover or click the scene to interact

This effect is free — the Scroll category contains 70 effects total, including 12 free. Effect.Labs has 811 vanilla effects. Explore the category →

Usage examples

2025 Impact Report
Rives & Sources NGO — Water, sanitation, health
47% read
Chapter 6 / 12

Editorial

In 2025, 214,000 people gained lasting access to safe drinking water through 38 projects run with communities of the Niger basin and the Mekong delta.

Field results

126 boreholes rehabilitated, 41 user committees trained, and water-point uptime raised from 68% to 91% in eighteen months.

Access to water

Each borehole is handed to a local committee that sets the price, keeps the books and orders spare parts: maintenance no longer depends on us.

Sanitation

9,200 household latrines built with the community-led approach; 63 villages certified open-defecation-free by health authorities.

Education

In 87 schools, student-led hygiene clubs cut absenteeism by 12% during the rainy season.

Health

Diarrhoea cases among children under five fell by 31% in covered areas, according to health-centre records.

Financial statement

Budget spent: €8.4M, of which 79% went to programmes, 13% to monitoring and evaluation and 8% to overheads. Accounts certified without qualification.

Governance

The board now includes 40% of members from the countries where we work, as pledged in 2023.

Voices from the field

"I used to walk two hours for water. Now the standpipe is at the end of the street and my daughters go to school." — Aminata, chair of a user committee.

Partners

27 local partners, 4 institutional funders and 11,400 regular donors made these results possible.

Outlook 2026

A new programme in the eastern Sahel, sensor-based monitoring extended to 300 boreholes, and quarterly publication of operating data.

Acknowledgements

To everyone who carried these projects, on the ground or from afar: this report is yours.

① An NGO's annual impact report — twelve chapters, “47% read”

WhenA sustainability or activity report read inside a container: field results, financial statement, testimonies, in twelve chapters separated by the code's .section-divider.
WhyA formal document is read in one go; the bar follows the gesture to the pixel and the percentage — computed by the code, displayed by one added line — tells the reader where they are: “47% read”, chapter 6 of 12. Scroll Progress Beam (fx-0793) gives a side light indicator with no numeric value; here the number is the data.
SettingsThe scene's frame (header + document) stands in for the sold .st-progress-scroll frame; container changed from 280px to flex: 1; scene opened at 47% of the run (remembered position); “47% read” label and chapter counter computed in the same scroll listener; original gradient.

② Legal notice with attested reading — the button unlocks at 100%

WhenGovernment portal: terms of use of a business application that every staff member must scroll through before validating; eight articles, an “I confirm I have read” button.
WhyProof of reading has to be measurable: scrollTop ÷ run gives a number, not an impression. The button stays disabled until the percentage reaches 100; no other effect in the batch combines a numeric progress with free-flowing long text.
SettingsScene frame (header + document + footer) instead of the sold frame; solid blue #3b82f6 fill instead of the gradient; scene footer with the “% read” value and the button, unlocked in the listener when progress ≥ 99.5; pulsing label removed.
CPF training · Module 1 of 6
Introduction to project management
0% read
Free reading · about 6 min

Module objectives

By the end of this chapter you will be able to define a project, tell its three constraints apart and recognise the roles in a project team.

What is a project?

A project is a temporary effort, with a beginning and an end, undertaken to deliver a unique result: a product, a service or an organisational change.

The constraint triangle

Scope, time and budget are linked: changing one moves the other two. A project manager spends their time making those trade-offs visible.

The roles

The sponsor decides and funds, the project manager organises and arbitrates, the team delivers, and stakeholders are consulted at milestones.

Example: redesigning an intranet

Scope: 40 pages and a directory. Time: 12 weeks. Budget: 3 person-days per week. What trade-off if the directory takes twice as long?

Key takeaways

A project is temporary and unique; its three constraints offset each other; each role has a clear responsibility. Module 2 covers planning.

Scroll to read

③ Online course module with free reading

WhenIntroductory chapter of a training course (CPF, onboarding, tutorial) in a web application: objectives, concepts, example, key takeaways.
WhyThe content stays a continuous text the learner scrolls through and back through freely — the bar encourages finishing without imposing stops. Snap + Progress (fx-0597) forces steps; they suit distinct stages, not a lesson you reread.
SettingsThe code's span.scroll-hint-label kept (“Scroll to read”), anchored at the bottom of the scene's frame, which plays the part of the sold .st-progress-scroll frame; padding-bottom: 40px on .progress-inner so it does not cover the last line; “% read” value in the header.

How it works

The sold HTML is a single block. A .st-progress-scroll frame wraps everything: it carries the #0a0a0f background, the text colour and the font (Inter, then the system font), it is position: relative so the absolutely positioned label has a reference, and it is a flex column with max-height: 100% — your colours and your font go on that single rule. Inside, .scroll-container#progress-scroll (width: 100%, height: 280px, overflow: hidden auto — no horizontal scrolling, automatic vertical scrolling —, position: relative, scroll-behavior: smooth) first holds the track .st-progress-bar-track (4 px tall, white at 8%, position: sticky; top: 0, z-index: 20) and its fill .st-progress-bar-fill#progress-fill (width: 0%, gradient #6366f1 → #a855f7 → #ec4899, right corners rounded by 2 px, transition: width 0.05s linear), then the body .progress-inner (padding: 24px 20px): five 1 rem white h3, five 0.8 rem p in white at 50% with a 1.7 line height, four 60 × 2 px .section-divider. Their margins are written in full (margin: 0 0 12px for headings, 0 0 16px for paragraphs): the rendering does not depend on a reset in the stylesheet that hosts the block. Because the track sits inside the element that scrolls, sticky keeps it glued to the top edge while the text passes underneath — measured: the track stays at y = 0 of the container at the end of the run. After the container, still inside the frame, a span.scroll-hint-label reading “Scrollez pour explorer” in 0.65 rem uppercase, white at 30%, anchored 8 px from the bottom of the frame, pulses from 0.3 to 0.7 opacity every 2 s (@keyframes pulseHint). WebKit scrollbars are narrowed to 4 px (::-webkit-scrollbar).

The script does one thing. Wrapped in an immediately invoked function — progressScroll and progressFill do not exist in window and cannot collide with your own variables —, it fetches the two elements by id and, if both exist, attaches a single scroll listener to the container — no requestAnimationFrame, no loop: nothing runs at rest. On each event, scrollHeight − clientHeight gives the total run in pixels (215 px for the sold content at 1,264 px wide, 454 px at 374 px, as paragraphs wrap), scrollTop / run × 100 gives the percentage, written as is into style.width. The 50 ms transition smooths the step between two events. Measured in Chromium on a blank page, one 60 px wheel notch: scrollTop goes to 60, the inline width to 27.907%, the rendered width to 352.7 px on a 1,264 px track — the exact proportion, settled within 150 ms. End of run: 215 / 215, i.e. 100%; back to top: 0%. Nothing is computed on load: until a scroll event has fired, style.width stays empty and the bar at 0%.

The percentage only exists as a width. The progress variable does hold 0 to 100, but the code never writes it anywhere as text: the reader sees a bar, not “47%”. Displaying it takes one line after the existing one — label.textContent = Math.round(progress) + '%' — and that is what the three scenes below do. Three design choices, checked on a blank page. The .st-progress-scroll frame ships its own background: pasted as is onto a white page, the block stays a dark rectangle where headings, text and track can be read (measured: no text block matching the background colour, on a white page as on a dark one) — without that background, white text would only exist on an already dark site. The same frame is the label's reference: an element in position: absolute anchors to its nearest positioned ancestor, and the frame is one; the label lands 8 px from the bottom of the block, over the last visible line (measured: y = 268 in a 280 px frame). Finally the frame is a flex column with max-height: 100%: placed in a fixed-height box shorter than 280 px, the container shrinks to the box instead of overflowing (measured: in a 250 px box, container at 250 px, track and label visible); on a page with no height constraint, that max-height has no effect. The code does, however, target two fixed ids: a second copy of the block on the same page has a bar that never moves (measured: empty style.width on the second one after 100 px of scrolling).

Accessibility

  • prefers-reduced-motion is absent from the code. What moves on its own is limited: the label's opacity pulse (2 s, infinite — measured still running under the “reduce” preference) and the bar's 50 ms smoothing, which only follows the gesture. scroll-behavior: smooth, however, animates every programmatic or anchor scroll (measured: scrollTop = 108 set by script arrives in ~160 ms in Chromium, instead of an immediate jump). Add @media (prefers-reduced-motion: reduce) { .scroll-container { scroll-behavior: auto } .scroll-hint-label { animation: none } }; the bar itself is not affected, it follows the user.
  • Keyboard: the container has no tabindex. Chrome 130+ and Firefox make scrollable areas without focusable children reachable with Tab (measured in Chromium 151: Tab focuses #progress-scroll, Arrow down scrolls 40 px, bar at 18.6%); Safari does not. Set tabindex="0", role="region" and an aria-label on the container so the keyboard works everywhere and the area is announced.
  • The bar is silent: two empty divs whose only change is a width, nothing is announced. For attested reading or a course module, give the track role="progressbar", aria-valuemin="0", aria-valuemax="100" and update aria-valuenow in the listener (one line, next to style.width) — or display the percentage as text in an aria-live="polite" element. If the bar stays decorative, put aria-hidden="true" on the track.
  • Contrast: on #0a0a0f, white headings measure 19.8:1 and the 50% opacity text 5.3:1 (AA); the “Scrollez pour explorer” label at 30% opacity measures 2.6:1 at rest and drops lower during the pulse — decorative, never make it carry information. The track (white at 8%) is below the 3:1 threshold for components: the coloured fill shows, the remaining part cannot be guessed; raise it to 20% or display the number. Those ratios hold as is on any page: the .st-progress-scroll frame ships its own #0a0a0f background. If you switch the frame to a light colour, change the headings (white), the text (white at 50%) and the track (white at 8%) at the same time, otherwise everything disappears.

Browser compatibility

Three properties set the floor: the two-value syntax overflow: hidden auto (Chrome 68, Firefox 61, Safari 13.1 — earlier, the declaration is ignored and the container does not scroll), position: sticky (Chrome 56, Firefox 32, Safari 13) and, for programmatic scrolling only, scroll-behavior (Safari 15.4). The JavaScript is ES2015 (const, arrow function): Chrome 49, Firefox 45, Safari 10. Zero dependencies. Progress is computed in JavaScript on the scroll event, not with animation-timeline: it works in Firefox, which renders native CSS scroll-driven animations in no version at all.

Chrome 68+✓ Full
Firefox 61+✓ Full (standard scrollbar: ::-webkit-scrollbar ignored)
Safari 13.1+✓ Full (programmatic scrolling not smoothed before 15.4)
Edge 79+✓ Full
Mobile iOS 13.4+✓ Full (system scrollbar: ::-webkit-scrollbar styles have no effect)
Android Chrome 68+✓ Full

Without JavaScript, the container scrolls normally and the track stays empty: a 4 px grey line, no error. Without the two-value overflow syntax (Safari before 13.1), the container does not scroll: the text overflows the 280 px and the bar never moves — write overflow-x: hidden; overflow-y: auto as two declarations to cover those versions.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="st-progress-scroll">
  <div class="scroll-container" id="progress-scroll">
    <div class="st-progress-bar-track">
      <div class="st-progress-bar-fill" id="progress-fill"></div>
    </div>
    <div class="progress-inner">
      <h3>Introduction</h3>
      <p>Le scrollytelling est une technique narrative qui utilise le defilement de la page pour guider l'utilisateur a travers une histoire visuelle immersive.</p>
      <div class="section-divider"></div>
      <h3>Chapitre 1</h3>
      <p>Les animations declenchees par le scroll permettent de reveler progressivement le contenu, creant un rythme naturel de lecture qui captive l'attention.</p>
      <div class="section-divider"></div>
      <h3>Chapitre 2</h3>
      <p>En combinant parallaxe, transitions et effets visuels, chaque section devient une scene dans un recit plus large, transformant un simple site web en experience narrative.</p>
      <div class="section-divider"></div>
      <h3>Chapitre 3</h3>
      <p>Les meilleurs exemples de scrollytelling equilibrent esthetique et fonctionnalite, guidant l'utilisateur sans le submerger d'effets excessifs.</p>
      <div class="section-divider"></div>
      <h3>Conclusion</h3>
      <p>Maitrisez ces techniques pour creer des experiences web memorables qui racontent votre histoire avec elegance et fluidite.</p>
    </div>
  </div>
  <span class="scroll-hint-label">Scrollez pour explorer</span>
</div>
CSS
/* Cadre de l'effet : il porte le fond, la couleur du texte et la police (remplacez-les par les vôtres),
   sert de repère (position: relative) au libellé placé en absolu, et, posé dans une boîte de hauteur
   fixe, réduit le conteneur au lieu de le laisser déborder (colonne flex + max-height: 100%). */
.st-progress-scroll {
  position: relative;
  display: flex;
  flex-direction: column;
  width: 100%;
  max-height: 100%;
  background: #0a0a0f;
  color: #fff;
  font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

.scroll-container {
  width: 100%;
  height: 280px;
  overflow: hidden auto;
  position: relative;
  scroll-behavior: smooth;
}

.scroll-container::-webkit-scrollbar {
  width: 4px;
}

.scroll-container::-webkit-scrollbar-track {
  background: rgba(255, 255, 255, 0.03);
}

.scroll-container::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.15);
  border-radius: 4px;
}

.scroll-container::-webkit-scrollbar-thumb:hover {
  background: rgba(255, 255, 255, 0.3);
}

.scroll-hint-label {
  position: absolute;
  bottom: 8px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 0.65rem;
  color: rgba(255, 255, 255, 0.3);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  pointer-events: none;
  z-index: 10;
  animation: pulseHint 2s ease-in-out infinite;
}

@keyframes pulseHint {
  0%, 100% {
    opacity: 0.3;
  }
  50% {
    opacity: 0.7;
  }
}

.st-progress-bar-track {
  position: sticky;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: rgba(255, 255, 255, 0.08);
  z-index: 20;
}

.st-progress-bar-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, #6366f1, #a855f7, #ec4899);
  border-radius: 0 2px 2px 0;
  transition: width 0.05s linear;
}

.progress-inner {
  padding: 24px 20px;
}

.progress-inner h3 {
  font-size: 1rem;
  font-weight: 700;
  color: #fff;
  margin: 0 0 12px;
}

.progress-inner p {
  font-size: 0.8rem;
  color: rgba(255, 255, 255, 0.5);
  line-height: 1.7;
  margin: 0 0 16px;
}

.progress-inner .section-divider {
  width: 60px;
  height: 2px;
  background: linear-gradient(90deg, #6366f1, transparent);
  margin: 20px 0;
}
JavaScript (fx-0575)
(function () {
  const progressScroll = document.getElementById('progress-scroll');
  const progressFill = document.getElementById('progress-fill');

  if (progressScroll && progressFill) {
    progressScroll.addEventListener('scroll', () => {
      const scrollTop = progressScroll.scrollTop;
      const scrollHeight = progressScroll.scrollHeight - progressScroll.clientHeight;
      const progress = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
      progressFill.style.width = progress + '%';
    });
  }
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Container height (CSS — .scroll-container height) 280px Sets the reading window, hence the run: scrollHeight − 280. The taller the container, the less there is to scroll and the further the bar moves per notch. In a flex column, replace it with flex: 1; min-height: 0 — that is what the scenes do. Placed in a shorter box, the frame (flex column, max-height: 100%) shrinks the container to the box without touching this value.
Background, colour and font (CSS — .st-progress-scroll) #0a0a0f, white, Inter then system font A single rule dresses the block: this is where your colours and your font go. The background is required, as headings, text and track are white. For a light frame, also change .progress-inner h3, .progress-inner p and .st-progress-bar-track. Drop the background if the block sits on an already dark site: the colour and the font remain useful.
Track thickness (CSS — .st-progress-bar-track height) 4px Thin line pinned to the top of the container. 2 px = discreet hairline; 8 px and above = a bar readable from afar, then raise the fill's border-radius (2 px) in proportion.
Fill colour (CSS — .st-progress-bar-fill background) linear-gradient(90deg, #6366f1, #a855f7, #ec4899) The gradient is drawn over the fill's current width: at 25% only the indigo and purple show; the pink only appears near the end. A solid colour reads more neutrally for a document; put the gradient on the track and a mask on the fill if you want fixed hues.
Smoothing (CSS — .st-progress-bar-fill transition) width 0.05s linear 50 ms between two bar positions. 0 = raw rendering on every scroll event; 0.2s = a bar catching up with the gesture, do not go beyond (it looks late).
Programmatic scrolling (CSS — .scroll-container scroll-behavior) smooth Affects neither the wheel nor the finger: only scrollTop = x, scrollTo() and anchors, animated over ~600 ms measured. Switch to auto if a script has to position the container without animation — or set scroll-behavior: auto inline for the duration of the assignment.
Percentage display (JS — to add after style.width) absent label.textContent = Math.round(progress) + '%' in the listener: the number already exists in the progress variable. This addition is what turns the bar into a reading indicator rather than a decoration.
Identifiers (HTML/JS — progress-scroll, progress-fill) single instance getElementById binds one instance only. For several containers, replace the two ids with querySelectorAll('.scroll-container') and, for each one, find its .st-progress-bar-fill before attaching the listener — the formula does not change.
Hint label (HTML/CSS — .scroll-hint-label) “Scrollez pour explorer”, 2 s pulse Absolutely positioned element, anchored 8 px from the bottom of the .st-progress-scroll frame (its position: relative reference): it overlaps the container's last visible line. Remove it safely, the JS never reads it; otherwise change the text and stop the pulse under prefers-reduced-motion.

FAQ

Why this bar rather than Scroll Progress Beam (fx-0793), which also tracks scrolling?

Because here the progress is a number you can read and use. The code computes progress from 0 to 100 in a four-line scroll listener, on a scrolling container: you can print it as text, compare it with a threshold (enable a button at 100%), send it to your analytics. The Beam is a side light indicator, decorative, with no explicit value, designed for the whole page. Both remain plain JavaScript, so they are available in Firefox — unlike bars built on animation-timeline: scroll(), which Firefox renders in no version.

The bar stays at 0% although my container is not at the top: why?

The script computes nothing on load, it waits for the first scroll event. If you position the container by script (scrollTop = 600 to resume reading) or the browser restores a position, the event fires and the bar follows — measured: scrollTop = 108 set by script gives 50.2% after 650 ms, scroll-behavior: smooth having made the trip in ~160 ms. On the other hand, injected content, a loading image or a resize change scrollHeight without firing scroll: the bar keeps the old proportion. Move the listener's body into an update() function, call it at initialisation and on resize.

How do I put several documents, each with its own bar, on the same page?

The sold code binds a single instance through getElementById: with two copies of the block, the second one has a bar that never moves (measured: empty style.width after 100 px of scrolling), and two identical ids are invalid HTML anyway. Drop the ids and loop over document.querySelectorAll('.scroll-container'): for each container, box.querySelector('.st-progress-bar-fill') gives its bar, and the listener is the same. The three scenes on this page work that way, each on its own container.