Arrives after a screen, rings with progress, and hands focus back at the top.
Scrollmotion
01Preview
Changelog
Everything we shipped, newest first.
- 4.12.0Sep 18Feature
- Usage-based billing is out of beta for every paid plan.
- Invoices show metered usage per project, with a CSV export.
- 4.11.3Sep 11Fix
- Webhooks retry with exponential backoff instead of failing after the first timeout.
- 4.11.0Sep 4Feature
- SSO with Okta, Google Workspace and Azure AD on the Team plan.
- SCIM provisioning keeps seats in sync with your directory.
- 4.10.2Aug 27Fix
- The dashboard no longer double-counts seats added and removed on the same day.
- 4.10.0Aug 20Feature
- Audit log export to your own bucket, daily or hourly.
- Filter the audit log by actor, action and project.
- 4.9.1Aug 13Fix
- Search results keep their order when new events arrive while you read them.
- 4.9.0Aug 6Feature
- Self-serve seat changes: add or remove seats without contacting sales.
- Prorated charges appear on the next invoice, itemised.
- 4.8.4Jul 30Fix
- Invoice PDFs render the tax ID on the first page again.
- 4.8.0Jul 23Feature
- Spending limits with an email when a project reaches 80%.
- 4.7.2Jul 16Fix
- Exports larger than 1 GB finish instead of timing out at 99%.
- 4.7.0Jul 9Feature
- Two-factor authentication can be required for everyone in a workspace.
- Recovery codes can be downloaded again from security settings.
- 4.6.1Jul 2Fix
- Dates in the activity feed follow your locale instead of the server’s.
- 4.6.0Jun 25Feature
- Project templates: start a project with its settings, roles and webhooks copied from another.
- 4.5.3Jun 18Fix
- Removing a teammate no longer leaves their pending invites active.
- 4.5.0Jun 11Feature
- A command palette, on ⌘K, for jumping to any project, invoice or setting.
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { BackToTop } from "@/components/ui/back-to-top";
// The page
<BackToTop />
// A scroll region, with focus returned to its heading
<div className="relative">
<div ref={scroller} className="h-full overflow-y-auto">
<h1 ref={heading} tabIndex={-1}>Changelog</h1>
…
</div>
<BackToTop scrollRoot={scroller} focusTarget={heading} position="absolute" />
</div>04Source
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ArrowUp } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
type Target = React.RefObject<HTMLElement | null>;
type Scroller = HTMLElement | null; // null means the page
// The native scroll-driven timeline, where the browser has it. Not in the DOM typings yet.
type ScrollTimelineCtor = new (options: { source: Element; axis?: "block" | "inline" }) => AnimationTimeline;
const nativeScrollTimeline = () => (window as unknown as { ScrollTimeline?: ScrollTimelineCtor }).ScrollTimeline;
const readY = (s: Scroller) => (s ? s.scrollTop : window.scrollY);
const maxY = (s: Scroller) => (s ? s.scrollHeight - s.clientHeight : document.documentElement.scrollHeight - window.innerHeight);
const viewH = (s: Scroller) => (s ? s.clientHeight : window.innerHeight);
function goTop(s: Scroller, smooth: boolean) {
(s ?? window).scrollTo({ top: 0, behavior: smooth ? "smooth" : "instant" });
}
/** Moves focus to the start of the content without scrolling, making it focusable if it isn't. */
function focusStart(el: HTMLElement) {
if (el.tabIndex < 0 && !el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
}
export type BackToTopProps = Omit<React.ComponentProps<"button">, "children"> & {
/** The scroll container. Defaults to the page. */
scrollRoot?: Target;
/** Pixels scrolled before it appears. Defaults to one screen of the container. */
threshold?: number;
/** Where keyboard focus lands once the top is reached. Defaults to the container, or the page's main. */
focusTarget?: Target;
/** Accessible name, and the visible text when showLabel is on. */
label?: string;
/** A pill with the label beside the ring, instead of a round icon button. */
showLabel?: boolean;
/** fixed sits in the viewport corner; absolute in the nearest positioned parent's. */
position?: "fixed" | "absolute";
};
export function BackToTop({
scrollRoot,
threshold,
focusTarget,
label = "Back to top",
showLabel = false,
position = "fixed",
className,
onClick,
...rest
}: BackToTopProps) {
const reduce = useReducedMotion();
const scroller = useRef<Scroller>(null);
const [visible, setVisible] = useState(false);
const [launches, setLaunches] = useState(0);
// Set while a trip to the top is under way; cleared on arrival or if the user takes over.
const trip = useRef(false);
useEffect(() => {
const s = scrollRoot?.current ?? null;
scroller.current = s;
const source: HTMLElement | Window = s ?? window;
let frame = 0;
let shown = false;
const update = () => {
frame = 0;
const y = readY(s);
const show = threshold ?? viewH(s);
if (trip.current && y <= 1) {
trip.current = false;
focusStart(focusTarget?.current ?? s ?? document.querySelector("main") ?? document.body);
}
// Shown past the threshold; once shown it stays until half of it, so it
// doesn't flicker for someone reading right at the line. On a trip it
// rides all the way up and leaves at the top.
shown = trip.current ? shown : shown ? y > show / 2 : y > show;
setVisible(shown);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
// Any hand on the wheel, screen or keys during the trip means they've taken over.
const takeOver = () => {
trip.current = false;
};
source.addEventListener("scroll", onScroll, { passive: true });
source.addEventListener("wheel", takeOver, { passive: true });
source.addEventListener("touchstart", takeOver, { passive: true });
source.addEventListener("keydown", takeOver);
frame = requestAnimationFrame(update);
return () => {
source.removeEventListener("scroll", onScroll);
source.removeEventListener("wheel", takeOver);
source.removeEventListener("touchstart", takeOver);
source.removeEventListener("keydown", takeOver);
cancelAnimationFrame(frame);
};
}, [scrollRoot, threshold, focusTarget]);
return (
<AnimatePresence>
{visible && (
<motion.button
key="back-to-top"
type="button"
aria-label={showLabel ? undefined : label}
data-position={position}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: 8, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.92, y: 4, filter: "blur(2px)", transition: { duration: 0.16, ease: ease.in } }}
transition={reduce ? { duration: 0.16 } : spring.snappy}
onClick={(e) => {
onClick?.(e);
if (e.defaultPrevented) return;
trip.current = true;
setLaunches((n) => n + 1);
goTop(scroller.current, !reduce);
}}
className={cn(
"group/top z-(--z-sticky) inline-flex items-center justify-center rounded-full bg-raised text-fg shadow-pop",
"bottom-[max(16px,env(safe-area-inset-bottom))] right-4",
position === "fixed" ? "fixed" : "absolute",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
// 40px drawn, 48px to a finger.
"before:absolute before:-inset-1 before:rounded-full before:content-[''] pointer-fine:before:hidden",
"transition-[background-color,border-color] duration-150 hover:bg-hover",
// Press is handled by Motion so it composes with the entrance transform.
// As a pill it has a border; as a circle the ring itself is the edge.
showLabel ? "h-10 gap-2 border border-line-2 pl-1 pr-3.5 text-[12.5px] font-medium hover:border-fg-4" : "size-10",
className,
)}
whileTap={reduce ? undefined : { scale: 0.92, transition: { duration: 0.08 } }}
{...(rest as React.ComponentProps<typeof motion.button>)}
>
{!showLabel && <ProgressRing scroller={scroller} className="absolute inset-0 size-10" />}
<span className="relative grid size-8 place-items-center">
{showLabel && <ProgressRing scroller={scroller} className="absolute inset-0 size-8" />}
<span className="relative grid size-4 place-items-center overflow-hidden transition-transform duration-200 ease-out-expo group-hover/top:-translate-y-px motion-reduce:transition-none">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={launches}
className="grid place-items-center"
// Each press sends the arrow up and out, and a fresh one rises into place.
initial={reduce ? { opacity: 0 } : { y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { y: -14, opacity: 0, transition: { duration: 0.16, ease: ease.in } }}
transition={reduce ? { duration: 0.12 } : { ...spring.pop, delay: 0.06 }}
>
<ArrowUp />
</motion.span>
</AnimatePresence>
</span>
</span>
{showLabel && label}
</motion.button>
)}
</AnimatePresence>
);
}
/**
* How far down the container is, drawn around the arrow. A native scroll
* timeline drives it where supported; otherwise the scroll position scrubs it.
*/
function ProgressRing({ scroller, className }: { scroller: React.RefObject<Scroller>; className?: string }) {
const arc = useRef<SVGCircleElement>(null);
useEffect(() => {
const el = arc.current;
if (!el) return;
const s = scroller.current;
const keyframes = [{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }];
const TL = nativeScrollTimeline();
if (TL) {
const anim = el.animate(keyframes, { fill: "both", timeline: new TL({ source: s ?? document.documentElement, axis: "block" }) } as KeyframeAnimationOptions);
return () => anim.cancel();
}
const anim = el.animate(keyframes, { duration: 1000, fill: "both" });
anim.pause();
const source: HTMLElement | Window = s ?? window;
let frame = 0;
const update = () => {
frame = 0;
const max = maxY(s);
anim.currentTime = max > 0 ? Math.min(1, readY(s) / max) * 1000 : 0;
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
source.addEventListener("scroll", onScroll, { passive: true });
update();
return () => {
source.removeEventListener("scroll", onScroll);
cancelAnimationFrame(frame);
anim.cancel();
};
}, [scroller]);
return (
<svg viewBox="0 0 40 40" className={cn("pointer-events-none -rotate-90", className)} aria-hidden>
<circle cx="20" cy="20" r="19.25" fill="none" strokeWidth="1.5" className="stroke-line-2 transition-[stroke] duration-150 group-hover/top:stroke-fg-4" />
<circle ref={arc} cx="20" cy="20" r="19.25" fill="none" strokeWidth="1.5" strokeLinecap="round" pathLength={1} strokeDasharray="1 1" strokeDashoffset={1} className="stroke-fg" />
</svg>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| scrollRoot | RefObject<HTMLElement | null> | — | The scroll container. Defaults to the page. |
| threshold | number | — | Pixels scrolled before it appears. Defaults to one screen of the container. |
| focusTarget | RefObject<HTMLElement | null> | — | Where focus lands on arrival. Defaults to the container, or the page's main. Made focusable with tabindex=-1 if it isn't. |
| label | string | "Back to top" | Accessible name, and the visible text with showLabel. |
| showLabel | boolean | false | A pill with the label beside the ring, instead of a round icon button. |
| position | "fixed" | "absolute" | "fixed" | The viewport's bottom-right corner (clear of the home indicator), or the nearest positioned parent's. |
06Notes
Behavior
- Appears past one screen and, once up, stays until half of that, so it doesn't flicker for someone reading right at the line.
- After a press it rides the whole way up and leaves at the top, then moves focus to the start of the content. Focus never falls to the body when the button disappears.
- A wheel, touch or key during the trip hands control back: the scroll stops where they stop it, and focus stays put.
- The ring shows how far down the container they are, driven by a native scroll timeline where supported and by one scroll listener otherwise.
Motion
- Enters on the snappy spring from 0.9 scale, 8px low with a 2px blur; leaves in 160ms, shorter and simpler.
- Each press sends the arrow up and out through a 16px window in 160ms and a new one rises in on the pop spring, so the button answers before the page has moved. Hover nudges the arrow up 1px.
- Reduced motion jumps straight to the top, and the button and arrow cross-fade in 120–160ms without scale, travel or blur.
Accessibility
- A native button with the label as its name, in the tab order only while shown. It draws at 40px and takes 48px of touch.
- Arrival moves focus to the heading or container you choose, without scrolling, so the next Tab continues from the top of the content.