← Overview

backToTop

Scroll-triggered control that jumps back to the top of the page.

Scroll this page. Two ordinary anchor links sit fixed in the bottom corners, and both are hidden until the page has been scrolled far enough to make them useful — the one thing pure CSS cannot decide. The script adds it as a single attribute, driven by one IntersectionObserver on a zero-size sentinel at the top of the page: no scroll listener, nothing measured per frame.

What the platform already does

Both controls are <a> elements with an in-page href, so the jump itself is native: the browser scrolls, it honours the scroll-behavior: smooth this demo page sets behind a prefers-reduced-motion check, and it never needed a click handler. The module deliberately refuses to enhance anything else — a <button> would be a control that does nothing without JavaScript, and a fragment pointing at a missing id would scroll nowhere. Both cases are reported through the reporter instead of being hidden behind a nice-looking pill.

The right-hand control shows the recommended wiring: href="#pageTop" resolves to the hero section at the top of this page. That matters for the keyboard. A fragment that resolves to an element makes that element the sequential focus navigation starting point, so the next Tab after the jump continues at the top of the page — where the reader now is — instead of at the control down here. The left-hand control uses plain href="#top", which the HTML specification resolves to the top of the document without any target element at all: less markup, no focus benefit.

What the script adds

Exactly one bit of information: has the reader scrolled far enough for this control to be worth showing? There is no CSS selector for it. Scroll-driven animations come close, but their support is still uneven, and the traditional answer — a scroll listener that reads the scroll offset on every frame — does continuous work for a state that changes twice.

So the script borrows the sentinel pattern from stickyObserver and scrollSpy: it prepends an empty, zero-size, aria-hidden <div> to <body> and gives one IntersectionObserver a region grown upwards by the threshold. The sentinel sits at scroll position 0 and travels up with the page, so it leaves that region at precisely the moment the page has been scrolled by the threshold — isIntersecting is "not far enough yet". The browser reports the two crossings; between them this page costs nothing. Instant jumps are crossings as well, so the Home key or another anchor link can never leave a stale state behind.

The threshold defaults to one viewport height — "the reader has left the first screen", whatever that screen happens to be. It is baked into the observed region, so a resize rebuilds the observer once (debounced), and only when the re-measured threshold actually changed. Resize this window and the labelled control's trigger point follows the new height; the icon-only one keeps its explicit 200px.

Where the state lives

Nowhere in the script's own memory: it toggles data-backtotop-hidden on the control and stops there — no inline styles, no animations, no ARIA. The stylesheet turns that attribute into opacity: 0, visibility: hidden and a short slide back into the corner, with the transition and its prefers-reduced-motion escape. Hiding with visibility rather than opacity alone is the accessibility-relevant half: a hidden control leaves the tab order and the accessibility tree, so this page has no invisible tab stop waiting in the corner while you read the top of it.

One exception is written into that rule: :not(:focus-visible). A control that still holds keyboard focus is never hidden away under the user — and the two instances here show both outcomes of an activation. Tab to the left one and press Enter: the page scrolls to the top, the attribute is written, and the pill stays on screen, because href="#top" is the top of the document and no element that could take the focus away from the link. One more Tab and it fades out. Do the same with the right one and the browser moves focus to the resolved #pageTop section instead, so the control fades out immediately and the next Tab lands on the “Overview” link at the top of the page — which is the reason to prefer a resolving fragment.

Everything above is a progressive enhancement in the strict sense: if this page's JavaScript failed to load, the two controls would sit in their corners permanently — still perfectly working jump links, just without the "only when useful" part. That is also why they are briefly visible right after a reload, before the lazily loaded module takes over. The readme documents the opt-out for projects that would rather start hidden; it keys off data-backtotop-ready, the second attribute the module writes, which means "the module has taken over" rather than any state — the absence of data-backtotop-hidden could not serve for it, meaning both “scrolled far enough” and “not initialised yet”.

Print this page (or open the print preview) and both controls are gone: they are position: fixed and permanently on screen, so on paper they would sit on top of the text — and a jump link means nothing there. That rule ships with the component.

You are near the bottom

Both controls are on screen now. Use one of them, then scroll down here again and use the other — and note that nothing about the page moved: they are position: fixed, anchored to the viewport corners via --backToTop-inset, and layered with the shared --zi-toTop token. The only node the module added to the page is a zero-size sentinel at the very top of <body>, taken out of the flow by the shipped stylesheet so it cannot occupy a grid slot of this centred layout.