"use client";
import * as React from "react";
import { AnimatePresence, motion, useMotionValue } from "motion/react";
export interface ScrollToTopProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"> {
/** Scrollable element to watch and scroll. Defaults to the window. */
container?: React.RefObject<HTMLElement | null>;
/** Scroll distance in px after which the button appears. */
threshold?: number;
/** Corner the button sits in. With `container`, place it inside a `relative` wrapper around the scroll box. */
position?: "bottom-right" | "bottom-left" | "bottom-center";
/** Distance from the edges in px. */
offset?: number;
/** Button diameter in px. */
size?: number;
/** Show the circular scroll-progress ring. */
showProgress?: boolean;
/** Ring and focus colour. */
accent?: string;
/** Move focus to the top after scrolling (focusTarget, else the container / the main landmark). */
focusOnTop?: boolean;
/** Element that receives focus after scrolling to the top. */
focusTarget?: React.RefObject<HTMLElement | null>;
/** Accessible label; the scrolled percentage is appended. */
label?: string;
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const RING = 2; // ring stroke width in px
const POSITIONS = {
"bottom-right": { right: "var(--stt-offset)" },
"bottom-left": { left: "var(--stt-offset)" },
"bottom-center": { left: "50%", marginLeft: "calc(var(--stt-size) / -2)" },
} as const;
/** Focuses an element without scrolling, making it programmatically focusable when needed. */
function focusQuietly(el: HTMLElement) {
if (el.tabIndex < 0 && !el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
}
/**
* A back-to-top button that appears after a scroll threshold, shows scroll progress as a ring,
* scrolls smoothly (instantly with reduced motion) and then moves focus to the top of the page or
* container. Works with the window or any scrollable element passed as `container`.
*/
export function ScrollToTop({
container,
threshold = 240,
position = "bottom-right",
offset = 24,
size = 48,
showProgress = true,
accent = "#FF7448",
focusOnTop = true,
focusTarget,
label = "Back to top",
className = "",
style,
onClick,
...props
}: ScrollToTopProps) {
const reduceMotion = usePrefersReducedMotion();
const [visible, setVisible] = React.useState(false);
// Rounded to 5% steps for the accessible label, so scrolling re-renders rarely.
const [percent, setPercent] = React.useState(0);
// Scroll progress 0–1, written once per frame without re-rendering.
const progress = useMotionValue(0);
const cleanupRef = React.useRef<() => void>(() => {});
const read = React.useEffectEvent(() => {
const el = container?.current;
const top = el ? el.scrollTop : window.scrollY;
const max = el ? el.scrollHeight - el.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
const p = max > 0 ? Math.min(1, Math.max(0, top / max)) : 0;
progress.set(p);
setVisible(top > threshold);
setPercent(Math.round(p * 20) * 5);
});
React.useEffect(() => {
const el = container?.current ?? null;
const target: HTMLElement | Window = el ?? window;
let frame = 0;
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => read());
};
target.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
// Content growing or shrinking changes the progress without a scroll event.
const observer = new ResizeObserver(schedule);
observer.observe(el ?? document.documentElement);
if (el?.firstElementChild) observer.observe(el.firstElementChild);
schedule();
return () => {
cancelAnimationFrame(frame);
target.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
observer.disconnect();
};
}, [container]);
// Cancel a pending "focus after scroll" on unmount.
React.useEffect(() => () => cleanupRef.current(), []);
const scrollToTop = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
const el = container?.current ?? null;
const scroller: HTMLElement | Window = el ?? window;
scroller.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
if (!focusOnTop) return;
// Move focus once the scroll has settled at the top (scrollend, polling as a fallback).
cleanupRef.current();
const atTop = () => (el ? el.scrollTop : window.scrollY) <= 1;
const destination = () =>
focusTarget?.current ?? el ?? document.querySelector<HTMLElement>("main, [role='main']") ?? document.body;
let done = false;
const finish = () => {
if (done) return;
done = true;
cleanupRef.current();
focusQuietly(destination());
};
const onEnd = () => atTop() && finish();
const poll = window.setInterval(() => atTop() && finish(), 100);
const timeout = window.setTimeout(finish, 1500);
scroller.addEventListener("scrollend", onEnd);
cleanupRef.current = () => {
window.clearInterval(poll);
window.clearTimeout(timeout);
scroller.removeEventListener("scrollend", onEnd);
cleanupRef.current = () => {};
};
if (atTop()) finish();
};
const r = size / 2 - RING / 2 - 1;
return (
<AnimatePresence>
{visible ? (
<motion.button
key="scroll-to-top"
type="button"
aria-label={`${label}, ${percent}% scrolled`}
title={label}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.8 }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.85, transition: { duration: 0.16 } }}
transition={reduceMotion ? { duration: 0.12 } : { type: "spring", stiffness: 460, damping: 30 }}
whileHover={reduceMotion ? undefined : { y: -2 }}
whileTap={reduceMotion ? undefined : { scale: 0.92 }}
onClick={scrollToTop}
className={`group z-40 grid place-items-center rounded-full border border-zinc-900/10 bg-white text-zinc-800 shadow-[0_12px_28px_-12px_rgb(0_0_0/0.35)] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--stt-accent)] dark:border-white/10 dark:bg-[#1a1d21] dark:text-zinc-100 dark:shadow-[0_12px_28px_-12px_rgb(0_0_0/0.8)] ${container ? "absolute" : "fixed"} ${className}`}
style={{
["--stt-accent" as string]: accent,
["--stt-offset" as string]: `${offset}px`,
["--stt-size" as string]: `${size}px`,
width: size,
height: size,
bottom: "var(--stt-offset)",
...POSITIONS[position],
...style,
}}
{...props}
>
{showProgress ? (
<svg aria-hidden="true" viewBox={`0 0 ${size} ${size}`} className="pointer-events-none absolute inset-0 size-full -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={RING} className="stroke-zinc-900/10 dark:stroke-white/10" />
<motion.circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
strokeWidth={RING}
strokeLinecap="round"
stroke="var(--stt-accent)"
style={{ pathLength: progress }}
/>
</svg>
) : null}
<svg
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className="relative size-4 transition-transform duration-300 ease-out group-hover:-translate-y-0.5"
>
<path d="M8 13V3.5M3.5 7.5 8 3l4.5 4.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</motion.button>
) : null}
</AnimatePresence>
);
}