ScrollFree

Progress Bar

A 4 px track pinned to the top of a 280 px frame by position: sticky, whose fill follows the frame's scroll in scaleX(0 → 1) through animation-timeline: scroll(nearest) — zero JavaScript, zero ids, as many copies as frames. Measured: scaleX = scrollTop ÷ 524 to the thousandth, 0.5 at 262 px, 1 at the last pixel; the page scrolling around it does not move the bar.

CSSScrollProgress

Updated

Progress Bar

Scroll to see the bar fill up. Uses animation-timeline: scroll() to link the animation to the container scroll.

Scroll ↓
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

TORNEX Configurator
Step 2 of 4 · Options

Data sheet — TX-420

Updated 09/2026
Power
Spindle power22 kW
Maximum speed4,500 rpm
Maximum torque320 N·m
Power supply400 V · 50 Hz
Travels
X travel320 mm
Z travel750 mm
Max. turning diameter420 mm
Max. turning length700 mm
Rapid traverse X/Z30 / 36 m/min
Accuracy
Positioning accuracy±0.005 mm
Repeatability±0.003 mm
Spindle runout0.002 mm
Turret
Tool stations12
Indexing time0.15 s
Driven tools12 × 6,000 rpm
Footprint
Dimensions W × D × H3,250 × 1,900 × 2,050 mm
Weight4,800 kg
CNC controlFanuc 0i-TF Plus

① Machine-tool data sheet — documentation pane of an industrial configurator

WhenConfigurator for a CNC lathe: ticked options on the left (spindle, tailstock, conveyor), on the right a fixed-height pane holding the data sheet — power, travels, tolerances, footprint — with the bar at its top.
WhyThe pane is a scroller nested in a page that scrolls itself: scroll(nearest) takes the pane, not the page — measured, scrolling the page 2,500 px leaves the bar at 0, the wheel inside the pane fills it. Scroll Progress Beam (fx-0793, published) needs a vertical rail in the margin that this narrow pane does not have; the 4 px track pinned at the top takes no room.
Settings262 px frame (height: 262px on .sdc-scroll-container, 260 px usable with the border), real content at height: auto (measured travel 399 px), track in white at 7%, fill linear-gradient(90deg, #22d3ee, #0ea5e9), tabindex="0" and role="region" on the frame. The pane scrolls on its own until the first gesture — a demo device, not a capability of the effect.
HALDE shelving unit · light oak
80 × 30 × 180 cm · 5 shelves · flat-packed
€129Add to cart

Assembly guide

9 steps · 35 min
Unpack and check

2 uprights, 5 shelves, 1 back panel, 24 screws, 8 dowels, 1 key.

Insert the dowels

Push the 8 dowels into the uprights, on the inner side.

Fit the bottom shelf

Four 40 mm screws into the uprights, without forcing.

Fit the top shelf

Same move; the frame now stands on its own.

Place the middle shelves

Three shelves resting on the dowels, 40 cm apart.

Nail the back panel

Unit laid flat, panel aligned, one nail every 15 cm.

Check it is square

Measure both diagonals: they must be equal.

Fit the pads

Four felt pads under the uprights.

Fix to the wall

Anti-tip bracket and a wall plug suited to your wall.

② Assembly guide for a flat-pack shelving unit — side panel of a furniture shop

WhenProduct page of a flat-packed shelving unit: on the left the visual, the price and the buy button, motionless; on the right a fixed-height panel that scrolls through the 9 assembly steps, the bar at the top of the panel.
WhyThe bar sits inside the panel, pinned to its top, and scroll(nearest) attaches it to the closest scrolling ancestor: the panel, not the page. It says one thing only, how far along the guide you are — measured, 390 px of travel, 0.5 as step 4 comes under the track, 1 on the last line of step 9 — while the visual, the price and the button do not move; the customer building the unit, phone resting on the box, sees what is left without counting steps. If the page scrolls in turn, the bar ignores it: measured, page scrolled 900 then 1,500 px, bar at 0; panel at mid-travel then the page brought back 400 px, bar still at 0.5. It has to stay inside the panel: placed in the page header, nearest would climb to the next scroller, the page.
SettingsLight theme: panel in flex: 1 to the right of the visual, frame at height: auto (259 px measured) with real content (measured travel 390 px, 372 px with the English texts), #ece5dc track, solid #a0623a fill, steps in a 22px 1fr grid with the number in a badge, tabindex="0", role="region" and aria-label on the frame. The panel scrolls on its own until the first gesture — a demo device.
Atelier Sonore · Season 3
Episode 47 — Repair rather than replace
12:4841:20

Transcript

41 min · 6,200 words

Léa (host) — Welcome to Atelier Sonore. Today we talk about what breaks, and what we do with it: a toaster, a phone, a washing machine.

Marc — Ten years ago I opened a repair workshop in a former hardware store. I was told it wouldn't last six months.

Léa — What lands on your bench most often?

Marc — Vacuum cleaners and coffee machines. Eight times out of ten it's a two-euro thermal fuse or a seal. The rest of the time, it's the board.

Léa — And the repairability index, does it change anything?

Marc — For customers, yes: they check the score before buying. For us, what matters is the spare part still available five years later, and the schematic.

Léa — Do you also train people?

Marc — One Saturday a month. People come with their item and we open it together. Most leave with it repaired and the urge to do it again.

Léa — We take a short break and come back with listeners' questions.

Léa — First question, from Nadia in Lyon: is it worth repairing a phone whose battery is glued in?

Marc — Yes, with a heat pad and patience. The glue softens at sixty degrees; the battery costs twenty euros. The hour of labour is the real price.

Léa — Tom in Nantes asks which tools to buy first.

Marc — A multimeter, a set of precision screwdrivers, and a lamp. The rest comes with the objects you open.

Léa — And the objects you refuse to repair?

Marc — Anything with a cracked lithium cell, and anything with no schematic and no spare parts. I say so on the first day: I'd rather lose a customer than sell a repair that won't hold.

③ Transcript of a podcast episode — listening page of an audio studio

WhenEpisode page: the player at the top with its cover, its button and its own orange time bar; below it, the time-stamped transcript in a 280 px frame, reading progress bar at the top of the frame.
WhyThe page already has a main scroll and the player its orange time bar: the green bar at the top of the frame reads only the transcript's scroll — measured, scrolling the page leaves the bar at 0 — and the listener sees at a glance where they are in the text relative to the audio (12:48 of 41:20), whether reading ahead or behind. The text itself does not move: only the 4 px line advances. Progress Reveal (fx-0588) would animate each cue in under the same track — a transcript you skim while listening has to stay still, not animate.
SettingsFrame of exactly 280 px (position: absolute; top: 80px under the player), fill linear-gradient(90deg, #34d399, #10b981) — green so it is not mistaken for the player's orange —, track in white at 8%, cues in a 44px 1fr grid with monospace timestamps, role="region" and aria-label on the frame; measured travel 504 px.

How it works

The sold HTML is three levels deep: the frame .demo-preview (centered flex, 100% × 280px, overflow: hidden, position: relative, #0a0a0f background, white text, sans-serif, plus the box-sizing: border-box rule for everything it contains), the scrolling box .sdc-scroll-container (100% × 280px, overflow-y: auto, scroll-behavior: smooth, a 4 px scrollbar styled with ::-webkit-scrollbar) and, inside it, in flow order, the track .sdc-progress-bar (position: sticky; top: 0, 100% × 4px, white at 10%, z-index: 10) holding the fill .sdc-progress-fill (100% height, 90° gradient #8b5cf6 → #ec4899, transform-origin: left, transform: scaleX(0)), then the content .sdc-progress-content: padding: 20px, 800 px tall, a flex column with gap: 16px, a 1 rem weight-700 h3, a 0.78 rem p in white at 50% (line height 1.6) and four 120 px .sdc-progress-block (white at 4%, 8% white border, 12 px radius). Under the frame, a span.sdc-scroll-hint reading “Scroll ↓”, absolute 8 px from the bottom, 0.65 rem, white at 30%, pulsing to 70% every 2 s. The JavaScript field is empty. Measured: scrollHeight 804 (800 px of content + the 4 px track, which is in flow), 524 px of travel.

The engine is two declarations on .sdc-progress-fill: animation: sdcProgressGrow linear and animation-timeline: scroll(nearest). scroll() swaps the animation's clock for an anonymous scroll progress timeline: 0% when the scroller is at the top, 100% at scrollHeight − clientHeight; nearest (the default value, spelled out here) picks the closest scrolling ancestor — .sdc-scroll-container, not the page — on the block axis. The keyframes go from scaleX(0) to scaleX(1); linear is not a detail: any other curve distorts the proportion (measured with ease-out: 0.68 at mid-travel). Measured in Chromium, blank page, wheel in 130 px notches: 0.248 at 130 px, 0.496 at 260, 0.744 at 390, 0.992 at 520, 1 at 524 — scaleX = scrollTop ÷ 524 to the thousandth; a script-set scrollTop = 262 gives exactly 0.5. getAnimations() returns one animation on a ScrollTimeline whose currentTime is a percentage (24.8 at 130 px), finished at the end, running again as soon as you scroll back up. The pinned track does not move a pixel while the heading passes beneath it (measured: track at y = 0 of the frame over the whole travel, h3 at −98 then −492 px). Because this is a transform and not a width, the gradient is painted at full width then compressed: at 50% you see the whole violet → pink gradient on the left half, and the tip is always pink.

The from { transform: scaleX(0) } keyframe is written out explicitly, and it is not redundant. Without it, the starting point is the element's underlying value, scaleX(0), a singular matrix: Chromium still interpolates the functions term by term (0.25 / 0.5 / 0.75 at the quarter marks), but WebKit 26.5, measured, returns 0.0625 / 0.25 / 0.5625 — the square of the progress, a quarter-full bar when the reading is halfway — while its timeline is right (currentTime 25, 50, 75%). With the from, WebKit renders 0.25 / 0.5 / 0.75 / 1 like Chromium. The other thing to know is what nearest really picks. Measured with the effect placed between 2,000 px of content above and below: scrolling the page to 1,900 then 2,500 px leaves the bar at 0; a 262 px wheel notch over the frame sets it to 0.5, and the page does not move. A second copy whose content is only 200 px tall, with no overflow: the timeline is attached to it but stays inactive (currentTime null), bar empty — it does not fall back to the page. Two copies on the same page: no id, no script, each bar reads only its own frame. scroll-behavior: smooth does nothing to the wheel in Chromium; a script-set scrollTop = 262 reads back 0 at once and lands in 249 ms (493 ms in Firefox).

Accessibility

  • prefers-reduced-motion not in the code: measured under emulation, nothing changes — the bar follows the scroll (0.248 at 130 px), the hint pulses (sdcHintPulse running) and the smooth stays. Nothing is hidden: the fill moves only with the reader's gesture, at the exact proportion, which makes it an indicator rather than an autonomous animation (WCAG 2.3.3 targets non-essential motion). Still add @media (prefers-reduced-motion: reduce) { .sdc-scroll-hint { animation: none } .sdc-scroll-container { scroll-behavior: auto } } for the pulse and programmatic scrolls.
  • Keyboard: measured, Tab focuses the frame without a tabindex in Chromium and Firefox (activeElement = .sdc-scroll-container); Arrow Down moves 40 px (bar at 7.6%), Page Down 285 px (54%), End goes to the bottom (100%). WebKit does not focus a scrolling box on Tab (measured: focus stayed on body, Arrow Down did nothing): add tabindex="0", a role="region" with an aria-label, and a :focus-visible style — the default ring surrounds the whole frame.
  • Contrast (on #0a0a0f): white heading 19.8:1; paragraph in white at 50% 5.4:1 at 12.5 px (4.5:1 threshold met); fill 4.7:1 (violet) to 5.6:1 (pink) on the background and 3.7 / 4.4:1 on the track — above the 3:1 required of a graphical component. The empty track, white at 10%, reads 1.26:1: barely visible — which is exactly what a Firefox visitor sees. The hint at 30% reads 2.6:1 static, 1.2:1 to 1.8:1 under the pulse (the 30% color and the 0.3 → 0.7 opacity multiply): put aria-hidden="true" on it or drop the span, it is decorative, hard-coded text.
  • Screen readers: the track and its fill are two empty divs, nothing is announced, and the value exists only in the compositor — no CSS rule can write it into an attribute. If the progress carries meaning (rules to be read to the end, an attestation), you need a role="progressbar" whose aria-valuenow is updated by a four-line scroll listener (which is what Introduction, fx-0575, does); otherwise aria-hidden="true" on .sdc-progress-bar. With a finger, scrolling is native (no listener); overscroll-behavior: contain keeps the page from being dragged along at the end of the frame.

Browser compatibility

CSS only: animation-timeline: scroll() is the one property that sets the floor — Chrome and Edge 115, Safari 26 and iOS 26, no Firefox version (measured in Firefox 153: CSS.supports('animation-timeline: scroll()') false, getAnimations() empty). The rest is classic: position: sticky, transform, linear-gradient, @keyframes, overflow-y; scroll-behavior and ::-webkit-scrollbar are cosmetic. Zero dependencies, empty JavaScript field. The explicit from keyframe is required by WebKit (see the mechanics): without it, Safari 26 renders the square of the progress.

Chrome 115+✓ Full
Firefox✓ No version (empty track, content readable)
Safari 26+✓ Full
Edge 115+✓ Full
Mobile iOS 26+✓ Full
Android Chrome 115+✓ Full

Measured in Firefox, blank page, wheel in 130 px notches then scrollTop set to 50 and 100%: the fill stays at scaleX(0) from start to end — the 4 px track (white at 10%, 1.26:1) is empty and nearly invisible, everything else works. Nothing is hidden, nothing overlaps: the Firefox visitor simply has no bar. Same before Chrome 115 and Safari 26. The sold code has no @supports fallback; two options: hide the track where the timeline is missing — @supports not (animation-timeline: scroll()) { .sdc-progress-bar { display: none } } — or take the JavaScript twin, Introduction (fx-0575), which builds the same bar with a scroll listener and runs in Firefox.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="sdc-scroll-container">
    <div class="sdc-progress-bar">
      <div class="sdc-progress-fill"></div>
    </div>
    <div class="sdc-progress-content">
      <h3>Progress Bar</h3>
      <p>Scroll to see the bar fill up. Uses animation-timeline: scroll() to link the animation to the container scroll.</p>
      <div class="sdc-progress-block"></div>
      <div class="sdc-progress-block"></div>
      <div class="sdc-progress-block"></div>
      <div class="sdc-progress-block"></div>
    </div>
  </div>
  <span class="sdc-scroll-hint">Scroll ↓</span>
CSS
.sdc-scroll-container {
  width: 100%;
  height: 280px;
  overflow-y: auto;
  position: relative;
  scroll-behavior: smooth;
}

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

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

.sdc-scroll-container::-webkit-scrollbar-thumb {
  background: rgba(139, 92, 246, 0.4);
  border-radius: 2px;
}

.sdc-scroll-hint {
  position: absolute;
  bottom: 8px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 0.65rem;
  color: rgba(255, 255, 255, 0.3);
  z-index: 5;
  pointer-events: none;
  animation: sdcHintPulse 2s ease-in-out infinite;
}

.sdc-progress-bar {
  position: sticky;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: rgba(255, 255, 255, 0.1);
  z-index: 10;
}

.sdc-progress-fill {
  height: 100%;
  background: linear-gradient(90deg, #8b5cf6, #ec4899);
  transform-origin: left;
  animation: sdcProgressGrow linear;
  animation-timeline: scroll(nearest);
  transform: scaleX(0);
}

.sdc-progress-content {
  padding: 20px;
  height: 800px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.sdc-progress-content h3 {
  font-size: 1rem;
  font-weight: 700;
  color: #fff;
  margin: 0;
}

.sdc-progress-content p {
  font-size: 0.78rem;
  color: rgba(255, 255, 255, 0.5);
  margin: 0;
  line-height: 1.6;
}

.sdc-progress-block {
  height: 120px;
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.08);
}

@keyframes sdcHintPulse {

  0%,
  100% {
    opacity: 0.3;
  }

  50% {
    opacity: 0.7;
  }
}

@keyframes sdcProgressGrow {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}
JavaScript (fx-0555)

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Frame height (CSS — .sdc-scroll-container and .demo-preview, height: 280px) 280 px / 280 px Both are aligned: the outer frame in overflow: hidden clips anything that overflows, hint included. Give the scrolling box the height of your layout (flex: 1 in a card, height: 60vh…): the timeline reads the real travel, nothing else to change. Without the .demo-preview frame, put box-sizing: border-box back: measured without it, the padding adds to the 800 px and the travel goes from 524 to 560.
Content height (CSS — .sdc-progress-content, height: 800px) 800 px (524 px travel) A demo placeholder: in production, drop the fixed height (height: auto) and let the real text set the travel — 0% at the top, 100% at the last pixel, whatever the length. If the content does not overflow, the timeline stays inactive and the bar empty (measured with 200 px of content).
Track and fill (CSS — .sdc-progress-bar, .sdc-progress-fill) 4 px, white at 10% / 90° gradient #8b5cf6 → #ec4899 The thickness is the track's, the fill follows in height: 100%. The gradient is compressed with the scaleX: the tip stays pink at every position; for a solid color, a single value. A border-radius on the fill would be squashed at low scale too — put it on the track with overflow: hidden.
animation-timeline: scroll(nearest) (CSS — .sdc-progress-fill) scroll(nearest) — the closest scrolling ancestor, block axis scroll(root) reads the whole page (measured: 0.5 at mid-page, bar in position: fixed); scroll(nearest inline) follows a horizontal scroll. The fill must stay inside the frame: moved out of it, nearest climbs to the next scroller — measured, the bar ignores the frame and follows the page. For a bar outside the frame, name the timeline: scroll-timeline-name: --doc on the frame, timeline-scope: --doc on the common ancestor, animation-timeline: --doc on the fill (measured: 0.47 at 262 px over a 560 px travel).
animation-range (CSS — absent, i.e. normal) the whole travel (0% → 100%) animation-range: 0 50% fills the bar over the first half only — but, with no animation-fill-mode: forwards, it drops back to zero past 50% (measured: 0.5 at 131 px, 0 at 262 and 524). Add animation-fill-mode: forwards (or both) as soon as the range does not cover the whole travel.
Curve and keyframes (CSS — animation: sdcProgressGrow linear, @keyframes sdcProgressGrow) linear, from scaleX(0) → to scaleX(1) The only curve that renders the exact proportion. Measured with ease-out: 0.68 at mid-travel in Chromium, 0.47 in WebKit — two browsers, two bars. Keep the from { transform: scaleX(0) } as well: without it, WebKit 26.5 renders the square of the progress (measured 0.25 at mid-travel), Chromium does not.
Smooth scrolling (CSS — .sdc-scroll-container, scroll-behavior: smooth) smooth Only eases programmatic scrolls and anchors: measured, scrollTop = 262 reads back 0 and lands in 249 ms in Chromium, 493 ms in Firefox; the wheel is unaffected. auto for an instant “Go to article 4” button.

FAQ

How does it differ from Scroll Progress Bar (fx-0547)? The two bars look identical.

The core is the same: 4 px track in position: sticky, scaleX(0 → 1) fill on animation-timeline: scroll(), #8b5cf6 → #ec4899 gradient, 800 px of content in a 280 px frame. fx-0547 writes scroll() with no argument — nearest is the default value, so both attach to the same scroller. The differences are in the frame: fx-0547 keeps a .demo-preview at min-height: 300px (the hint sits under the frame), fx-0555 fixes it at height: 280px; overflow: hidden (the hint covers the bottom of the frame); fx-0555 writes the from keyframe WebKit requires, fx-0547 does not; and fx-0555 is free where fx-0547 is premium. Same family in the catalog: Progress Reveal (fx-0539) animates a --sb-progress variable on the same timeline to fill a word in background-clip: text, and Progress Reveal (fx-0588) adds, under the same pinned track, cards revealed by view(). If you want the value (a percentage as text, a button unlocked at 100%) or Firefox, take Introduction (fx-0575), the JavaScript twin.

My bar stays empty although the frame scrolls: why?

Three causes, all measured. The browser has no animation-timeline — Firefox in any version, Chrome before 115, Safari before 26: the fill stays at scaleX(0), the track is empty (hide it with @supports not (animation-timeline: scroll())). The content does not overflow: a frame whose text is 200 px tall in 280 px has zero travel, the timeline is attached but inactive (currentTime null) and the bar stays at 0 — it does not fall back to the page. The fill was moved out of the frame: scroll(nearest) then looks for the closest scroller above it, often the page — measured, frame scrolled 262 px, bar at 0; page scrolled to 700 px, bar at 0.47. In that last case, name the timeline (scroll-timeline-name on the frame, timeline-scope on the common ancestor).

Can it show the progress of the whole page rather than a frame's?

Yes, with two changes: animation-timeline: scroll(root) on .sdc-progress-fill — or scroll() if no ancestor scrolls — and the track in position: fixed; top: 0 (or sticky in your header), without the 280 px frame. Measured on a 3,000 px page: 0 at the top, 0.5 at 1,100 px over a 2,200 px travel, 1 at the bottom. The track must stay in the document, not inside another scrolling block, or nearest would prefer that one. On mobile, the address bar changes clientHeight and hence the travel: the bar realigns itself with no resize listener — an advantage of native CSS over the JavaScript twin.