Agustin Barrientos
All posts

How animation-timeline: scroll() Replaces Scroll JavaScript

Want more of these in your Google results?

Marketing wants the article page to feel alive. Add a reading progress bar at the top, reveal the cards as they scroll into view, shrink the header when the reader scrolls down, keep the current section name in the corner, fire a “read past the halfway point” event for analytics, update the URL hash as sections pass, and load the next article when the reader reaches the end. One ticket, and it reads like one feature.

You start where everyone starts. A scroll listener that measures how far down the page you are and writes that to a progress bar:

scroll-engine.ts
// scroll-engine.ts, the version that looked done
const $bar = document.querySelector<HTMLElement>( '#reading-progress' )!;

window.addEventListener( 'scroll', () => {
	const max = document.documentElement.scrollHeight - window.innerHeight;
	const progress = window.scrollY / max;
	$bar.style.transform = `scaleX( ${progress} )`;
} );

A handful of lines, it works, you move on. Two weeks later the same file has grown a resize listener to recompute max, a requestAnimationFrame throttle so the handler stops thrashing layout on every wheel tick, a chunk that reads each section’s getBoundingClientRect() to find which one is on screen, a class toggle that shrinks the header past a threshold, another that adds a visible class to each card as it enters, and a cleanup function to tear all of it down. The progress bar that started this is now four lines inside a few dozen lines of scroll engine, and every one of those lines runs on the main thread while the reader scrolls.

This first demo runs a representative slice of that grown engine inside its own scroll panel: the progress bar, the active-section scan, and the header shrink. Scroll it and the counters on the right climb, scroll-handler calls and layout reads and style writes, all next to a fixed lines-of-JavaScript figure that the later steps will delete from. Tap Resize the sidebar to watch the resize recalcs pile up too, the cost a window resize would otherwise trigger.

scroll engine
import { LitElement, html, css } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';

/**
 * The running tally of work the naive scroll engine does, shown live so the cost is concrete rather than asserted.
 */
type EngineCounters = {

	/**
	 * Scroll events the one overloaded handler has serviced, the cost the CSS version drops to zero.
	 */
	handlerCalls: number;

	/**
	 * Layout reads the handler forces per frame: one scrollTop plus one getBoundingClientRect for every section.
	 */
	layoutReads: number;

	/**
	 * Style writes the handler makes, the bar's scaleX set on every scroll.
	 */
	styleWrites: number;

	/**
	 * Recalculations the animated resize drives, standing in for the resize listener a grown engine keeps.
	 */
	resizeRecalcs: number;
};

/**
 * The naive JavaScript scroll engine, the version that looked done and then grew teeth.
 *
 * A single `scroll` listener on the inner scroller paints the reading-progress bar, reads every section box to find the active one, and shrinks the header, so the side panel can count the handler calls, layout reads, and style writes piling up on the main thread.
 * It listens to its own scroll container rather than the window, so it runs inside the article's auto-sized iframe instead of needing the whole page to scroll.
 *
 * @element demo-animation-timeline-scroll-scroll-listener-grows-teeth
 */
@customElement( 'demo-animation-timeline-scroll-scroll-listener-grows-teeth' )
export class ScrollListenerGrowsTeethDemo extends LitElement {

	/**
	 * Live counts of the work the engine does, updated on every scroll and resize so the reader watches the cost climb.
	 */
	@state()
	private counters: EngineCounters = { handlerCalls: 0, layoutReads: 0, styleWrites: 0, resizeRecalcs: 0 };

	/**
	 * The title of the section the engine currently considers active, shown in the sticky header.
	 */
	@state()
	private activeLabel = 'Introduction';

	/**
	 * Whether the header has compressed, toggled by the engine once the reader scrolls past a small threshold.
	 */
	@state()
	private shrunk = false;

	/**
	 * The inner scroll container that drives everything; the engine listens to its scroll, never the window's.
	 */
	@query( '.scroller' )
	private $scroller!: HTMLElement;

	/**
	 * The progress-bar fill the engine writes `scaleX` onto on every scroll event.
	 */
	@query( '.fill' )
	private $fill!: HTMLElement;

	/**
	 * The box whose width the Resize button animates, standing in for a layout change that forces the engine to recompute its measurements.
	 */
	@query( '.resize-box' )
	private $resizeBox!: HTMLElement;

	/**
	 * The cached maximum scroll distance, recomputed on resize the way a real engine caches and invalidates layout.
	 */
	private max = 1;

	/**
	 * The fixed lines-of-JavaScript figure for the grown engine, the constant a few lines of CSS later drive to zero.
	 */
	private readonly linesOfJs = 38;

	/**
	 * The timer driving the Resize animation, tracked so it can be cleared when it finishes or the demo leaves the document.
	 */
	private resizeTimer = 0;

	/**
	 * Styles for the harness shell, the inner scroller, and the side panel of counters.
	 */
	static styles = css`
		*,
		*::before,
		*::after {
			box-sizing: border-box;
		}

		:host { display: block; height: 100%; font-family: var(--font-sans, system-ui, sans-serif); color: var(--ink, #17171a); }
		.wrap { display: grid; grid-template-columns: 1fr 200px; height: 100%; min-height: 420px; }
		.stage { padding: 16px; min-width: 0; }
		.scroller { position: relative; height: 360px; overflow-y: auto; border: 1px solid var(--line, #e7e6e2); border-radius: 12px; background: var(--surface, #fff); scrollbar-width: thin; }
		.topbar { position: sticky; top: 0; z-index: 2; background: color-mix( in srgb, var(--surface, #fff) 88%, transparent ); backdrop-filter: blur(6px); border-bottom: 1px solid var(--line, #e7e6e2); }
		.track { height: 5px; background: var(--line-soft, #eee); }
		.fill { height: 100%; width: 100%; transform: scaleX(0); transform-origin: left; background: var(--accent, #2257e6); }
		.head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 14px 16px; transition: padding 0.2s ease; }
		.head.shrunk { padding: 7px 16px; }
		.head h3 { margin: 0; font-size: 17px; transition: font-size 0.2s ease; }
		.head.shrunk h3 { font-size: 14px; }
		.active { font-family: var(--font-mono, monospace); font-size: 11px; color: var(--accent, #2257e6); white-space: nowrap; }
		.body { padding: 18px 16px 40px; }
		.resize-box { width: 100%; margin: 0 0 18px; padding: 12px 14px; border: 1px dashed var(--accent, #2257e6); border-radius: 8px; font-size: 12px; color: var(--ink-soft, #56565c); background: color-mix( in srgb, var(--accent, #2257e6) 6%, transparent ); }
		section { margin-bottom: 22px; }
		section h4 { margin: 0 0 8px; font-size: 15px; }
		section p { margin: 0; font-size: 14px; line-height: 1.65; color: var(--ink-soft, #56565c); }
		.panel { border-left: 1px solid var(--line, #e7e6e2); padding: 16px; display: flex; flex-direction: column; gap: 0; background: var(--bg, #fcfcfb); }
		.panel h5 { margin: 0 0 8px; font-family: var(--font-mono, monospace); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-soft, #56565c); }
		.row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 0; border-top: 1px solid var(--line-soft, #eee); font-size: 12.5px; color: var(--ink-soft, #56565c); }
		.row b { font-family: var(--font-mono, monospace); font-size: 16px; color: var(--accent, #2257e6); font-weight: 700; }
		.resize-btn { margin-top: 14px; font: inherit; font-size: 12.5px; padding: 9px 12px; border: 1px solid var(--line, #e7e6e2); border-radius: 8px; background: var(--surface, #fff); color: var(--ink, #17171a); cursor: pointer; }
		.resize-btn:hover { border-color: var(--accent, #2257e6); }
		.note { margin: 12px 0 0; font-size: 11px; line-height: 1.5; color: var(--ink-faint, #8a8a90); }
		@media (width <= 560px) {
			.wrap { grid-template-columns: 1fr; }
			.panel { border-left: 0; border-top: 1px solid var(--line, #e7e6e2); flex-direction: row; flex-wrap: wrap; gap: 4px 18px; }
			.panel h5 { flex-basis: 100%; }
			.row { border-top: 0; padding: 4px 0; }
		}
	`;

	/**
	 * Caches the max scroll distance and attaches the scroll listener once the DOM exists, on every connect.
	 *
	 * Awaits the first render so the scroller is laid out, then guards the await against a teardown that raced it.
	 * Using `connectedCallback` rather than `firstUpdated` means a reconnected element re-attaches the listener and recomputes the range instead of going inert, the same lifecycle discipline the final component keeps.
	 */
	async connectedCallback(): Promise<void> {
		super.connectedCallback();
		await this.updateComplete;

		if ( ! this.isConnected ) {
			return;
		}

		this.max = this.$scroller.scrollHeight - this.$scroller.clientHeight;
		this.$scroller.addEventListener( 'scroll', this.onScroll, { passive: true } );
	}

	/**
	 * Removes the scroll listener and stops the resize animation, the cleanup a grown engine has to remember.
	 *
	 * The optional chain covers a disconnect that runs before the first render ever resolved `$scroller`.
	 */
	disconnectedCallback(): void {
		super.disconnectedCallback();
		this.$scroller?.removeEventListener( 'scroll', this.onScroll );
		window.clearInterval( this.resizeTimer );
	}

	/**
	 * The one overloaded handler: it paints the bar, reads every section box to pick the active one, and shrinks the header, all by touching layout on every scroll event.
	 */
	private onScroll = (): void => {
		// The range was cached while the iframe could still be zero-sized (a lazy load racing the page's hydration), so an obviously wrong cache re-measures once here; the resize handler owns every later recompute.
		if ( this.max <= 0 ) {
			this.max = this.$scroller.scrollHeight - this.$scroller.clientHeight;
		}

		const top = this.$scroller.scrollTop;
		const progress = this.max > 0 ? Math.min( top / this.max, 1 ) : 0;
		this.$fill.style.transform = `scaleX( ${ progress } )`;

		const $sections = Array.from( this.$scroller.querySelectorAll<HTMLElement>( 'section[id]' ) );
		let active = this.activeLabel;
		for ( const $section of $sections ) {
			const rect = $section.getBoundingClientRect();
			if ( rect.top <= 140 ) {
				active = $section.dataset.title ?? $section.id;
			}
		}

		this.counters = {
			handlerCalls: this.counters.handlerCalls + 1,
			layoutReads: this.counters.layoutReads + 1 + $sections.length,
			styleWrites: this.counters.styleWrites + 1,
			resizeRecalcs: this.counters.resizeRecalcs,
		};
		if ( active !== this.activeLabel ) {
			this.activeLabel = active;
		}
		if ( ( top > 40 ) !== this.shrunk ) {
			this.shrunk = top > 40;
		}
	};

	/**
	 * Animates the resize box narrower and back over three seconds, recomputing the cached max on every frame the way a resize listener would, so one tap shows the resize recalcs climb without anyone touching the window.
	 */
	private onResize = (): void => {
		const start = performance.now();
		window.clearInterval( this.resizeTimer );
		this.resizeTimer = window.setInterval( () => {
			const t = Math.min( ( performance.now() - start ) / 3000, 1 );
			// A triangle wave: 0 at the ends, 1 in the middle, so the box narrows then returns to its original width.
			const wave = 1 - Math.abs( t * 2 - 1 );
			this.$resizeBox.style.width = `${ 100 - wave * 45 }%`;
			this.max = this.$scroller.scrollHeight - this.$scroller.clientHeight;
			this.counters = {
				...this.counters,
				resizeRecalcs: this.counters.resizeRecalcs + 1,
				layoutReads: this.counters.layoutReads + 1,
			};
			if ( t >= 1 ) {
				window.clearInterval( this.resizeTimer );
			}
		}, 50 );
	};

	/**
	 * Renders the inner scroller with its sticky progress bar and shrinking header beside the live cost panel.
	 */
	render() {
		return html`
			<div class="wrap">
				<div class="stage">
					<div class="scroller">
						<div class="topbar">
							<div class="track"><div class="fill"></div></div>
							<div class="head ${ this.shrunk ? 'shrunk' : '' }">
								<h3>The launch post</h3>
								<span class="active">active: ${ this.activeLabel }</span>
							</div>
						</div>
						<div class="body">
							<div class="resize-box">A sidebar that can change width. Tap Resize to watch the engine recompute.</div>
							<section id="intro" data-title="Introduction">
								<h4>Introduction</h4>
								<p>Scroll this panel. The bar at the top fills, the header shrinks, and the active label tracks where you are. Every bit of that runs in one scroll handler.</p>
							</section>
							<section id="setup" data-title="Setup">
								<h4>The setup</h4>
								<p>It began as one line that wrote the bar's scaleX from the scroll offset. Then it grew a section scan, a header toggle, and a resize listener, all sharing the same event.</p>
							</section>
							<section id="growth" data-title="Growth">
								<h4>How it grew</h4>
								<p>Each new request rode the one handler. The counters on the right are the bill: a layout read per section, a style write, every single scroll frame.</p>
							</section>
							<section id="cost" data-title="Cost">
								<h4>The cost</h4>
								<p>On a fast desktop this feels fine. On a mid-range phone, reading layout inside the scroll handler makes the browser recompute geometry on frames it would rather spend scrolling.</p>
							</section>
							<section id="end" data-title="The point">
								<h4>The point</h4>
								<p>One small ask became a scroll engine. The next steps in the article delete the paint half of it and watch these counters fall to zero.</p>
							</section>
						</div>
					</div>
				</div>
				<div class="panel">
					<h5>Scroll engine cost</h5>
					<div class="row"><span>scroll-handler calls</span><b>${ this.counters.handlerCalls }</b></div>
					<div class="row"><span>layout reads</span><b>${ this.counters.layoutReads }</b></div>
					<div class="row"><span>style writes</span><b>${ this.counters.styleWrites }</b></div>
					<div class="row"><span>resize recalcs</span><b>${ this.counters.resizeRecalcs }</b></div>
					<div class="row"><span>lines of JavaScript</span><b>${ this.linesOfJs }</b></div>
					<button type="button" class="resize-btn" @click=${ this.onResize }>Resize the sidebar</button>
					<p class="note">A few lines of CSS later drive the same bar with every counter at zero.</p>
				</div>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-animation-timeline-scroll-scroll-listener-grows-teeth': ScrollListenerGrowsTeethDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

That growth is the real subject. The ticket asked for seven things, the naive instinct answered all seven with one scroll listener, and the result is a single overloaded handler doing seven unrelated jobs on the hot path. Most of those jobs don’t belong on a scroll listener at all anymore, and a couple of them never belonged in JavaScript. Sorting out which is which is what this whole piece comes down to.

A quick note on letting AI code this

Ask a model for a reading progress bar and it’ll hand you a competent scroll listener with a requestAnimationFrame throttle and a resize handler, the same engine you’d have written, written well. Ask it the right way, for a CSS scroll-driven version, and it’ll hand you a few lines that delete every one of those lines. Both answers run, and they sit at opposite ends of a wide quality scale. The value you bring is knowing that painting the reader’s position is a visual timeline the browser can scrub for you, so you ask for the version that gets out of JavaScript’s way and recognize it as the better one when it lands. The models keep getting better at writing both; the judgment about which to ask for is the part that stays yours.

The contracts hiding in one scroll listener

A junior sees one event, so they write one listener. A senior sees several different jobs wearing that event and asks, for each, who should own it now. Painting the reader’s position is one kind of job. Holding which section is active is a different kind. Firing an analytics milestone is a third. They all happen during a scroll, and that’s the only thing they share.

Sort them before writing anything, because the owner differs by row, and getting it wrong means either reimplementing something the browser now does for free or shipping a paint job as if it were application state.

Table Each scroll behavior, the old instinct, who owns it now, and the question that decides.
Scroll behaviorOld instinctWho owns it nowSenior question
Reading progress barscroll listener writing scaleXCSS scroll() timelineIs this only painting position?
Card reveal on enterscroll or observer plus a class toggleCSS view() timeline if reversible; JavaScript state if one-shotShould this scrub and reverse with scroll, or fire once and stay?
Shrinking sticky headerscroll listener toggling a classposition: sticky pins it; a named scroll() timeline drives the shrinkIs this layout, or is it state?
Active section labelscrollspy scroll listenerJavaScript IntersectionObserverDoes the app read this value?
Analytics “read 50%” eventscroll listener plus a beaconJavaScript IntersectionObserverIs this a business event?
URL hash and historyscroll listener plus pushStateJavaScript IntersectionObserverDoes this change navigation?
Load the next pagescroll listener plus fetchJavaScript IntersectionObserverDoes this fetch data?

That table is the article. Scroll-driven animations move where the judgment happens without taking it off your plate. The old question was “how do I optimize this scroll listener?” The new one is “why is this a scroll listener at all, and which half of it is just paint?” A scroll-driven animation scrubs CSS keyframes from how far you’ve scrolled, and that’s the entire capability. A progress bar needs nothing more, and neither does a card reveal that is allowed to rewind, so CSS can own them. The shrinking header splits in two, since sticky layout keeps it pinned while the shrink itself is one more visual timeline. The moment the work is a value the app reads, an event the business cares about, a route change, or a fetch, you’re past what a timeline can express, and that code stays in JavaScript. The point that recurs underneath every row is that the JavaScript left over is doing real work, and because IntersectionObserver schedules visibility checks asynchronously, that code stays off the per-scroll hot path and never polls layout on every frame.

One word in that table is doing quiet double duty. Paint here means visual presentation, the job of showing something on screen, not the paint stage of the browser’s rendering pipeline; the compositor section later deals with that narrower meaning by name.

The bar we’re holding it to

The finished page should hit these targets, some built in full and some deliberately classified rather than built:

  • start from the working JavaScript engine above and show it shrinking as each paint job moves to CSS;
  • move the reading progress bar to a CSS scroll() timeline, declared after the animation shorthand, gated behind @supports over a static empty-bar base so it never sits falsely full where the timeline can’t run;
  • move the card reveals, deliberately chosen reversible, to a CSS view() timeline with an explicit animation-range, on a content-complete base, behind a reduced-motion guard;
  • pin the header with position: sticky and route any compression by contract, CSS while the shrink is purely visual, smooth or stepped, and JavaScript once the app reads the stuck state or the needed CSS lacks a fallback;
  • build the active-section state deeply with an IntersectionObserver whose selection rule resolves in document order so the result is deterministic, and fire the halfway analytics event once behind a guard, both off IntersectionObserver and never a scroll listener;
  • name the URL hash and the load-more fetch as JavaScript-owned behaviors triggered by observer signals and show their shape, without building a router or an infinite-scroll subsystem;
  • keep the static base style the readable, content-complete state, so Firefox stable, an inactive timeline, and a reduced-motion preference all land on a usable page;
  • wrap the JavaScript-owned behavior in a small typed Lit component that emits the active-section change and the analytics milestone as typed events, fires the load-more request once behind an in-flight guard, and tears down every observer in disconnectedCallback.

The split is worth naming. This build implements the bar, the reveals, the active-section state, the halfway event, and the load-more signal in full, and it classifies the header shrink, the URL mutation, and the fetch boundary, showing where each belongs without turning it into a separate subsystem.

A few concrete checks to measure your own work against. The progress bar should give an identical visual result while the scroll-handler count, the layout-read count, and the lines of JavaScript for that behavior all drop to zero. The cards should reveal on scroll progress, and with reduced motion they should stay present and fully readable in their static state. Pointing the bar at a nested panel’s scroller, at the outer scroller, or at an axis with no scrollbar should change which scroller drives the timeline or leave it inactive on the base style. And the version on transform is the one the compositor can keep moving while the main thread is flooded, where the same motion expressed with a layout property freezes and then jumps once the thread frees.

What you’ll need

Be comfortable with TypeScript and the DOM. No Lit experience required, since it’s a thin layer over web components and I’ll explain the Lit-specific bits as they come up. Beyond that you’ll want Lit and TypeScript for the component that owns the JavaScript half, and your browser devtools, the Animations panel to see a timeline scrub and the Performance panel to see which work landed on the compositor. No animation library and no scroll library, which is most of the point.

One support note up front, so “delete your scroll JavaScript” never reads as “works everywhere.” As of the time of writing, scroll-driven animations are supported across most browsers but not all of them. Chrome and Edge shipped them in 115 back in 2023, Safari brought them to stable in the 26 release, and Firefox is the holdout, where the feature is still behind a flag in stable. So this is a technique you ship today as a progressive enhancement, carried by a real fallback for the engines that don’t support it yet. The exact picture is in the table a few steps down, next to the feature query that gates the first timeline.

Try it yourself first

Give it a real attempt before reading on. Take the grown engine from the opener and try deleting the progress-bar handler entirely, replacing it with a CSS scroll() timeline on the bar. Then look at the card reveals and ask whether the card is readable with the animation removed, because that answer decides your fallback. Leave the active-section label and the analytics event for last, and notice that those two never feel like paint. The sorting is the exercise; the syntax is the easy part.

Building it

This is the longest section, and it’s organized as a build that you could follow line by line. Each step shows only the new or changed slice, and the full component is assembled once at the end. The order is deliberate: get the page feeling shippable first, get honest about the edges second, and put the judgment calls last.

Step 1: the scroll engine, and what it actually does

The opener’s demo is the starting line. That engine packs several of those jobs into one scroll listener: the progress bar writes scaleX, a getBoundingClientRect() pass finds the active section, and a threshold toggles the header’s shrunk class, with a resize listener recomputing the measurements beside it. It works, and on a fast machine with a mouse it even feels fine. The cost shows up on the counters in that demo and on a mid-range phone, where reading layout inside a scroll handler means the browser recomputes geometry on frames it would rather spend scrolling.

Keep that engine in mind as the thing we’re deleting from. Every later step removes a slice of it and names exactly which lines went away, so the deletion stays visible rather than hand-waved. The grown engine is the demo embedded in the opener; the steps below reference it rather than re-embedding it.

Step 2: name the jobs

Before any new syntax, run the contracts table from above over the engine. Two of the seven behaviors are pure paint, the progress bar and the card reveals, though the reveal has to clear one product question first. Should it scrub and reverse as the reader scrolls, or fire once and stay? A view() timeline follows the element both ways, so it owns the reversible version; a reveal that must happen exactly once is persistent state no matter how visual it looks, and state is JavaScript’s column. This build chooses the reversible contract, and step 4 makes that choice out loud. The header is the ambiguous one, and it splits: pinning it is layout that sticky owns, and any shrink is one more visual timeline. Four more are clearly not paint. The active-section label is state the app reads, the halfway event is a business event, the URL hash is navigation, and load-more is data. A scroll-driven CSS timeline can own a visual timeline and nothing else, so it’s a candidate for the two paint jobs. The header gets sticky positioning for the pinning half. The last four stay in JavaScript by construction, because a timeline runs no script of yours, fires no event, and has no access to history, the URL, or the network.

That routing is the senior move, and it’s reusable well beyond this one API. Get it right and the rest of the build is mechanical.

Step 3: delete the progress listener

The reading progress bar is the cleanest case, because it’s only painting position. A scroll-driven animation expresses the whole thing in CSS. You write keyframes that grow the bar from empty to full, then attach the animation to a scroll() timeline so the browser scrubs those keyframes from the document’s scroll progress instead of from time.

global.css
/* global.css */
@keyframes grow-progress {
  from { transform: scaleX( 0 ); }
  to { transform: scaleX( 1 ); }
}

#reading-progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  transform: scaleX( 0 ); /* static base: an empty bar where the timeline never runs */
  transform-origin: left; /* resolves to 0% 50%, so it grows from the left edge */
}

@supports ( ( animation-timeline: scroll() ) and ( animation-range: 0% 100% ) ) {
  #reading-progress {
    animation: grow-progress 1ms linear both; /* the 1ms never drives progress; the timeline does */
    animation-timeline: scroll(); /* must come after the animation shorthand */
  }
}

A few things in that block are load-bearing, and the first is an ordering trap that bites almost everyone once. The animation-timeline property is a reset-only sub-property of the animation shorthand, which means writing the shorthand resets animation-timeline back to auto and the shorthand itself can’t set it. So animation-timeline: scroll() has to come on its own line, after the animation shorthand, or it silently does nothing. MDN states it flatly: when you create a scroll-driven animation, declare animation-timeline after any animation shorthand for it to take effect. The same rule covers animation-range-start and animation-range-end, which matters in the next step.

The 1ms duration looks odd and earns a sentence. The timeline drives the animation, not the clock, so the value barely matters, but it can’t be zero. Chrome’s docs reach for auto, which fills the whole timeline in a supporting engine; a literal 1ms just travels better, because Firefox’s flagged build wants a non-zero duration and an engine that falls back to a time-based animation reads auto as 0s. So 1ms is the one value that behaves the same everywhere, enough for a valid animation to exist for the timeline to scrub. The both fixes a subtler edge: the very bottom of the scroll sits just past the animation’s active range, and without a fill the bar snaps back to its empty base the moment you reach it, so both holds the final keyframe instead.

The @supports gate and the static base are the fallback, and they’re more deliberate than they look. The base transform: scaleX( 0 ) is an empty bar. That’s the state Firefox stable lands on, because every scroll-driven rule is gated, the gate fails there, and the element keeps its base style. The gate is also what makes the both safe to write. Skip it, and an engine that doesn’t understand scroll() walks into a genuinely nasty trap: animation-timeline stays auto, so the 1ms animation runs as an ordinary time-based one, finishes almost instantly, and with both holding the final keyframe it parks the bar full, telling every Firefox reader they’ve finished an article they just opened. Behind the gate, an engine that fails the query never parses the rule at all, so the fill only ever runs where a real timeline is scrubbing it. The gate plus the scaleX( 0 ) base keeps that whole failure mode off the page.

The condition tests for animation-range even though this bar never sets one. That’s on purpose. Firefox’s flagged implementation parses animation-timeline but not animation-range, so a bare animation-timeline: scroll() check would pass there and run a half-supported model. Using animation-range as a completeness proxy makes the whole page degrade as one piece, so you never get a working bar sitting above broken reveals. Reading the spec closely is what teaches this: feature detection sometimes has to gate a whole capability set rather than a single property, because one engine ships part of it.

This demo runs the same bar with the JavaScript deleted. Scroll it and the panel that climbed in the opener stays flat: the scroll-handler count, the layout-read count, and the style-write count all hold at zero, and the line count for that behavior drops to nothing. The handler, the resize listener, the scaleX write, and the requestAnimationFrame throttle that a hand-written engine grows are simply gone, and a CSS scroll() timeline scrubs the bar in their place.

reading-progress
/* The static base: an empty bar, so an engine without scroll-driven
   animation shows scaleX(0) rather than a stuck, permanently full bar. */
.fill {
	transform: scaleX( 0 );
	transform-origin: left;
}

@keyframes grow-progress {
	from { transform: scaleX( 0 ); }
	to { transform: scaleX( 1 ); }
}

/* Gate the scroll-driven rule, then attach the timeline. scroll() resolves
   to the nearest scrolling ancestor, the inner scroller the fill sits in.
   animation-timeline is declared after the shorthand or it resets to auto.
   The both fill holds the bar full at the exact end of the scroll, which
   sits just past the animation's active range. */
@supports ( ( animation-timeline: scroll() ) and ( animation-range: 0% 100% ) ) {
	.fill {
		animation: grow-progress 1ms linear both;
		animation-timeline: scroll();
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.
Browser supportanimation-timeline87% of users
Chrome115
Edge115
FirefoxPreview
Safari26
Safari iOS26
Chrome Android115

Browser data updated September 24, 2026

That table is the support reality the fallback exists for. The one browser it flags, Firefox, is dark for the entire technique in stable, which is exactly why the static base has to be a usable state rather than a decorative starting point. Read the number and the fallback together. This ships today across Chromium and Safari as a progressive enhancement, and the empty-bar base completes it for the third engine. Confirm the current picture in the table when you build, since Firefox’s status is the volatile fact here and the percentage recomputes as the data updates.

One more beat belongs in this step, because it’s the question people get wrong next: what chooses the timeline. The scroll() function takes a scroller and an axis, defaulting to scroll(nearest block). Plain scroll() walks up to the nearest scroll container; scroll(root) always means the document; scroll(self) means the element itself. If the chosen axis has no scrollbar, the timeline is inactive and the element shows its base style, which is a different failure from being unsupported. A common way to trip it is overflow: hidden, which turns an element into a scroll container and so can quietly become the one scroll(nearest) resolves to instead of the scroller you meant. The fix is to be explicit about which element should scroll: if an element is meant to be the timeline source, give it overflow: auto or scroll, and if an ancestor only needs to clip, prefer overflow: clip over hidden so it never becomes a scroll container.

This demo makes that concrete. Pointing the bar at a specific scroller rather than its nearest one is done with a named timeline: each box publishes a named scroll timeline with the scroll-timeline property, and a shared timeline-scope lifts those names into reach. The demo’s code panel spells the three moving parts out, scroll-timeline: --name on each scroller, timeline-scope on a common ancestor, and animation-timeline: --name on the bar. That timeline-scope step is the one piece this named-timeline pattern leans on that Firefox doesn’t support, so a named timeline carries a slightly tighter support floor than a plain scroll(). Step 5 comes back to that. The demo pairs an outer scroller (the blue box) with a nested panel (the green box), and three buttons point the progress bar’s timeline at one of them or at an axis with no scrollbar. Pick a source, scroll the box the status line names, and the bar fills; choose the wrong axis and the timeline goes inactive, leaving the bar on its empty base.

scroll() source
/* One bar, one set of keyframes. Only the timeline it reads changes. */
@keyframes fill {
	from { transform: scaleX( 0 ); }
	to { transform: scaleX( 1 ); }
}

.bar {
	/* Static base: empty bar wherever the timeline never runs. */
	transform: scaleX( 0 );
	transform-origin: left;
}

/* The two scrollers each publish a named timeline. */
.panel { scroll-timeline: --panel block; }
.outer { scroll-timeline: --outer block; }

/* The bar is a descendant of .outer but a sibling subtree from .panel, so a
   common ancestor lifts both names into scope where the bar can name either. */
.stage { timeline-scope: --panel, --outer; }

@supports ( ( animation-timeline: scroll() ) and ( animation-range: 0% 100% ) ) {
	.bar { animation: fill 1ms linear both; } /* both holds the ends: the exact bottom sits past the active range and would otherwise snap the bar to its empty base */

	/* The nested panel drives the bar. */
	.bar[ data-source='panel' ] { animation-timeline: --panel; }

	/* The whole demo (the outer scroller) drives the bar. */
	.bar[ data-source='outer' ] { animation-timeline: --outer; }

	/* Wrong axis: the nearest inline scroll container has no scrollbar, so this
	   timeline is inactive and the bar keeps its empty base. */
	.bar[ data-source='wrong' ] { animation-timeline: scroll( nearest inline ); }
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

Step 4: replace the reveals

The card reveals are the other visual timeline, and they use the second timeline kind. Where scroll() tracks how far a container has scrolled, view() tracks a single element passing through the scrollport, which is exactly “reveal this as it enters.” You set an animation-range to say where in that pass the animation runs.

This implementation picks the reversible reveal deliberately. A view() timeline is a mapping, not a memory, so scrolling back up rewinds the card and scrolling down past it plays the lift again. If the ticket means reveal once and stay visible, that’s a one-shot decision the application has to remember, so it stays in JavaScript, typically an IntersectionObserver adding a class the element never loses, with the exact same content-complete base underneath. Ask which contract the product wants before you delete anything.

global.css
/* global.css */
@keyframes reveal {
  from { opacity: 0; transform: translateY( 2rem ); }
  to { opacity: 1; transform: translateY( 0 ); }
}

.card { opacity: 1; } /* static, content-complete base for unsupported and reduced motion */

@supports ( ( animation-timeline: view() ) and ( animation-range: entry 0% cover 40% ) ) {
  @media ( prefers-reduced-motion: no-preference ) {
    .card {
      animation: reveal 1ms linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 40%; /* start as it enters, finish at 40% of the cover range */
    }
  }
}

The single most important line here is the base .card { opacity: 1 }. The card is visible by default, and the animation only decorates how it arrives. A reader tempted to “tidy” that base to opacity: 0 would break the fallback in every case it has to cover, hiding content in Firefox, hiding it under reduced motion, and hiding it whenever the timeline is inactive. The fallback must never hide content. That’s the product contract the paint deletion isn’t allowed to damage, so the visible base stays outside the gate where nothing can switch it off.

The both token in animation: reveal 1ms linear both is animation-fill-mode: both, the same fill the bar carries in step 3, and here it’s what stops the card from popping. With it, a supported browser holds the from-state (faded and shifted down) before the card reaches the entry range and holds the to-state after, so the reveal eases cleanly across the range instead of snapping at the boundary. The both lives inside the gate, so it only ever affects supported, motion-allowing browsers; the base outside the gate is still a plain visible card.

The reduced-motion guard wraps the reveal but not the bar, and the split is intentional. The reveal is decoration, so it goes behind @media ( prefers-reduced-motion: no-preference ) and a reader who’s asked for less motion simply gets the static cards. The progress bar communicates reading position, which is information the reader needs, so it stays simple, non-parallax, and outside the motion guard. One honesty note: a pure-CSS bar exposes no aria-valuenow to update, so it is marked aria-hidden and reads as presentational to assistive technology, and a screen-reader user takes reading position from the section headings and document structure rather than the bar. (If your product’s motion policy is strict enough to want the bar gone too, disable it rather than animating it.) Some people get motion sick from movement tied to scrolling, so honoring prefers-reduced-motion on the decorative pieces is part of doing this right.

Scroll this demo and the cards rise and fade in on their view() progress; scroll back up and the timeline rewinds them, so you can replay any reveal by hand. Flip Reduced motion and the cards stay present and fully readable, which is the fallback floor you’re designing to. The demo dials the lift up so it reads at a glance in a small frame, so its keyframe travels farther than the minimal translateY( 2rem ) in the snippet above; the gate, the range, and the base are identical.

view() reveal
@keyframes reveal {
	from { opacity: 0; transform: translateY( 88px ) scale( 0.85 ); }
	to { opacity: 1; transform: translateY( 0 ) scale( 1 ); }
}

.card { opacity: 1; }

@supports ( ( animation-timeline: view() ) and ( animation-range: entry 0% cover 40% ) ) {
	@media ( prefers-reduced-motion: no-preference ) {
		.card {
			animation: reveal 1ms linear both;
			animation-timeline: view();
			animation-range: entry 0% cover 40%;
		}
	}
}
An iframe can't force prefers-reduced-motion, so the Reduced motion toggle strips the reveal instead, landing the cards on the same readable base a real preference would.

Step 5: the header is two jobs

The shrinking header is the row that tempts you to overthink it, and the senior move is noticing it’s two jobs wearing one word. Keeping the header on screen is layout, and position: sticky owns that half outright, no JavaScript and no timeline. The shrink is a separate job, because sticky positioning only pins the element; it doesn’t grow a stuck-state hook or compress anything by itself.

So route the shrink like every other row in the table, by contract rather than by easing. If the shrink is purely visual, CSS can own it whichever way it moves: a named scroll() timeline scrubs it smoothly with progress, and the same timeline with a steps() easing, or a scroll-state container query where they’re supported, snaps it at a threshold. It stays in JavaScript when the app reads the stuck state or changes behavior on it, or when the CSS mechanism you’d need has no acceptable fallback. When you do reach for the timeline, watch the structure. A named timeline declared on the scroller is visible only to its descendants, so a header that lives outside the scroller would need timeline-scope on a common ancestor to see it. timeline-scope landed slightly later than the rest (Chrome 116, Safari 26) and, like everything here, is missing from Firefox, so it’s one more line to check in the support table rather than a separately risky feature. All of which is a reason to keep the pinning in sticky layout and add the shrink only when the design earns it. The judgment is the same one from the table: ask which half is layout, which is paint, and which is state before you decide what owns each.

Step 6: keep real state in JavaScript, and keep it senior

Now the four behaviors a timeline can’t touch. The active-section label is a value the app reads, the halfway milestone is a business event, the URL hash is navigation, and load-more is data. None of them are paint, so none of them move to CSS. They also don’t belong on a scroll listener, because the right tool is IntersectionObserver, which the browser schedules asynchronously so your code never reads layout on every frame. Deleting the paint code didn’t lower the bar on this code; it still has to be careful.

Wrap it in a small typed Lit component. Its two payload-bearing events, the active-section change and the one-shot analytics milestone, get distinct types so they never share a shape; the complete types.ts is folded into the assembly step below.

The interesting decision is which section counts as active. The product question is “which section is the reader at,” and the tempting answer, picking whichever section reports the largest intersectionRatio, is wrong in a way that looks right. That ratio measures how much of the section’s own box is visible, so a short section that fits entirely in view scores a perfect 1 while the tall section the reader is actually inside never gets close, since most of its box sits off screen. The metric rewards being small while what you want is reading position. The rule that actually answers the question is a thin reading line near the top third of the viewport, with the active section being the first one in document order that crosses it. Resolving in document order is what makes it deterministic, so the result never depends on the order the browser happens to deliver observer entries.

article-scroll.ts
// article-scroll.ts, choosing the active section
/**
 * Keeps {@link ArticleScroll.onLine} in sync and picks the active section in document order.
 *
 * The active one is the first id in document order that is currently on the line, so the result never depends on the order the browser delivers entries.
 * Choosing by the largest `intersectionRatio` is the tempting alternative, but the ratio measures how much of a section's own box is visible, so a short section fully in view outranks the tall one the reader is actually inside.
 */
private readonly onSectionsCross = ( entries: IntersectionObserverEntry[] ): void => {
	for ( const entry of entries ) {
		if ( entry.isIntersecting ) {
			this.onLine.add( entry.target.id );
		} else {
			this.onLine.delete( entry.target.id );
		}
	}
	const active = this.sectionIds.find( ( id ) => this.onLine.has( id ) );
	if ( active ) {
		this.setActiveSection( active );
	}
};

Observing the whole section box rather than a single line element keeps the reading band from falling into a gap inside a long section. The set tracks membership, and document order from sectionIds picks the winner. If the band ever does land in a gap between sections, the if ( active ) guard simply holds the last active section rather than clearing the label, so the readout never blanks on a short viewport.

That reading line is a rootMargin band, and it hides a trap. Percentage margins are specified relative to the root’s width, even for the top and bottom edges, which makes them the wrong unit for a line meant to sit at a fraction of the root’s height, and historical engine behavior has not always matched the spec. So measure the height yourself and set the vertical margins in pixels. Observer options can’t be mutated after construction, so a height change means recreating the observers, which the resize handler does, off the scroll path and only when the height actually changes.

article-scroll.ts
// article-scroll.ts, reading lines in pixels, not percentages
/**
 * Builds the reading-line and halfway observers from the current viewport height, recreating them when that height changes.
 *
 * `rootMargin` percentages are specified relative to the root's width, even for the top and bottom edges, which makes them the wrong unit for a line meant to sit at a fraction of the root's height, and historical engine behavior has not always matched the spec. So the margins are computed in pixels from the measured height: a line near the top third for the active section, and a thin band at the center for the halfway milestone.
 * Observer options cannot be mutated after construction, so a height change disconnects and recreates both; the early return skips a width-only resize, and none of this runs on the scroll path.
 */
private buildLineObservers(): void {
	const height = document.documentElement.clientHeight;
	if ( height === this.lineObserverHeight ) {
		return;
	}
	this.lineObserverHeight = height;

	const readingTop = Math.round( height * 0.3 );
	const readingBottom = Math.round( height * 0.65 );
	const halfwayInset = Math.round( height * 0.49 );

	this.sectionObserver?.disconnect();
	this.sectionObserver = new IntersectionObserver( this.onSectionsCross, {
		rootMargin: `-${ readingTop }px 0px -${ readingBottom }px 0px`,
	} );
	for ( const $section of this.$sections ) {
		this.sectionObserver.observe( $section );
	}

	this.halfwayObserver?.disconnect();
	this.halfwayObserver = new IntersectionObserver( this.onHalfwayCross, {
		rootMargin: `-${ halfwayInset }px 0px -${ halfwayInset }px 0px`,
	} );
	this.halfwayObserver.observe( this.$halfwaySentinel );
}

The section-change event is gated in one place, setActiveSection, which sets the active id and dispatches the event only when the id actually changes, so a stable section never re-emits as the reader scrolls within it.

The analytics milestone is a separate observer, and its geometry is worth a paragraph. The sentinel is a zero-paint strip that starts at half the content’s height and runs to its end, and the observer watches it through a thin band at the middle of the viewport, so the two first touch right as half the scrollable range falls behind the reader, closely matching the progress bar’s fifty percent. Closely is the honest word, because IntersectionObserver runs asynchronously and reports approximate boundary crossings, which suits an analytics milestone and rules out a pixel-exact trigger. Both halves of that shape are load-bearing. A sentinel merely entering the viewport would fire too early, about half a viewport of scroll before the midpoint, and a point-sized sentinel could jump clean past a thin band during a fast flick, because the observer only samples at frame boundaries; the strip keeps intersecting once crossed, so no scroll speed can skip it. The event itself fires once behind a hasReportedHalfway guard, so scrolling back up and then down never re-reports.

Three jobs, three observers, never one overloaded handler. The URL hash and load-more are the same signal-then-act pattern, JavaScript-owned behaviors each triggered by its own observer signal. A hash update would watch the same section boxes and call history.replaceState rather than the old instinct’s pushState, so a scrollspy that fires on every section doesn’t flood the back button with entries, and load-more watches a sentinel at the foot and requests the next page. This build ships load-more’s shape (a load-more event fired once behind an in-flight guard, with a rearmLoadMore() the consumer calls after appending the page, so a re-intersecting sentinel never requests the same page twice) and names the hash as the same pattern, and stops short of a router or an infinite-scroll subsystem, because deciding how deep to build each job is itself the judgment the piece is about. The appended page needs no ceremony either, since the slot’s slotchange handler re-collects the sections and the new page joins the scrollspy in document order.

Deleting the scroll listener removed the classic leak, the handler nobody remembered to remove, but the observers that replace it carry their own teardown obligation, so disconnectedCallback drops the resize listener, disconnects every observer, and clears the on-line set.

This step writes no new demo of its own. The full component is folded into step 8 below, and the review demo there runs a scroller-rooted copy of it.

Step 7: the compositor boundary

This is the performance step, and it’s where “off the main thread” gets stated precisely so nobody leaves with the wrong souvenir. Scroll-driven animations can run on the compositor thread, staying smooth while the main thread is busy, but only when the animated properties are compositor-friendly. The cross-engine safe pair is transform and opacity, which is why every keyframe in this build animates one of those two. Animate a layout property instead, height, top, width, margin, and the work goes back to the main thread every single frame, which is exactly the jank you were trying to escape.

That boundary is structural. It’s how the platform is built, and no future version patches it away. A timeline only scrubs keyframe values from scroll progress; it can’t run your script, fire your event, or touch the network. And the compositor can only fast-path properties that don’t trigger layout. So the rule holds. Keep the scroll-driven paint on transform and opacity, and keep state, events, navigation, and data in JavaScript.

This demo makes the boundary visible. Two boxes glide the same way, one animating transform (the compositor lane) and one animating layout properties, margin-left and height (the main-thread lane). Tap Run heavy work and the page blocks for a moment: the layout box freezes mid-glide and then jumps to catch up, while the transform box is the one the compositor can keep gliding. The Reduced motion toggle pauses both lanes, since with nothing moving there’s nothing for the block to freeze, which is the honest behavior under that preference. A scroll-driven animation on transform or opacity gets this same treatment, which is why this build keeps its timelines off layout. One honest caveat: a sandboxed iframe gives no lab-grade numbers, and even when the compositor keeps a box moving, JavaScript labels and counters elsewhere can still freeze, because that’s the main thread the work is choking. The claim is the narrow one. For compositor-friendly properties, the browser may keep the animation moving even while the main thread is blocked. Scroll-driven animation still costs work; the compositor just runs the safe properties off the main thread.

compositor-boundary
/* Same visual glide, two engines. */

/* Compositor-friendly: transform, with opacity the classic compositor-safe pair, */
/* is a property the browser can keep on its own thread, so the compositor keeps it gliding */
/* while the main thread is blocked. The animation is already active, so it is */
/* compositor-eligible on its own; no will-change hint is needed. */
@keyframes glide-transform {
	from { transform: translateX( 0 ) scale( 1 ); }
	to   { transform: translateX( 180px ) scale( 1.25 ); }
}

.box--transform {
	animation: glide-transform 2.6s linear infinite alternate;
}

/* Main-thread bound: animating a layout property (here `margin-left` and */
/* `height`) recomputes layout every frame, so the same motion stalls the */
/* instant the main thread is blocked, then jumps when it frees. */
@keyframes glide-layout {
	from { margin-left: 0; height: 56px; }
	to   { margin-left: 180px; height: 70px; }
}

.box--layout {
	animation: glide-layout 2.6s linear infinite alternate;
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

Step 8: assemble and review

The pieces from step 6 fit together in one place. connectedCallback waits for the first render, collects the section ids, builds the reading-line observers from the current height, points the load-more observer at the foot sentinel, and listens for the resize that would change the line geometry, so the visibility work is wired up once and nothing rides a scroll event:

article-scroll.ts
// article-scroll.ts, wiring it together
/**
 * Wires every observer once the slotted sections and the sentinels exist.
 *
 * Awaits the first render so the `<slot>` is populated and both sentinels are in the shadow root, then collects the section ids, builds the reading-line observers from the current height, points the load-more observer at the foot sentinel, and listens for the resize that would change the line geometry.
 * Keeping all observation here, rather than in a `scroll` handler, is the whole point: the browser schedules visibility work asynchronously, so the page never reads layout on every frame.
 * The `isConnected` check guards the await: if the element left the document while the first render was pending, `disconnectedCallback` has already run, and starting to observe now would leak observers on a detached tree.
 */
async connectedCallback(): Promise<void> {
	super.connectedCallback();
	await this.updateComplete;

	if ( ! this.isConnected ) {
		return;
	}

	this.collectSections();
	this.buildLineObservers();
	this.loadMoreObserver.observe( this.$footSentinel );
	window.addEventListener( 'resize', this.onResize );
}

The isConnected guard is that teardown discipline pointed the other way: a component that can be removed mid-await has to confirm it still lives in the document before it starts observing. The progress bar and the reveals aren’t in this file on purpose, since they are the CSS from steps 3 and 4. The complete types.ts and component sit folded below to copy whole. The demo that closes this step scrolls inside its own box, so it runs a scroller-rooted copy of these observers, and its code panel shows that harness rather than this component, keeping the panel honest about what the iframe runs.

types.ts
/**
 * A read-only snapshot of the scroll-driven state that `article-scroll` owns in JavaScript.
 * This is the half a scroll-driven CSS timeline cannot express, so it stays a value the app reads rather than paint.
 * `activeSectionId` is the section currently crossing the reading line, and `readPastHalfway` flips once the analytics milestone has fired.
 */
export interface ArticleScrollState {

	/**
	 * The id of the section currently on the reading line, or an empty string before the first section is resolved or after a slot change removes it.
	 * It follows the `section-change` events for live crossings, and also resets to an empty string without an event when a slot change removes the active section, so a consumer that must stay exact reads this state rather than only listening.
	 */
	activeSectionId: string;

	/**
	 * Whether the reader has passed the article's midpoint, which is the same condition that fires the one-shot `halfway-read` event.
	 * It is a guard the component already tracks, surfaced here so a consumer can read it as state.
	 */
	readPastHalfway: boolean;
}

/**
 * The detail payload carried by the `section-change` event of `article-scroll`.
 * It fires only when the active section actually changes, so a stable section never re-emits, and a scrollspy label or a table-of-contents highlight can bind straight to it.
 */
export interface SectionChangeDetail {

	/**
	 * The id of the section that just became active, taken from the slotted section element.
	 * It matches one of the ids the component collected in document order.
	 */
	id: string;

	/**
	 * A human label for the section, resolved from its `data-title`, its first heading, or the id as a last resort.
	 * It exists so a consumer can show the section name without re-reading the DOM.
	 */
	title: string;
}

/**
 * The detail payload carried by the `halfway-read` event of `article-scroll`.
 * It is a business event (the reader reached the midpoint), so it lives in JavaScript and never on a CSS timeline, and it fires exactly once behind a guard.
 */
export interface HalfwayReadDetail {

	/**
	 * The milestone name, a single literal so a consumer can switch on it and so this never collides with a future milestone payload.
	 */
	milestone: 'halfway';

	/**
	 * The id of the section that was active when the midpoint was crossed, for attributing the read in analytics.
	 */
	sectionId: string;
}
Full component
import { LitElement, html, css } from 'lit';
import { customElement, query, queryAssignedElements } from 'lit/decorators.js';
import type { ArticleScrollState, SectionChangeDetail, HalfwayReadDetail } from './types';

/**
 * The JavaScript half of a scroll-driven article page, the part that stays in JavaScript after the paint moves to CSS.
 *
 * The progress bar and the card reveals belong to CSS scroll-driven timelines, so they are not here.
 * What remains is the work a timeline cannot express, and every job runs on `IntersectionObserver`, never a `scroll` listener, so the page never polls layout on each frame.
 * It tracks the active section (state the app reads), fires a one-shot halfway analytics event (a business event), and surfaces a load-more request at the foot (navigation and data, kept illustrative).
 *
 * Slot the article's `<section id="...">` boxes as direct light-DOM children (a wrapping element around them is not observed); the component collects their ids in document order so a tie on the reading line resolves deterministically.
 *
 * @element article-scroll
 * @slot - The article content, a sequence of `<section id="...">` boxes whose ids drive the active-section state.
 * @fires section-change - Fired when the active section changes, carrying a `SectionChangeDetail`; a stable section emits nothing.
 * @fires halfway-read - Fired exactly once when the article's midpoint crosses the middle of the viewport, carrying a `HalfwayReadDetail`.
 * @fires load-more - Fired once when the foot sentinel scrolls into view, guarded until {@link ArticleScroll.rearmLoadMore} re-arms it; the component runs no fetch or router of its own.
 */
@customElement( 'article-scroll' )
export class ArticleScroll extends LitElement {

	/**
	 * The id of the section currently on the reading line, surfaced through the {@link ArticleScroll.state} getter and the `section-change` event.
	 *
	 * It starts empty, is updated only through {@link ArticleScroll.setActiveSection}, and resets to empty when a slot change removes the active section.
	 * It drives no template output, so it is a plain field rather than reactive `@state`.
	 */
	private activeSectionId = '';

	/**
	 * The slotted `<section id="...">` boxes, resolved in flattened document order by Lit.
	 *
	 * Observing the whole box, rather than a single line element, keeps the reading band from falling into a gap inside a long section.
	 */
	@queryAssignedElements( { selector: 'section[id]' } )
	private $sections!: HTMLElement[];

	/**
	 * The midpoint sentinel in the shadow root, the single element the halfway analytics observer watches.
	 *
	 * It is an absolutely positioned strip from half the host's height to its end, watched through a band at the middle of the viewport: the two first touch right as half the scrollable range falls behind the reader, and the strip keeps intersecting from there on, so a fast flick can never skip the crossing between observer samples.
	 */
	@query( '#halfway-sentinel' )
	private $halfwaySentinel!: HTMLElement;

	/**
	 * The foot sentinel in the shadow root, the single element the load-more observer watches.
	 */
	@query( '#foot-sentinel' )
	private $footSentinel!: HTMLElement;

	/**
	 * Section ids in document order, so ties on the line resolve deterministically.
	 *
	 * The binding is `readonly` but the array is refilled in place by {@link ArticleScroll.collectSections} on each connect.
	 */
	private readonly sectionIds: string[] = [];

	/**
	 * Section titles keyed by id, resolved once when the sections are collected so the `section-change` event carries a label without re-reading the DOM on every change.
	 */
	private readonly sectionTitles = new Map<string, string>();

	/**
	 * Sections currently touching the reading line near the top third of the viewport.
	 *
	 * A `Set` because membership, not order, is what each callback toggles; document order comes from {@link ArticleScroll.sectionIds} when the active one is picked.
	 */
	private readonly onLine = new Set<string>();

	/**
	 * Whether a load-more request is already outstanding, the guard that keeps the foot sentinel from double-firing.
	 *
	 * Scrolling away and back, or a resize that re-intersects the sentinel, must not request the same page twice while the first is still in flight.
	 */
	private hasRequestedMore = false;

	/**
	 * Whether the halfway analytics event has already fired, the guard that keeps it a one-shot.
	 *
	 * It doubles as the `readPastHalfway` value exposed through {@link ArticleScroll.state}.
	 */
	private hasReportedHalfway = false;

	/**
	 * The reading-line and halfway observers, both rebuilt whenever the viewport height changes.
	 *
	 * Their `rootMargin` is in pixels derived from the height, so neither can be created before the height is known; {@link ArticleScroll.buildLineObservers} does that.
	 */
	private sectionObserver?: IntersectionObserver;
	private halfwayObserver?: IntersectionObserver;

	/**
	 * The viewport height the line observers were last built for, so a resize only rebuilds them when the height actually changes.
	 */
	private lineObserverHeight = 0;

	/**
	 * Keeps {@link ArticleScroll.onLine} in sync and picks the active section in document order.
	 *
	 * The active one is the first id in document order that is currently on the line, so the result never depends on the order the browser delivers entries.
	 * Choosing by the largest `intersectionRatio` is the tempting alternative, but the ratio measures how much of a section's own box is visible, so a short section fully in view outranks the tall one the reader is actually inside.
	 */
	private readonly onSectionsCross = ( entries: IntersectionObserverEntry[] ): void => {
		for ( const entry of entries ) {
			if ( entry.isIntersecting ) {
				this.onLine.add( entry.target.id );
			} else {
				this.onLine.delete( entry.target.id );
			}
		}
		const active = this.sectionIds.find( ( id ) => this.onLine.has( id ) );
		if ( active ) {
			this.setActiveSection( active );
		}
	};

	/**
	 * Fires the halfway analytics event the first time the midpoint sentinel crosses the center band.
	 *
	 * The sentinel sits at half the content's height and the band sits at half the viewport, so the crossing lands right around the moment half the scrollable range falls behind the reader, closely matching a progress bar's fifty percent.
	 * The observer is asynchronous and reports approximate boundary crossings, which suits an analytics milestone and rules out a pixel-exact trigger.
	 * The `hasReportedHalfway` guard makes it a one-shot, so re-entering the midpoint on a scroll back up never re-reports.
	 */
	private readonly onHalfwayCross = ( entries: IntersectionObserverEntry[] ): void => {
		for ( const entry of entries ) {
			if ( entry.isIntersecting && ! this.hasReportedHalfway ) {
				this.hasReportedHalfway = true;
				this.dispatchEvent(
					new CustomEvent<HalfwayReadDetail>( 'halfway-read', {
						detail: { milestone: 'halfway', sectionId: this.activeSectionId },
						bubbles: true,
						composed: true,
					} ),
				);
			}
		}
	};

	/**
	 * Watches a foot sentinel and requests the next page when it scrolls into view.
	 *
	 * It dispatches a `load-more` event rather than fetching, so the observer stays the signal and the consumer stays the owner deciding what to load, without building a router or an infinite-scroll subsystem.
	 * The request is one-shot: the guard flips, the sentinel is unobserved, and {@link ArticleScroll.rearmLoadMore} re-arms both once the consumer has appended the page.
	 */
	private readonly loadMoreObserver = new IntersectionObserver( ( entries ) => {
		for ( const entry of entries ) {
			if ( entry.isIntersecting && ! this.hasRequestedMore ) {
				this.hasRequestedMore = true;
				this.loadMoreObserver.unobserve( entry.target );
				this.dispatchEvent(
					new CustomEvent( 'load-more', { bubbles: true, composed: true } ),
				);
			}
		}
	} );

	/**
	 * Layout for the slotted article and the two sentinels, scoped to the shadow root.
	 *
	 * The sentinels carry no paint of their own; they exist only as observation targets, so they are zero-height and aria-hidden in the template.
	 */
	static styles = css`
		:host {
			display: block;
			position: relative;
		}

		.sentinel {
			display: block;
			height: 1px;
			margin: 0;
			padding: 0;
			pointer-events: none;
		}

		/* A strip from half the host's height to its end: it first meets the viewport's middle band right around half the scrollable range, and it keeps intersecting from there on, so a fast flick can never jump past it between observer samples. */
		.sentinel--halfway {
			position: absolute;
			top: 50%;
			bottom: 0;
			left: 0;
			width: 1px;
			height: auto;
		}

		#foot-sentinel {
			margin-block-start: var(--space-16, 64px);
		}
	`;

	/**
	 * A read-only snapshot of the state this component owns, for a consumer that prefers to read state over listening for events.
	 *
	 * `readPastHalfway` is the same guard that gates the one-shot `halfway-read` event, surfaced here as a value.
	 */
	get state(): ArticleScrollState {
		return {
			activeSectionId: this.activeSectionId,
			readPastHalfway: this.hasReportedHalfway,
		};
	}

	/**
	 * Builds the reading-line and halfway observers from the current viewport height, recreating them when that height changes.
	 *
	 * `rootMargin` percentages are specified relative to the root's width, even for the top and bottom edges, which makes them the wrong unit for a line meant to sit at a fraction of the root's height, and historical engine behavior has not always matched the spec. So the margins are computed in pixels from the measured height: a line near the top third for the active section, and a thin band at the center for the halfway milestone.
	 * Observer options cannot be mutated after construction, so a height change disconnects and recreates both; the early return skips a width-only resize, and none of this runs on the scroll path.
	 */
	private buildLineObservers(): void {
		const height = document.documentElement.clientHeight;
		if ( height === this.lineObserverHeight ) {
			return;
		}
		this.lineObserverHeight = height;

		const readingTop = Math.round( height * 0.3 );
		const readingBottom = Math.round( height * 0.65 );
		const halfwayInset = Math.round( height * 0.49 );

		this.sectionObserver?.disconnect();
		this.sectionObserver = new IntersectionObserver( this.onSectionsCross, {
			rootMargin: `-${ readingTop }px 0px -${ readingBottom }px 0px`,
		} );
		for ( const $section of this.$sections ) {
			this.sectionObserver.observe( $section );
		}

		this.halfwayObserver?.disconnect();
		this.halfwayObserver = new IntersectionObserver( this.onHalfwayCross, {
			rootMargin: `-${ halfwayInset }px 0px -${ halfwayInset }px 0px`,
		} );
		this.halfwayObserver.observe( this.$halfwaySentinel );
	}

	/**
	 * The pending resize-settle timer, so a continuous drag-resize rebuilds the observers once after it stops rather than on every frame.
	 */
	private resizeTimer = 0;

	/**
	 * Rebuilds the reading-line observers a short beat after a resize settles, since their pixel margins depend on the height.
	 *
	 * A drag-resize fires an event per frame; debouncing rebuilds the two observers once the height stops changing rather than on every frame, at the cost of the reading line being briefly coarse mid-drag. Either way this is off the scroll path.
	 */
	private readonly onResize = (): void => {
		window.clearTimeout( this.resizeTimer );
		this.resizeTimer = window.setTimeout( () => this.buildLineObservers(), 150 );
	};

	/**
	 * Wires every observer once the slotted sections and the sentinels exist.
	 *
	 * Awaits the first render so the `<slot>` is populated and both sentinels are in the shadow root, then collects the section ids, builds the reading-line observers from the current height, points the load-more observer at the foot sentinel, and listens for the resize that would change the line geometry.
	 * Keeping all observation here, rather than in a `scroll` handler, is the whole point: the browser schedules visibility work asynchronously, so the page never reads layout on every frame.
	 * The `isConnected` check guards the await: if the element left the document while the first render was pending, `disconnectedCallback` has already run, and starting to observe now would leak observers on a detached tree.
	 */
	async connectedCallback(): Promise<void> {
		super.connectedCallback();
		await this.updateComplete;

		if ( ! this.isConnected ) {
			return;
		}

		this.collectSections();
		this.buildLineObservers();
		this.loadMoreObserver.observe( this.$footSentinel );
		window.addEventListener( 'resize', this.onResize );
	}

	/**
	 * Tears down every observer and clears the on-line set when the element leaves the document.
	 *
	 * Deleting the scroll listener removed the classic un-removed `scroll` handler leak; the observers kept in its place carry their own teardown obligation, so each one is disconnected here, leaving nothing observing a detached tree.
	 */
	disconnectedCallback(): void {
		super.disconnectedCallback();
		window.removeEventListener( 'resize', this.onResize );
		window.clearTimeout( this.resizeTimer );
		this.sectionObserver?.disconnect();
		this.halfwayObserver?.disconnect();
		this.loadMoreObserver.disconnect();
		this.onLine.clear();
		this.lineObserverHeight = 0;
	}

	/**
	 * Re-arms the load-more request after the consumer has appended the next page.
	 *
	 * Loading is the consumer's job, so the component cannot know when it finished; calling this clears the in-flight guard and observes the foot sentinel again, making the next request possible.
	 * The appended sections themselves need no call at all, because the slot's `slotchange` handler already folds them into the section observer.
	 * It no-ops if the element has since disconnected, so a late call never observes a detached tree.
	 */
	rearmLoadMore(): void {
		if ( ! this.isConnected ) {
			return;
		}

		this.hasRequestedMore = false;
		this.loadMoreObserver.observe( this.$footSentinel );
	}

	/**
	 * Re-observes the slotted sections from scratch, so the active-section state always reflects what is actually in the slot.
	 *
	 * Disconnecting first drops targets from a previous pass, clearing the on-line set forgets sections that no longer exist, and re-collecting rebuilds the document-order list before every current section is observed again.
	 * If the change removed the active section, the stored id resets too, so the component never reports a section that is no longer in the slot.
	 */
	private refreshSections(): void {
		this.onLine.clear();
		this.collectSections();

		if ( this.activeSectionId && ! this.sectionIds.includes( this.activeSectionId ) ) {
			this.activeSectionId = '';
		}

		this.sectionObserver?.disconnect();
		for ( const $section of this.$sections ) {
			this.sectionObserver?.observe( $section );
		}
	}

	/**
	 * Re-runs the section wiring whenever the slot's contents change.
	 *
	 * A consumer that appends the next page after a `load-more` request does not have to tell the component; the `slotchange` event does, and the new sections join the scrollspy in document order.
	 */
	private onSlotChange = (): void => {
		if ( ! this.isConnected ) {
			return;
		}

		this.refreshSections();
	};

	/**
	 * Sets the active section and dispatches `section-change` only when the id actually changes.
	 *
	 * The id guard is what keeps a stable active section from emitting duplicate events as the reader scrolls within it.
	 *
	 * @param id - The id of the section now on the reading line, always one of the collected `sectionIds`.
	 */
	private setActiveSection( id: string ): void {
		if ( id === this.activeSectionId ) {
			return;
		}

		this.activeSectionId = id;
		this.dispatchEvent(
			new CustomEvent<SectionChangeDetail>( 'section-change', {
				detail: { id, title: this.sectionTitles.get( id ) ?? id },
				bubbles: true,
				composed: true,
			} ),
		);
	}

	/**
	 * Reads the slotted sections into {@link ArticleScroll.sectionIds} and {@link ArticleScroll.sectionTitles} in document order.
	 *
	 * Refills both in place so a re-connected element rebuilds cleanly, and resolves each title from `data-title`, then the first heading, then the id, so the `section-change` event always carries a usable label.
	 */
	private collectSections(): void {
		this.sectionIds.length = 0;
		this.sectionTitles.clear();

		for ( const $section of this.$sections ) {
			this.sectionIds.push( $section.id );
			this.sectionTitles.set( $section.id, this.resolveTitle( $section ) );
		}
	}

	/**
	 * Resolves a human label for a section without a cast.
	 *
	 * Prefers an explicit `data-title`, falls back to the trimmed text of the first heading, and finally to the id, so a missing label never produces an empty event payload.
	 *
	 * @param $section - The slotted section element to label.
	 */
	private resolveTitle( $section: HTMLElement ): string {
		const explicit = $section.dataset.title?.trim();
		if ( explicit ) {
			return explicit;
		}

		const heading = $section.querySelector( 'h1, h2, h3, h4, h5, h6' );
		const fromHeading = heading?.textContent?.trim();
		if ( fromHeading ) {
			return fromHeading;
		}

		return $section.id;
	}

	/**
	 * Renders the slotted article and the two observation sentinels, re-wiring the section observer on `slotchange` so appended pages keep scrollspy coverage.
	 *
	 * The midpoint sentinel is pinned at half the host's height and the foot sentinel sits after the content; both are aria-hidden because they exist only as `IntersectionObserver` targets and carry no reader-facing content.
	 */
	render() {
		return html`
			<slot @slotchange=${this.onSlotChange}></slot>
			<div id="halfway-sentinel" class="sentinel sentinel--halfway" aria-hidden="true"></div>
			<div id="foot-sentinel" class="sentinel" aria-hidden="true"></div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'article-scroll': ArticleScroll;
	}
}

Run the review questions over the finished page. Where’s the timeline, and where’s the state? Is the base style content-complete, so Firefox stable and an inactive timeline both land somewhere readable? Is animation-timeline after the animation shorthand? Does the feature query include animation-range? What happens in a nested scroller, and is the active-section rule deterministic? Are the animated properties still transform and opacity? Those questions are the durable part; the API is just what made them concrete this time.

This last demo is the assembled page, with the controls on top: a switch over who drives the progress bar, plus reduced motion and an unsupported-engine fallback. The readouts below it are grouped by owner and live, and the Progress bar row sits in whichever column currently drives it, so flipping the switch literally moves the row from the CSS group to the JavaScript group, where its handler calls climb while the active section, the halfway event, the display-only hash, and the guarded next-page request keep working untouched. That one moving row is the routing in a single panel. It ties the routing together, and because it scrolls inside its own box, its code panel shows the harness that actually runs there, with every observer rooted on that scroller the way the article’s component roots on the viewport.

final-review harness
import { LitElement, html, css, type PropertyValues } from 'lit';
import { customElement, state, query, queryAll } from 'lit/decorators.js';

/**
 * One section of the sample article, the unit the active-section observer tracks and the unit CSS reveals.
 *
 * The body is a sentence or two of filler so each section is tall enough to cross the reading line on its own as the inner reader scrolls.
 */
interface DemoSection {

	/**
	 * The section id, the value the active-section readout reports and the illustrative URL-hash readout mirrors.
	 */
	id: string;

	/**
	 * The human label shown in the active-section readout, also written to the section's `data-title`.
	 */
	title: string;

	/**
	 * A short body paragraph, present in the static base so the CSS reveal only decorates how a card enters.
	 */
	body: string;
}

/**
 * Which layer paints the progress bar, the contrast the assembled review makes visible and countable; the reveals stay on their CSS timeline in both modes.
 *
 * `css` lets the CSS `scroll()` timeline own the bar's paint, the result the article argues for.
 * `js` simulates the deleted scroll engine, repainting the bar from a `scroll` listener so the contrast is visible and countable.
 */
type Implementation = 'css' | 'js';

/**
 * The assembled review page for the scroll-driven article: one inner scroller that runs every behavior at once, with live readouts for the JavaScript half.
 *
 * CSS owns the sticky reading-progress bar (a `scroll()` timeline) and the card reveals (a `view()` timeline); both run with no handler of ours.
 * JavaScript owns the active-section label and the one-shot halfway analytics counter, and the URL hash and load-more note shown as the same signal-then-act pattern, the observer supplying the signal and the app owning the act.
 * Every observer here is rooted on the inner reader, not the viewport, because in the article's auto-height iframe the page never scrolls and only the reader does; a viewport-rooted observer would read the wrong scrollport.
 * The reduced-motion and unsupported-engine states are simulated by toggling a `data-` attribute: reduced motion removes only the decorative reveals and keeps the informational progress bar, while unsupported removes both CSS timelines, matching a failed feature query. The controls label them as simulations because a sandboxed iframe can force neither.
 *
 * The code panel beside this demo shows this harness itself, so what you read is what runs, with every observer rooted on the inner reader; the copyable, viewport-rooted version of the same JavaScript half is `article-scroll`, shown assembled in the article.
 *
 * The custom-element tag is the long, collision-proof name the registry derives as `demo-<article>-<slug>`; it is never shown to a reader.
 *
 * @element demo-animation-timeline-scroll-scroll-final-review
 */
@customElement( 'demo-animation-timeline-scroll-scroll-final-review' )
export class ScrollFinalReviewDemo extends LitElement {

	/**
	 * Which implementation paints the progress bar, driving the `data-impl` attribute on the reader.
	 *
	 * It starts on `css`, the state the article argues for, so the demo opens on the deleted-JavaScript result.
	 */
	@state()
	private implementation: Implementation = 'css';

	/**
	 * Whether the demo simulates a reduced-motion preference, driving the `data-reduced-motion` attribute.
	 *
	 * The toggle is labeled a simulation because a sandboxed iframe cannot set `prefers-reduced-motion`; flipping it strips the reveal so the cards rest on their content-complete base.
	 */
	@state()
	private reducedMotion = false;

	/**
	 * Whether the demo simulates an engine without scroll-driven animation support, driving the `data-unsupported` attribute.
	 *
	 * It stands in for a browser where the `@supports` gate fails: the bar sits at its static `scaleX( 0 )` base and the cards stay fully visible, never falsely full or hidden.
	 */
	@state()
	private unsupported = false;

	/**
	 * The label of the section currently on the reading line, set by the scroller-rooted section observer.
	 *
	 * It starts as a dash so the readout reads honestly before the first section resolves rather than guessing a section.
	 */
	@state()
	private activeTitle = '-';

	/**
	 * The id of the active section, the value the illustrative URL-hash readout mirrors.
	 */
	@state()
	private activeId = '';

	/**
	 * How many times the halfway analytics milestone has fired, the live proof the one-shot guard holds.
	 *
	 * It reaches one when the reader first crosses the midpoint and stays there however often the reader scrolls back over it.
	 */
	@state()
	private halfwayCount = 0;

	/**
	 * How many times the foot sentinel has requested the next page, counted from the illustrative load-more pattern.
	 *
	 * The demo only counts the request; it runs no fetch, which is the boundary the article draws around load-more.
	 */
	@state()
	private loadMoreCount = 0;

	/**
	 * How many `scroll` events the simulated JavaScript engine has handled, the cost the CSS version drops to zero.
	 *
	 * It only climbs while `implementation` is `js`, since the CSS timeline runs no handler of ours at all.
	 */
	@state()
	private scrollHandlerCalls = 0;

	/**
	 * The main reader, the real scroll container whose overflow keeps the CSS `scroll()` and `view()` timelines active.
	 *
	 * It is also the `root` every IntersectionObserver here watches against, and the element the simulated JavaScript engine reads its scroll position from.
	 */
	@query( '.reader' )
	private $reader!: HTMLElement;

	/**
	 * The progress-bar fill inside the reader, the element the simulated JavaScript engine writes `scaleX` onto.
	 *
	 * In CSS mode it carries no inline transform and the `scroll()` timeline drives it; in JavaScript mode the scroll handler sets its transform directly.
	 */
	@query( '.reader > .bar-track .bar-fill' )
	private $barFill!: HTMLElement;

	/**
	 * The section boxes inside the reader, resolved by Lit so the section observer can watch each one.
	 *
	 * They are real children of the reader rather than slotted into a sub-component, so the observer can root on the reader without crossing a shadow boundary.
	 */
	@queryAll( 'section.card' )
	private $sections!: NodeListOf<HTMLElement>;

	/**
	 * The midpoint sentinel inside the reader, the single element the halfway analytics observer watches.
	 *
	 * It is an absolutely positioned strip from half the content's height to its end, observed through a band at the middle of the reader, so the halfway event lands closely on the bar's fifty percent and no scroll speed can skip it.
	 */
	@query( '#halfway-sentinel' )
	private $halfwaySentinel!: HTMLElement;

	/**
	 * The foot sentinel inside the reader, the single element the load-more observer watches.
	 */
	@query( '#foot-sentinel' )
	private $footSentinel!: HTMLElement;

	/**
	 * Section ids in document order, so a tie on the reading line resolves to the earliest section deterministically.
	 *
	 * Filled once the sections exist; the active section is the first id in this order that is currently on the line.
	 */
	private readonly sectionIds: string[] = [];

	/**
	 * Section titles keyed by id, resolved once so the readout shows a label without re-reading the DOM on every change.
	 */
	private readonly sectionTitles = new Map<string, string>();

	/**
	 * Ids of the sections currently touching the reading line, a set because membership rather than order is what each callback toggles.
	 *
	 * Document order, used to pick the active one, comes from {@link ScrollFinalReviewDemo.sectionIds}, never from the order the browser delivers entries.
	 */
	private readonly onLine = new Set<string>();

	/**
	 * Whether the halfway milestone has already fired, the guard that keeps the analytics event a one-shot.
	 */
	private hasReportedHalfway = false;

	/**
	 * Whether the illustrative load-more request has fired, the in-flight guard a production consumer would clear after appending the page.
	 */
	private hasRequestedMore = false;

	/**
	 * The scroll handler for the simulated JavaScript engine, retained as a field so it can be added and removed by reference.
	 *
	 * Keeping a stable reference is the same discipline the article keeps for the listener it ultimately deletes: a handler you add you must be able to remove.
	 */
	private readonly onReaderScroll = (): void => {
		this.scrollHandlerCalls += 1;
		this.paintProgressFromScroll();
	};

	/**
	 * Paints the progress bar from the reader's current scroll position without counting a call.
	 *
	 * The initial paint when JavaScript mode is selected goes through here rather than the handler, so the call count reflects real scroll events and never starts at one before the reader has moved.
	 */
	private paintProgressFromScroll(): void {
		const max = this.$reader.scrollHeight - this.$reader.clientHeight;
		const progress = max > 0 ? this.$reader.scrollTop / max : 0;
		this.$barFill.style.transform = `scaleX( ${progress} )`;
	}

	/**
	 * The active-section observer, rooted on the reader so the reading line is measured against the scroller, not the viewport.
	 *
	 * Setting `root` to the reader is the load-bearing choice: the page never scrolls inside the iframe, so a default viewport root would measure the wrong scrollport and the label would never track.
	 * The active section is the first id in document order currently on the line, so the result never depends on the order the browser delivers entries, and a short section fully in view cannot outrank the tall one the reader is inside.
	 */
	private sectionObserver: IntersectionObserver | null = null;

	/**
	 * The halfway analytics observer, rooted on the reader, firing the milestone the first time the midpoint sentinel crosses into view.
	 *
	 * The `hasReportedHalfway` guard makes it a one-shot, so scrolling back up across the midpoint never re-reports.
	 */
	private halfwayObserver: IntersectionObserver | null = null;

	/**
	 * The load-more observer, rooted on the reader, counting an illustrative next-page request when the foot sentinel scrolls into view.
	 *
	 * It only tallies the request to show the shape, the observer signaling and the app owning the request; the demo deliberately runs no fetch and loads no next page.
	 */
	private loadMoreObserver: IntersectionObserver | null = null;

	/**
	 * The reader height the line observers were last built for, so a resize only rebuilds them when the height actually changes.
	 */
	private lineObserverHeight = 0;

	/**
	 * Watches the reader for height changes, since the reading-line and halfway margins are pixels derived from that height.
	 */
	private readerResize: ResizeObserver | null = null;

	/**
	 * The sample article sections, fixed content so the active-section and reveal behavior is deterministic across loads.
	 *
	 * Each id doubles as the illustrative URL hash, and each title is the label the active-section readout shows.
	 */
	private readonly sections: DemoSection[] = [
		{
			id: 'intro',
			title: 'The request',
			body: 'Marketing asked for a progress bar, card reveals, a shrinking header, a section label, a halfway event, a live URL hash, and an endless feed. That is seven jobs wearing one scroll.',
		},
		{
			id: 'contracts',
			title: 'Name the jobs',
			body: 'A visual timeline is the one kind of scroll work CSS can take off your hands. State, events, navigation, and data stay in JavaScript, and naming each job is the whole decision.',
		},
		{
			id: 'progress',
			title: 'Delete the paint',
			body: 'The progress bar and the reveals move to CSS timelines, gated behind a feature query over a content-complete base, so an unsupported engine still reads cleanly.',
		},
		{
			id: 'state',
			title: 'Keep the state',
			body: 'The active section is a value the app reads, so an IntersectionObserver resolves it in document order. This readout is that state, live.',
		},
		{
			id: 'analytics',
			title: 'Fire the event',
			body: 'Reading past the midpoint is a business event, so it fires once behind a guard off its own sentinel, never a scroll listener. Cross back over it and it stays quiet.',
		},
		{
			id: 'review',
			title: 'What stays in JS',
			body: 'The hash and the next-page fetch stay JavaScript-owned, each riding an observer signal. This demo names their shape and stops short of a router or an infinite feed.',
		},
		{
			id: 'header',
			title: 'The header is layout',
			body: 'Pinning the header is layout, so position: sticky owns that half without a listener. A purely visual shrink stays in CSS, smooth or stepped, and it moves to JavaScript only when the app reads the state.',
		},
		{
			id: 'ship',
			title: 'Ship the split',
			body: 'Scroll to the very bottom and the bar should sit completely full. Every behavior above is tagged with its owner, which is the routing this whole build teaches.',
		},
	];

	/**
	 * Styles for the harness shell, the scroll container, the CSS timelines, and the controls, scoped to the shadow root.
	 *
	 * The reading progress bar and the card reveals are gated behind `@supports` over static bases: JavaScript mode replaces only the bar's timeline with a scroll handler, reduced motion removes only the decorative reveals, and the unsupported state removes both CSS timelines.
	 */
	static styles = css`
		*,
		*::before,
		*::after {
			box-sizing: border-box;
		}

		:host {
			display: block;
			height: 100%;
			font-family: var( --font-sans, system-ui, sans-serif );
			color: var( --ink, #17171a );
		}

		.wrap {
			display: grid;
			grid-template-columns: 1fr 19rem;
			min-height: 620px;
			height: 100%;
		}

		/* The stage cell takes the row height the controls column sets, and the reader fills it absolutely, so the scrollable area is always exactly as tall as the sidebar. */
		.stage {
			position: relative;
			min-height: 620px;
		}

		.reader {
			position: absolute;
			inset: 0;
			overflow: auto;
			border-right: 1px solid var( --line, #e7e6e2 );
			background: var( --bg, #fcfcfb );
		}

		.bar-track {
			position: sticky;
			top: 0;
			z-index: 2;
			height: 6px;
			background: var( --line-soft, #f0efec );
		}

		.bar-fill {
			height: 100%;
			background: var( --accent, #2257e6 );
			/* Static base: an empty bar wherever the timeline never runs, never a falsely full one. */
			transform: scaleX( 0 );
			/* Grows from the left edge. */
			transform-origin: left;
		}

		/*
		 * The progress bar as a whole behavior in CSS, gated as a conservative whole-feature query.
		 * The bar sets no animation-range itself, so the range here is a completeness proxy: an engine that parses the timeline but cannot run the full model degrades as one piece.
		 * The data- guards drop the rule for the JavaScript and unsupported simulations; reduced motion deliberately keeps the bar, since it informs reading position rather than decorating it.
		 */
		@supports ( ( animation-timeline: scroll() ) and ( animation-range: 0% 100% ) ) {
			.reader:not( [data-impl='js'] ):not( [data-unsupported] ) > .bar-track .bar-fill {
				animation: grow-progress 1ms linear both;
				/*
				 * Must come after the animation shorthand, which resets it to auto.
				 * scroll() is scroll( nearest ): it resolves to the nearest ancestor scroll container, the .reader, whose overflow actually scrolls.
				 * scroll( self ) would target the .bar-fill's own scroll, and the fill is not a scroll container, so the timeline would be inactive and the bar would stay at its scaleX( 0 ) base and never fill.
				 */
				animation-timeline: scroll();
			}
		}

		@keyframes grow-progress {
			from {
				transform: scaleX( 0 );
			}

			to {
				transform: scaleX( 1 );
			}
		}

		.content {
			position: relative;
			padding: var( --space-5, 20px );
			display: grid;
			gap: var( --space-5, 20px );
		}

		.lede {
			margin: 0;
			font-size: var( --fs-13, 13px );
			line-height: 1.6;
			color: var( --ink-soft, #56565c );
		}

		.card {
			border: 1px solid var( --line, #e7e6e2 );
			border-radius: var( --radius-md, 12px );
			background: var( --surface, #fff );
			padding: var( --space-5, 20px );
			min-height: 168px;
			display: grid;
			gap: 10px;
			align-content: start;
			/* Static, content-complete base for the unsupported and reduced-motion fallbacks. */
			opacity: 1;
		}

		.card h3 {
			margin: 0;
			font-size: var( --fs-17, 17px );
			color: var( --ink, #17171a );
		}

		.card p {
			margin: 0;
			font-size: var( --fs-13, 13px );
			line-height: 1.6;
			color: var( --ink-soft, #56565c );
		}

		/*
		 * The card reveal on a view timeline, gated on the exact range it uses and held behind the reduced-motion guard.
		 * The reduced-motion and unsupported guards strip it to the content-complete opacity: 1 base; the JavaScript bar mode leaves it alone, because the reveals stay CSS-owned in both modes.
		 * The keyframe is deliberately bold (a long lift plus a scale) so the reveal is unmistakable in a small frame; the article's minimal 2rem lift teaches the same rule.
		 */
		@supports ( ( animation-timeline: view() ) and ( animation-range: entry 0% cover 40% ) ) {
			@media ( prefers-reduced-motion: no-preference ) {
				.reader:not( [data-unsupported] ):not( [data-reduced-motion] ) .card {
					animation: reveal 1ms linear both;
					animation-timeline: view();
					/* Start as the card enters, finish 40% up the cover range. */
					animation-range: entry 0% cover 40%;
				}
			}
		}

		@keyframes reveal {
			from {
				opacity: 0;
				transform: translateY( 88px ) scale( 0.85 );
			}

			to {
				opacity: 1;
				transform: translateY( 0 ) scale( 1 );
			}
		}

		/*
		 * A footer-sized run of space below the last card so its reveal endpoint (cover 40%) is reachable before the scroller bottoms out.
		 * Without it the final card parks mid-reveal at max scroll, half transparent and shifted, because the range cannot complete.
		 */
		.tail {
			min-height: 240px;
			margin: 0;
			display: grid;
			place-content: end center;
			color: var( --ink-soft, #56565c );
			font-size: 0.8rem;
		}

		.sentinel {
			height: 1px;
			margin: 0;
			padding: 0;
			pointer-events: none;
		}

		/* A strip from half the content's height to its end, observed through a band at the reader's middle: the halfway event fires closely on the bar's fifty percent, and a fast flick can never jump past it between observer samples. */
		.sentinel--halfway {
			position: absolute;
			top: 50%;
			bottom: 0;
			left: 0;
			width: 1px;
			height: auto;
		}

		.controls {
			padding: var( --space-5, 18px );
			display: grid;
			gap: 14px;
			align-content: start;
		}

		.section-title {
			font-family: var( --font-mono, monospace );
			font-size: 10px;
			letter-spacing: 0.08em;
			text-transform: uppercase;
			color: var( --ink-faint, #71717b );
			margin: 0 0 8px;
		}

		.readouts {
			display: grid;
			gap: 8px;
			margin: 0;
			padding: 0;
			list-style: none;
		}

		.readout {
			display: grid;
			gap: 3px;
			border: 1px solid var( --line, #e7e6e2 );
			border-radius: var( --radius, 9px );
			padding: 8px 10px;
		}

		.readout .k {
			font-size: 11px;
			color: var( --ink-faint, #71717b );
		}

		.stack {
			display: grid;
			gap: 8px;
			align-content: start;
		}

		.control-label {
			margin: 0 0 4px;
			font-size: 11px;
			color: var( --ink-faint, #71717b );
		}

		.control-note {
			margin: 6px 0 0;
			font-size: 10px;
			line-height: 1.5;
			color: var( --ink-faint, #71717b );
		}

		.readout .v {
			font-family: var( --font-mono, monospace );
			font-size: 13px;
			color: var( --ink, #17171a );
			word-break: break-all;
		}

		.note {
			font-size: 10px;
			color: var( --ink-faint, #71717b );
		}

		.seg {
			display: flex;
			width: 100%;
			border: 1px solid var( --line, #e7e6e2 );
			border-radius: 7px;
			overflow: hidden;
		}

		.seg-option {
			flex: 1;
			padding: 6px 8px;
			border: none;
			background: var( --bg, #fcfcfb );
			color: var( --ink-soft, #56565c );
			font: inherit;
			font-size: 12px;
			cursor: pointer;
		}

		.seg-option + .seg-option {
			border-left: 1px solid var( --line, #e7e6e2 );
		}

		.seg-option.on {
			background: var( --accent, #2257e6 );
			color: var( --accent-ink, #fff );
		}

		.toggle {
			display: flex;
			align-items: center;
			justify-content: space-between;
			gap: 8px;
			width: 100%;
			background: var( --bg, #fcfcfb );
			color: var( --ink, #17171a );
			border: 1px solid var( --line, #e7e6e2 );
			border-radius: 7px;
			padding: 5px 10px;
			font: inherit;
			font-size: 12px;
			cursor: pointer;
		}

		.track {
			position: relative;
			width: 30px;
			height: 16px;
			border-radius: 999px;
			background: var( --line, #e7e6e2 );
			box-shadow: inset 0 0 0 1px color-mix( in srgb, var( --ink, #17171a ) 12%, transparent );
			transition: background 0.2s ease;
			flex: none;
		}

		.track.on {
			background: var( --accent, #2257e6 );
			box-shadow: none;
		}

		.knob {
			position: absolute;
			top: 2px;
			left: 2px;
			width: 12px;
			height: 12px;
			border-radius: 50%;
			background: var( --surface, #fff );
			box-shadow: 0 1px 2px rgba( 0, 0, 0, 0.35 );
			transition: transform 0.2s ease;
		}

		.track.on .knob {
			transform: translateX( 14px );
		}

		@media ( width <= 640px ) {
			.wrap {
				grid-template-columns: 1fr;
			}

			.stage {
				min-height: 0;
			}

			.reader {
				position: static;
				height: 620px;
				border-right: none;
				border-bottom: 1px solid var( --line, #e7e6e2 );
			}
		}
	`;

	/**
	 * Wires the scroller-rooted observers and the simulated progress handler once the reader, the sections, and the sentinels exist.
	 *
	 * Awaits the first render so the reader is laid out and can serve as each observer's `root`, then collects the section ids in document order and observes every section box and both sentinels.
	 * It also re-syncs the simulated progress handler, so a reconnect while JavaScript mode is selected restores the listener that teardown removed rather than leaving the bar frozen.
	 * Keeping all observation here, rather than in a `scroll` handler, is the whole point: the browser schedules visibility work asynchronously, so the page never reads layout on every frame.
	 */
	async connectedCallback(): Promise<void> {
		super.connectedCallback();
		await this.updateComplete;

		if ( ! this.isConnected ) {
			return;
		}

		this.collectSections();
		this.connectObservers();
		this.syncProgressImplementation();
	}

	/**
	 * Tears down every observer and the simulated JavaScript scroll handler when the demo leaves the document.
	 *
	 * The observers each carry their own teardown obligation, and the contrast engine has a listener to clear; clearing both here leaves nothing observing or listening on a detached tree, the leak the article warns against.
	 */
	disconnectedCallback(): void {
		super.disconnectedCallback();
		this.$reader?.removeEventListener( 'scroll', this.onReaderScroll );
		this.readerResize?.disconnect();
		this.sectionObserver?.disconnect();
		this.halfwayObserver?.disconnect();
		this.loadMoreObserver?.disconnect();
		this.readerResize = null;
		this.sectionObserver = null;
		this.halfwayObserver = null;
		this.loadMoreObserver = null;
		this.onLine.clear();
		this.lineObserverHeight = 0;
	}

	/**
	 * Re-syncs the simulated JavaScript scroll handler whenever the implementation toggle changes.
	 *
	 * @param changed - The set of reactive properties Lit has just updated.
	 */
	updated( changed: PropertyValues ): void {
		if ( ! changed.has( 'implementation' ) ) {
			return;
		}

		this.syncProgressImplementation();
	}

	/**
	 * Attaches or removes the simulated JavaScript scroll handler to match the current implementation, from a clean slate each time.
	 *
	 * It removes any existing listener first, so calling it from both `connectedCallback` and `updated` is safe and idempotent; a reconnect while JavaScript mode is selected re-attaches the handler that teardown removed, which a property-change-only path would miss.
	 * In JavaScript mode it attaches the listener and paints once from the current position; in CSS mode it clears the inline transform so the timeline owns the bar again.
	 */
	private syncProgressImplementation(): void {
		this.$reader.removeEventListener( 'scroll', this.onReaderScroll );

		if ( this.implementation === 'js' ) {
			this.$reader.addEventListener( 'scroll', this.onReaderScroll, { passive: true } );
			this.paintProgressFromScroll();
			return;
		}

		this.$barFill.style.transform = '';
		this.scrollHandlerCalls = 0;
	}

	/**
	 * Reads the rendered sections into {@link ScrollFinalReviewDemo.sectionIds} and {@link ScrollFinalReviewDemo.sectionTitles} in document order.
	 *
	 * Document order is what lets a tie on the reading line resolve to the earliest section, so the active-section readout never depends on the order the browser delivers observer entries.
	 */
	private collectSections(): void {
		this.sectionIds.length = 0;
		this.sectionTitles.clear();

		for ( const $section of this.$sections ) {
			this.sectionIds.push( $section.id );
			this.sectionTitles.set( $section.id, $section.dataset.title ?? $section.id );
		}
	}

	/**
	 * Starts the load-more observer, builds the height-derived reading-line observers, and watches the reader for the resize that would change their geometry.
	 *
	 * Every observer is rooted on the reader, not the viewport, because the page never scrolls inside the iframe, so a viewport root would measure the wrong scrollport.
	 * The load-more observer needs no margin; the reading-line and halfway observers live in {@link ScrollFinalReviewDemo.buildLineObservers} because their pixel margins depend on the reader's height.
	 */
	private connectObservers(): void {
		this.loadMoreObserver = new IntersectionObserver(
			( entries ) => {
				for ( const entry of entries ) {
					if ( entry.isIntersecting && ! this.hasRequestedMore ) {
						this.hasRequestedMore = true;
						this.loadMoreObserver?.unobserve( entry.target );
						this.loadMoreCount += 1;
					}
				}
			},
			{ root: this.$reader },
		);
		this.loadMoreObserver.observe( this.$footSentinel );

		this.buildLineObservers();

		this.readerResize = new ResizeObserver( () => this.buildLineObservers() );
		this.readerResize.observe( this.$reader );
	}

	/**
	 * Builds the reading-line and halfway observers from the reader's current height, recreating them when it changes.
	 *
	 * `rootMargin` percentages are specified relative to the root's width, even for the top and bottom edges, which makes them the wrong unit for a line meant to sit at a fraction of the reader's height, and historical engine behavior has not always matched the spec. So the margins are pixels derived from the reader's height: a line near the top third for the active section, and a thin band at the center for the halfway milestone.
	 * Observer options are immutable, so a height change disconnects and recreates both; the early return skips a width-only resize.
	 */
	private buildLineObservers(): void {
		const height = this.$reader.clientHeight;
		if ( height === 0 || height === this.lineObserverHeight ) {
			return;
		}
		this.lineObserverHeight = height;

		const readingTop = Math.round( height * 0.3 );
		const readingBottom = Math.round( height * 0.65 );
		const halfwayInset = Math.round( height * 0.49 );

		this.sectionObserver?.disconnect();
		this.sectionObserver = new IntersectionObserver(
			( entries ) => {
				for ( const entry of entries ) {
					if ( entry.isIntersecting ) {
						this.onLine.add( entry.target.id );
					} else {
						this.onLine.delete( entry.target.id );
					}
				}
				const active = this.sectionIds.find( ( id ) => this.onLine.has( id ) );
				if ( active ) {
					this.setActiveSection( active );
				}
			},
			{ root: this.$reader, rootMargin: `-${ readingTop }px 0px -${ readingBottom }px 0px` },
		);

		this.halfwayObserver?.disconnect();
		this.halfwayObserver = new IntersectionObserver(
			( entries ) => {
				for ( const entry of entries ) {
					if ( entry.isIntersecting && ! this.hasReportedHalfway ) {
						this.hasReportedHalfway = true;
						this.halfwayCount += 1;
					}
				}
			},
			{ root: this.$reader, rootMargin: `-${ halfwayInset }px 0px -${ halfwayInset }px 0px` },
		);

		for ( const $section of this.$sections ) {
			this.sectionObserver.observe( $section );
		}
		this.halfwayObserver.observe( this.$halfwaySentinel );
	}

	/**
	 * Sets the active section and updates the readout only when the id actually changes.
	 *
	 * The id guard is what keeps a stable active section from churning the readout as the reader scrolls within one section.
	 *
	 * @param id - The id of the section now on the reading line, always one of the collected `sectionIds`.
	 */
	private setActiveSection( id: string ): void {
		if ( id === this.activeId ) {
			return;
		}

		this.activeId = id;
		this.activeTitle = this.sectionTitles.get( id ) ?? id;
	}

	/**
	 * Renders the scrolling reader with the CSS-driven bar and reveals, the layer-tagged readouts, and the controls.
	 *
	 * The `data-impl`, `data-reduced-motion`, and `data-unsupported` attributes on the reader are the switches the CSS reads: `data-impl` hands only the bar to the scroll listener, reduced motion strips only the reveals, and unsupported strips every timeline.
	 */
	render() {
		return html`
			<div class="wrap">
				<div class="stage">
				<div
					class="reader"
					data-impl=${this.implementation}
					?data-reduced-motion=${this.reducedMotion}
					?data-unsupported=${this.unsupported}
				>
					<div class="bar-track" aria-hidden="true">
						<div class="bar-fill"></div>
					</div>
					<div class="content">
						<p class="lede">
							A sample article page, scrolled inside its own box. The card reveals and, by default, the progress
							bar run on CSS timelines; the section label and the analytics count run on JavaScript observers
							rooted on this scroller.
						</p>
						${this.sections.map(
							( section ) => html`
								<section id=${section.id} data-title=${section.title} class="card">
									<h3>${section.title}</h3>
									<p>${section.body}</p>
								</section>
							`,
						)}
						<p class="tail">End of the sample page.</p>

						<div id="halfway-sentinel" class="sentinel sentinel--halfway" aria-hidden="true"></div>

						<div id="foot-sentinel" class="sentinel" aria-hidden="true"></div>
					</div>
				</div>
				</div>

				<div class="controls">
					<div class="stack">
						<div>
							<p class="control-label">Progress bar driven by</p>
							<div class="seg" role="group" aria-label="Progress bar implementation">
								<button type="button"
									aria-pressed=${this.implementation === 'css'}
									class=${this.implementation === 'css' ? 'seg-option on' : 'seg-option'}
									@click=${() => this.setImplementation( 'css' )}
								>
									CSS timeline
								</button>
								<button type="button"
									aria-pressed=${this.implementation === 'js'}
									class=${this.implementation === 'js' ? 'seg-option on' : 'seg-option'}
									@click=${() => this.setImplementation( 'js' )}
								>
									JS listener
								</button>
							</div>
							<p class="control-note">Flip it and the Progress bar row below moves between the two groups. Nothing else moves.</p>
						</div>
						<button type="button"
							class="toggle"
							@click=${this.toggleReducedMotion}
							aria-pressed=${this.reducedMotion}
						>
							<span>Reduced motion</span>
							<span class=${this.reducedMotion ? 'track on' : 'track'}><span class="knob"></span></span>
						</button>
						<button type="button"
							class="toggle"
							@click=${this.toggleUnsupported}
							aria-pressed=${this.unsupported}
						>
							<span>Unsupported engine</span>
							<span class=${this.unsupported ? 'track on' : 'track'}><span class="knob"></span></span>
						</button>
					</div>

					<div>
						<p class="section-title">Paint jobs (CSS)</p>
						<ul class="readouts">
							${this.implementation === 'css'
								? html`
										<li class="readout">
											<span class="k">Progress bar</span>
											<span class="v">scroll() timeline</span>
											<span class="note">${this.unsupported ? 'Unsupported here, so the bar rests on its static empty base.' : 'Scrubbed by CSS; no JavaScript of ours runs to paint it.'}</span>
										</li>
									`
								: null}
							<li class="readout">
								<span class="k">Card reveals</span>
								<span class="v">view() timeline</span>
								<span class="note">${this.unsupported ? 'Unsupported here, so cards rest on their visible base.' : this.reducedMotion ? 'Reduced motion, so cards rest on their visible base.' : 'Each card lifts in as it enters.'}</span>
							</li>
						</ul>
					</div>

					<div>
						<p class="section-title">State and events (JavaScript)</p>
						<ul class="readouts">
							${this.implementation === 'js'
								? html`
										<li class="readout">
											<span class="k">Progress bar</span>
											<span class="v">our scroll listener</span>
											<span class="note">A JS listener paints it: ${this.scrollHandlerCalls} ${this.scrollHandlerCalls === 1 ? 'call' : 'calls'} and counting.</span>
										</li>
									`
								: null}
							<li class="readout">
								<span class="k">Active section</span>
								<span class="v">${this.activeTitle}</span>
								<span class="note">Read by an observer rooted on this scroller.</span>
							</li>
							<li class="readout">
								<span class="k">Halfway event</span>
								<span class="v">${this.halfwayCount === 0 ? 'not yet' : 'fired once'}</span>
								<span class="note">One-shot, close to the bar's fifty percent.</span>
							</li>
							<li class="readout">
								<span class="k">URL hash</span>
								<span class="v">#${this.activeId || '...'}</span>
								<span class="note">Display only; nothing is routed.</span>
							</li>
							<li class="readout">
								<span class="k">Next page</span>
								<span class="v">${this.loadMoreCount === 0 ? 'not requested yet' : 'requested once'}</span>
								<span class="note">Fires once at the very bottom; a real page would append more and re-arm.</span>
							</li>
						</ul>
					</div>

				</div>
			</div>
		`;
	}

	/**
	 * Selects which implementation paints the bar, driving the `data-impl` attribute on the reader; the reveals stay on their CSS timeline in either choice.
	 *
	 * A two-option switch rather than a single toggle, so both choices stay visible and the reader always knows what the alternative is.
	 *
	 * @param implementation - The implementation the pressed option names.
	 */
	private setImplementation = ( implementation: Implementation ): void => {
		this.implementation = implementation;
	};

	/**
	 * Flips the simulated reduced-motion preference, which drives the `data-reduced-motion` attribute that strips the reveal.
	 */
	private toggleReducedMotion = (): void => {
		this.reducedMotion = ! this.reducedMotion;
	};

	/**
	 * Flips the simulated unsupported-engine state, which drives the `data-unsupported` attribute that strips every scroll-driven rule.
	 */
	private toggleUnsupported = (): void => {
		this.unsupported = ! this.unsupported;
	};
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-animation-timeline-scroll-scroll-final-review': ScrollFinalReviewDemo;
	}
}
Reduced motion strips the decorative reveals and keeps the informational progress bar, while Unsupported engine drops both timelines to the same readable base a failing feature query would.

A few ways this goes wrong

  • Writing the animation shorthand after animation-timeline. The shorthand resets animation-timeline to auto, so a timeline set before it silently disappears and the animation runs on time instead of scroll, or not at all. Always declare animation-timeline and animation-range after the shorthand.
  • A lone @supports ( animation-timeline: scroll() ) check. Firefox’s flagged build parses animation-timeline but not animation-range, so that bare query passes there and runs a half-supported model. Test the full declaration set you rely on, and gate the bar on animation-range as a completeness proxy so the whole page degrades together.
  • overflow: hidden killing the timeline. It quietly turns an element into a scroll container and can hijack scroll(nearest), leaving the timeline inactive and the element stuck on its base style. Use overflow: clip when you only need to clip, and overflow: auto or scroll when an element really should be the scroller.
  • Tidying the static base to opacity: 0. It looks cleaner and it breaks the fallback in every case it has to cover, hiding content in Firefox, under reduced motion, and whenever the timeline is inactive. The base must be the readable, content-complete state.
  • Animating a layout property and losing the compositor. Switch a reveal from transform to height or top and the work goes back to the main thread every frame, reintroducing the jank you left JavaScript to escape. Keep scroll-driven paint on transform and opacity.

What separates a senior from a junior here

  • Routing the work before writing it. A junior answers a scroll ticket with a scroll listener. A senior reads the seven jobs hiding in it, names each one as paint, layout, state, event, navigation, or data, and assigns an owner per job. Get that sort right and the syntax that follows is mechanical, and the habit carries to the next API long after this one.
  • Designing the fallback as a first-class state the build is shaped around. The static base is the contract that content is never hidden. It stays readable in Firefox, under reduced motion, and whenever a timeline goes inactive. A senior writes that base first and gates the enhancement on top of it.
  • Keeping the retained code careful. Deleting the paint didn’t make the state code easy. Picking a deterministic active-section rule over a tempting ratio, firing analytics once behind a guard, and disconnecting every observer in teardown are the parts that break silently if you treat the leftover JavaScript as leftover.
  • Reading the support table before trusting the feature. A senior checks where a feature actually runs today, ships it as an enhancement where it does, and writes the fallback for where it doesn’t, rather than assuming “new” means “everywhere” or “Chromium only.”

When CSS can’t own it

The line is sharp once you’ve drawn it. Reach for the CSS timeline when the behavior is a reversible visual mapping from scroll progress, the content is already complete without it, and the paint rides on transform or opacity. Keep the job in JavaScript the moment it must happen exactly once, becomes state the app reads, fires a business event, changes navigation, or loads data. And never let essential content depend on the enhancement on either side of that line, because the content-complete base is the contract everything else stands on. A scroll-driven animation is a genuinely good tool with one job. The senior skill is knowing that job by name before you write the listener.

Build your own

You’ve poked at the finished page above. Now take a real article of your own and run the table over it. Move the bar and any deliberately reversible reveals to CSS, keep the active section and any analytics on observers, write the static base so nothing ever hides, and check it in Firefox stable to see the fallback do its job. That last check, the one in the browser that doesn’t support the feature, is where you find out whether you actually drew the line or just moved the code.

Want more of these in your Google results?