Navigation
Back to top
A button that appears once a long page or panel is scrolled, and takes scroll and focus back to the top.
- Built on
<button>position: stickyscrollTofocus({ preventScroll })- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.4 kB
- Built from
- Icon
- 4.2.0 Preview links for every branch.
- 4.1.3 Faster cold starts in eu-west-1.
- 4.1.2 Fixed log search with quotes.
- 4.1.0 Rollbacks from the command line.
- 4.0.1 Clearer build error messages.
- 4.0.0 New project dashboard.
Code
import { BackToTop } from "@/components/equip/back-to-top";
const releases = [
["4.2.0", "Preview links for every branch."],
["4.1.3", "Faster cold starts in eu-west-1."],
["4.1.2", "Fixed log search with quotes."],
["4.1.0", "Rollbacks from the command line."],
["4.0.1", "Clearer build error messages."],
["4.0.0", "New project dashboard."],
];
export default function Demo() {
return (
<div
style={{
width: 216,
height: 124,
overflowY: "auto",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
background: "var(--eq-surface)",
fontSize: "var(--eq-text-s)",
}}
>
<ul style={{ margin: 0, padding: "8px 12px 12px", listStyle: "none", color: "var(--eq-ink-2)" }}>
{releases.map(([version, text]) => (
<li key={version} style={{ padding: "6px 0", borderBottom: "1px solid var(--eq-line)" }}>
<strong style={{ color: "var(--eq-ink)" }}>{version}</strong> {text}
</li>
))}
</ul>
<BackToTop threshold={48} compact />
</div>
);
}Examples
Long article
target names the heading that takes focus at the top, so the next Tab carries on from there instead of from the bottom of the page.
Moving a database without downtime
Plan the cut-over
Pick a quiet hour and tell the people who rely on the old service. Freeze schema changes the day before.
Pick a quiet hour and tell the people who rely on the old service. Freeze schema changes the day before.
Copy the data
Run the first copy while both systems are live, then a short final sync once writes are paused.
Run the first copy while both systems are live, then a short final sync once writes are paused.
Switch the traffic
Move a small share of requests first and watch error rates before moving the rest.
Move a small share of requests first and watch error rates before moving the rest.
Clean up
Keep the old database read-only for a week, then archive it and remove its credentials.
Keep the old database read-only for a week, then archive it and remove its credentials.
Code
"use client";
import { useId } from "react";
import { BackToTop } from "@/components/equip/back-to-top";
const sections = [
["Plan the cut-over", "Pick a quiet hour and tell the people who rely on the old service. Freeze schema changes the day before."],
["Copy the data", "Run the first copy while both systems are live, then a short final sync once writes are paused."],
["Switch the traffic", "Move a small share of requests first and watch error rates before moving the rest."],
["Clean up", "Keep the old database read-only for a week, then archive it and remove its credentials."],
];
export default function Example() {
const heading = `migration-${useId().replace(/[^\w-]/g, "")}`;
return (
<div
style={{
width: "100%",
maxWidth: 440,
height: 260,
overflowY: "auto",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
background: "var(--eq-surface)",
fontSize: "var(--eq-text-s)",
lineHeight: 1.6,
color: "var(--eq-ink-2)",
}}
>
<article style={{ padding: "16px 20px 24px" }}>
<h3 id={heading} style={{ margin: "0 0 8px", fontSize: "var(--eq-text-l)", color: "var(--eq-ink)" }}>
Moving a database without downtime
</h3>
{sections.map(([title, text]) => (
<section key={title}>
<h4 style={{ margin: "16px 0 4px", color: "var(--eq-ink)" }}>{title}</h4>
<p style={{ margin: 0 }}>{text}</p>
<p style={{ margin: "8px 0 0" }}>{text}</p>
</section>
))}
</article>
<BackToTop threshold={120} target={heading} />
</div>
);
}Inside a scroll box
It watches the nearest scrolling ancestor, so it works in a panel as well as on the page; pass fixed to pin it to the window instead.
- eu-west-1 build cache cleared
- Deploy 8f3c2a promoted to production
- Domain shop.example.com verified
- Rollback to 7d19e0 finished
- Usage alert at 80% of the build minutes
- Team member Priya Raman added
- API key ending 4F2Q rotated
- Preview for branch checkout-v2 ready
- Webhook to Slack failed twice
- Invoice INV-2041 paid
Code
import { BackToTop } from "@/components/equip/back-to-top";
const results = [
"eu-west-1 build cache cleared",
"Deploy 8f3c2a promoted to production",
"Domain shop.example.com verified",
"Rollback to 7d19e0 finished",
"Usage alert at 80% of the build minutes",
"Team member Priya Raman added",
"API key ending 4F2Q rotated",
"Preview for branch checkout-v2 ready",
"Webhook to Slack failed twice",
"Invoice INV-2041 paid",
];
export default function Example() {
return (
<div
style={{
width: "100%",
maxWidth: 380,
height: 220,
overflowY: "auto",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
background: "var(--eq-surface)",
fontSize: "var(--eq-text-s)",
}}
>
<ol style={{ margin: 0, padding: "4px 16px 16px", listStyle: "none", color: "var(--eq-ink-2)" }}>
{results.map((text) => (
<li key={text} style={{ padding: "10px 0", borderBottom: "1px solid var(--eq-line)" }}>
{text}
</li>
))}
</ol>
<BackToTop threshold={80}>Top of activity</BackToTop>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
threshold | number | 400 | Pixels scrolled before the button shows. |
target | string | none | Id of the element that takes focus at the top, such as the main heading. Without it, the first focusable element at the top takes focus. |
compact | boolean | false | Shows the arrow alone; a string label becomes the accessible name. |
fixed | boolean | false | Pins it to the corner of the viewport and follows the page's scroll. |
children | ReactNode | "Back to top" | The label. |
...rest | ComponentProps<"button"> | none | Every native button attribute. An onClick that calls preventDefault stops the scroll. |
Anatomy
| root | .eq-back-to-top | The button. data-shown once scrolled past the threshold. |
|---|---|---|
| label | .eq-back-to-top-label |
Keyboard
| EnterSpace | Scrolls to the top and moves focus there. |
|---|
Notes
- Place it as the last child of the scroll box, or at the end of the page; it watches the nearest scrolling ancestor, sticks to its bottom corner while scrolling and comes to rest after the content.
- With no target and nothing focusable at the top, focus goes to the scroll box itself, which gets tabindex="-1" for it.
- While hidden it has visibility: hidden, so it is out of the tab order and the accessibility tree until it shows.
- Focus moves to the top along with the scroll, so keyboard and screen reader users carry on from where they now are.
- A target without its own tab stop gets tabindex="-1" so it can take focus.
- Scrolling is smooth unless the motion tokens are at zero, which is where reduced motion puts them.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/back-to-top.tsx98 lines
"use client";
import { useEffect, useRef, useState, type ComponentProps, type MouseEvent } from "react";
import { Icon } from "./icon";
import "./back-to-top.css";
export type BackToTopProps = ComponentProps<"button"> & {
/** How far, in pixels, the page or its scroll box must be scrolled before the button shows. */
threshold?: number;
/** The id of the element that takes focus at the top, such as the main heading. */
target?: string;
/** Shows the arrow alone; the label becomes the accessible name. */
compact?: boolean;
/** Pins it to the corner of the viewport instead of the end of its scroll box. */
fixed?: boolean;
};
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), summary';
/** The nearest ancestor that scrolls vertically, or null for the page itself. */
function scrollParent(element: HTMLElement): HTMLElement | null {
for (let node = element.parentElement; node && node !== document.body; node = node.parentElement) {
const overflow = getComputedStyle(node).overflowY;
if (overflow === "auto" || overflow === "scroll" || overflow === "overlay") return node;
}
return null;
}
/** Smooth unless the motion tokens are at zero, which is where reduced motion puts them. */
function scrollBehavior(element: Element): ScrollBehavior {
const value = getComputedStyle(element).getPropertyValue("--eq-dur-move").trim();
return parseFloat(value) === 0 ? "auto" : "smooth";
}
export function BackToTop({
threshold = 400,
target,
compact = false,
fixed = false,
className,
children = "Back to top",
onClick,
...rest
}: BackToTopProps) {
const ref = useRef<HTMLButtonElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const button = ref.current;
if (!button) return;
const box = fixed ? null : scrollParent(button);
const source: HTMLElement | Window = box ?? window;
const read = () => setShown((box ? box.scrollTop : window.scrollY) > threshold);
read();
source.addEventListener("scroll", read, { passive: true });
return () => source.removeEventListener("scroll", read);
}, [threshold, fixed]);
function handleClick(event: MouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
const button = event.currentTarget;
const box = fixed ? null : scrollParent(button);
const behavior = scrollBehavior(button);
if (box) box.scrollTo({ top: 0, behavior });
else window.scrollTo({ top: 0, behavior });
// Focus moves to the top too, so the next Tab continues from there rather than from here.
const scope: ParentNode = box ?? document;
let destination = target ? document.getElementById(target) : null;
destination ??=
Array.from(scope.querySelectorAll<HTMLElement>(FOCUSABLE)).find(
(element) => element !== button && element.getClientRects().length > 0,
) ?? box;
if (destination && !destination.matches(FOCUSABLE) && !destination.hasAttribute("tabindex")) {
destination.setAttribute("tabindex", "-1");
}
destination?.focus({ preventScroll: true });
}
return (
<button
type="button"
ref={ref}
aria-label={compact && typeof children === "string" ? children : undefined}
{...rest}
className={["eq-back-to-top", className].filter(Boolean).join(" ")}
data-shown={shown || undefined}
data-compact={compact || undefined}
data-fixed={fixed || undefined}
onClick={handleClick}
>
<Icon name="arrow-up" size="s" />
{!compact && <span className="eq-back-to-top-label">{children}</span>}
</button>
);
}components/equip/back-to-top.css86 lines
@layer equip {
/* Sticks to the bottom corner of its scroll box while scrolling, and comes to rest after the
last of the content at the end, so it never covers the final line. */
.eq-back-to-top {
position: sticky;
inset-block-end: var(--eq-space-4);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
gap: var(--eq-space-2);
width: max-content;
height: var(--eq-h-m);
margin-block: var(--eq-space-2) var(--eq-space-4);
margin-inline: auto var(--eq-space-4);
padding: 0 var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
white-space: nowrap;
cursor: pointer;
user-select: none;
/* Hidden until scrolled. visibility keeps it out of the tab order while it is away. */
visibility: hidden;
opacity: 0;
translate: 0 8px;
scale: 0.96;
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
visibility var(--eq-dur),
background-color var(--eq-dur) var(--eq-ease);
}
.eq-back-to-top[data-fixed] {
position: fixed;
inset: auto var(--eq-space-4) max(var(--eq-space-4), env(safe-area-inset-bottom)) auto;
margin: 0;
}
.eq-back-to-top[data-compact] {
width: var(--eq-h-m);
padding: 0;
}
.eq-back-to-top[data-shown] {
visibility: visible;
opacity: 1;
translate: none;
scale: 1;
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring),
scale var(--eq-dur-move) var(--eq-spring),
visibility 0s,
background-color var(--eq-dur) var(--eq-ease);
}
.eq-back-to-top > svg {
flex: none;
width: 16px;
height: 16px;
}
@media (hover: hover) {
.eq-back-to-top:hover {
background: color-mix(in srgb, var(--eq-raised) 90%, var(--eq-ink));
}
}
.eq-back-to-top[data-shown]:active {
scale: var(--eq-press);
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-back-to-top:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
}