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.
Updated
Progress Bar
Scroll to see the bar fill up. Uses animation-timeline: scroll() to link the animation to the container scroll.
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
Assembly guide
9 steps · 35 min2 uprights, 5 shelves, 1 back panel, 24 screws, 8 dowels, 1 key.
Push the 8 dowels into the uprights, on the inner side.
Four 40 mm screws into the uprights, without forcing.
Same move; the frame now stands on its own.
Three shelves resting on the dowels, 40 cm apart.
Unit laid flat, panel aligned, one nail every 15 cm.
Measure both diagonals: they must be equal.
Four felt pads under the uprights.
Anti-tip bracket and a wall plug suited to your wall.
Transcript
41 min · 6,200 wordsLé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.
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 thesmoothstays. 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
tabindexin 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 onbody, Arrow Down did nothing): addtabindex="0", arole="region"with anaria-label, and a:focus-visiblestyle — 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): putaria-hidden="true"on it or drop thespan, 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 arole="progressbar"whosearia-valuenowis updated by a four-linescrolllistener (which is what Introduction, fx-0575, does); otherwisearia-hidden="true"on.sdc-progress-bar. With a finger, scrolling is native (no listener);overscroll-behavior: containkeeps 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.
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.
<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>
.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);
}
}
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
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.