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.