A floating button that appears after scrolling, draws a progress ring, scrolls smoothly (instantly under reduced motion) and moves focus to the content.
Release notes
Scroll down to reveal the button. The ring fills as you read.
Version 1.14
Fixes, polish and small improvements across the app.
Version 1.13
Fixes, polish and small improvements across the app.
Version 1.12
Fixes, polish and small improvements across the app.
Version 1.11
Fixes, polish and small improvements across the app.
Version 1.10
Fixes, polish and small improvements across the app.
Version 1.9
Fixes, polish and small improvements across the app.
Version 1.8
Fixes, polish and small improvements across the app.
Version 1.7
Fixes, polish and small improvements across the app.
Version 1.6
Fixes, polish and small improvements across the app.
Version 1.5
Fixes, polish and small improvements across the app.
Version 1.4
Fixes, polish and small improvements across the app.
Version 1.3
Fixes, polish and small improvements across the app.
Version 1.2
Fixes, polish and small improvements across the app.
Version 1.1
Fixes, polish and small improvements across the app.
"use client";
import * as React from "react";
import { BackToTop } from "@/components/ballmac/back-to-top";
export default function BackToTopDemo() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div className="relative w-full max-w-md overflow-hidden rounded-xl border bg-card shadow-sm">
<div ref={scroller} tabIndex={0} aria-label="Release notes" className="h-72 overflow-auto p-5 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50">
<h2 className="text-lg font-semibold tracking-tight">Release notes</h2>
<p className="mt-1 text-sm text-muted-foreground">Scroll down to reveal the button. The ring fills as you read.</p>
<ul className="mt-4 grid gap-3">
{Array.from({ length: 14 }, (_, i) => (
<li key={i} className="rounded-lg border p-3 text-sm">
<p className="font-medium">Version 1.{14 - i}</p>
<p className="text-muted-foreground">Fixes, polish and small improvements across the app.</p>
</li>
))}
</ul>
</div>
<BackToTop container={scroller} threshold={120} focusTarget="[aria-label='Release notes']" className="absolute" />
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/back-to-topInstall the dependencies.
$ pnpm add lucide-react motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/scroll @ballmac/motion-presets @ballmac/i18nCopy the source into your project.
components/ballmac/back-to-top.tsx// Ballmac UI: Back To Top. https://ui.ballmac.com/components/back-to-top "use client"; import * as React from "react"; import { ArrowUp } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion, useScroll, useTransform } from "motion/react"; import { prefersReducedMotion, useScrolled } from "@/lib/ballmac/scroll"; import { spring } from "@/lib/ballmac/motion"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; type BackToTopProps = Omit<React.ComponentProps<"button">, "onClick"> & { /** Scroll distance in pixels after which the button appears. */ threshold?: number; /** A scrollable element to watch and scroll instead of the page. */ container?: React.RefObject<HTMLElement | null>; /** Draw a ring around the button that fills with scroll progress. */ showProgress?: boolean; /** CSS selector of the element that receives focus after scrolling. Defaults to `main`, then the page. */ focusTarget?: string; /** Button text, used as the accessible name. */ label?: string; /** Show the label next to the arrow. */ showLabel?: boolean; }; const R = 18; const C = 2 * Math.PI * R; /** * A floating button that appears after the reader scrolls down and returns them to the top. Focus moves to the main * content afterwards, so keyboard users do not lose their place when the button fades away. * Fixed to the viewport; add `absolute` to `className` to place it inside a positioned container. */ function BackToTop({ threshold = 320, container, showProgress = true, focusTarget, label, showLabel = false, className, ...props }: BackToTopProps) { const msg = useMessages() label ??= msg("back-to-top.label", "Back to top") const reduce = useReducedMotion(); const visible = useScrolled(threshold, container); const { scrollYProgress } = useScroll(container ? { container } : undefined); const dashOffset = useTransform(scrollYProgress, (p) => C * (1 - p)); return ( <AnimatePresence> {visible && ( <motion.button type="button" data-slot="back-to-top" aria-label={showLabel ? undefined : label} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.9 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.9 }} transition={reduce ? { duration: 0.1 } : spring.snappy} onClick={() => { const behavior = prefersReducedMotion() ? "auto" : "smooth"; const box = container?.current; if (box) box.scrollTo({ top: 0, behavior }); else window.scrollTo({ top: 0, behavior }); const target = document.querySelector<HTMLElement>(focusTarget ?? "main") ?? document.body; if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1"); target.focus({ preventScroll: true }); }} className={cn( "fixed end-5 bottom-5 z-40 inline-flex h-11 items-center justify-center gap-2 rounded-full border bg-background/90 text-foreground shadow-[0_8px_24px_-8px_rgb(0_0_0/0.3)] outline-none backdrop-blur transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50", showLabel ? "pe-4 ps-3" : "w-11", className, )} {...(props as object)} > {showProgress && !showLabel ? ( <svg aria-hidden="true" viewBox="0 0 44 44" className="pointer-events-none absolute inset-0 -rotate-90"> <circle cx="22" cy="22" r={R} fill="none" strokeWidth="2" className="stroke-border" /> <motion.circle cx="22" cy="22" r={R} fill="none" strokeWidth="2" strokeLinecap="round" className="stroke-primary" strokeDasharray={C} style={{ strokeDashoffset: dashOffset }} /> </svg> ) : null} <ArrowUp aria-hidden="true" className="relative size-4" /> {showLabel && <span className="relative text-sm font-medium">{label}</span>} </motion.button> )} </AnimatePresence> ); } export { BackToTop, type BackToTopProps };Update the import paths to match your project setup.
Usage
import { BackToTop } from "@/components/ballmac/back-to-top"The full example is in the Code tab above.
Examples
Labelled
Scroll to show the labelled version, line 1.
Scroll to show the labelled version, line 2.
Scroll to show the labelled version, line 3.
Scroll to show the labelled version, line 4.
Scroll to show the labelled version, line 5.
Scroll to show the labelled version, line 6.
Scroll to show the labelled version, line 7.
Scroll to show the labelled version, line 8.
Scroll to show the labelled version, line 9.
Scroll to show the labelled version, line 10.
Scroll to show the labelled version, line 11.
Scroll to show the labelled version, line 12.
"use client";
import * as React from "react";
import { BackToTop } from "@/components/ballmac/back-to-top";
export default function BackToTopStates() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div className="relative w-full max-w-sm overflow-hidden rounded-xl border bg-card">
<div ref={scroller} tabIndex={0} aria-label="Long page" className="h-44 overflow-auto p-4 text-sm text-muted-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50">
{Array.from({ length: 12 }, (_, i) => (
<p key={i} className="mb-3">Scroll to show the labelled version, line {i + 1}.</p>
))}
</div>
<BackToTop container={scroller} threshold={60} showLabel focusTarget="[aria-label='Long page']" className="absolute end-3 bottom-3" />
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
thresholdScroll distance in pixels after which the button appears. | number | 320 |
containerA scrollable element to watch and scroll instead of the page. | React.RefObject<HTMLElement | null> | — |
showProgressDraw a ring around the button that fills with scroll progress. | boolean | true |
focusTargetCSS selector of the element that receives focus after scrolling. Defaults to `main`, then the page. | string | — |
labelButton text, used as the accessible name. | string | — |
showLabelShow the label next to the arrow. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | Scrolls to the top and moves focus to the main content so focus is not lost when the button disappears |
| Reduced motion | Jumps instead of scrolling smoothly |
Use with AI
Render <BackToTop /> once. threshold sets when it appears; container follows a scrollable element; focusTarget chooses where focus lands. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Back To Top (@ballmac/back-to-top) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Long pages, feeds and docs
- Panels with a lot of scrolling
Not for
- Short pages
- Infinite feeds where returning to the top loses work
Registry JSON: https://ui.ballmac.com/r/back-to-top.json
Credits
Free to use in personal and commercial projects.
Pairs well with
Scroll Progress
A thin reading-progress bar fixed to the top or bottom of the page, or to a scrollable panel, that fills on a soft spring (or exactly under reduced motion).
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Animated Tabs
Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.
Breadcrumb
A compact navigation trail with truncation, semantic current page, and visible keyboard focus.