Back to Top
A floating back-to-top button that appears after scrolling a page or container, with a ring showing scroll position and smooth scrolling (instant under reduced motion).
Preview
Guide · 6 min read
Framing a print at home
Before you start
Clear a bench near a window and lay out the frame, the glass and the brads. Measure twice: the print should sit a few millimetres inside the rebate on every side.
Cutting the mount
Score the board on the back with a fresh blade, then cut at a steady 45 degrees. Corners that overrun by a hair look worse than corners that stop just short.
Cleaning the glass
Work in long strokes with a lint-free cloth. Hold the pane up to the light at an angle; dust shows best against a dark wall.
Assembly
Stack glass, mount, print and backing face down. Press the brads in with a flat tool, two per side, and finish with tape over the seam.
Choosing a frame
Thin black or natural oak suits most prints. Go wider and darker for small, busy images; a pale frame lets a quiet one breathe.
Picking glass
Plain float glass is fine away from sunlight. Near a window, a UV-filtering pane keeps colours from fading over the years.
Hanging
Two hooks beat one: the frame stays level when a door slams. Aim the centre of the picture at eye height, about 145 cm from the floor.
Shipments · scroll to reveal
- Shipment #4810 · Packed
- Shipment #4811 · In transit
- Shipment #4812 · Delivered
- Shipment #4813 · Packed
- Shipment #4814 · In transit
- Shipment #4815 · Delivered
- Shipment #4816 · Packed
- Shipment #4817 · In transit
- Shipment #4818 · Delivered
- Shipment #4819 · Packed
- Shipment #4820 · In transit
- Shipment #4821 · Delivered
- Shipment #4822 · Packed
- Shipment #4823 · In transit
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/back-to-top.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| target | RefObject<HTMLElement | null> | — (window) | The scroll container. With one, the button is absolute; put it in a relative wrapper beside the scroller. |
| threshold | number | 240 | Show once scrolled this many px. |
| position | "bottom-right" | "bottom-left" | "bottom-center" | "bottom-right" | Where it floats. |
| showProgress | boolean | true | Draw the ring that fills with scroll position. |
| label | string | "Back to top" | Accessible name and title. |
| onClick | MouseEventHandler | — | Runs first; call preventDefault to skip the built-in scroll. |
Build notes
- One effect subscribes to `scroll` on the container (or window) plus `resize`, and reads once on the next frame. Progress goes into a motion value (no re-render per scroll); `visible` is state and only changes when the threshold is crossed. - Ring: 44-unit SVG, r 20, rotated −90°; `strokeDashoffset = C × (1 − progress)` via `useTransform`, track at `stroke-border/70`. - Entrance: spring (480/32) from y 12, scale 0.9, opacity 0 inside `AnimatePresence`. - Button: `size-11 rounded-full bg-card ring-hairline shadow-raised-md inset-shadow-highlight`, `hover:shadow-raised-lg`, `active:scale-95`. <!-- PEBBLE-LICENSE -->
View source
registry/button/back-to-top/back-to-top.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: back-to-top.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import {
AnimatePresence,
motion,
MotionConfig,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";
const R = 20;
const C = 2 * Math.PI * R;
export type BackToTopProps = Omit<React.ComponentProps<"button">, "children"> & {
/**
* The scroll container. Omit to follow the window (the button is then
* `fixed`); with a container, put the button in a `relative` wrapper beside
* it and it sits `absolute` over the container's corner.
*/
target?: React.RefObject<HTMLElement | null>;
/** Show once scrolled this far (px). */
threshold?: number;
position?: "bottom-right" | "bottom-left" | "bottom-center";
/** Draw the scroll-position ring around the button. */
showProgress?: boolean;
label?: string;
};
/**
* A floating button that appears once a page or container has been scrolled,
* shows how far down you are as a ring, and scrolls smoothly back to the top
* (instantly under reduced motion).
*/
export function BackToTop({
target,
threshold = 240,
position = "bottom-right",
showProgress = true,
label = "Back to top",
className,
onClick,
...props
}: BackToTopProps) {
const reduced = useReducedMotion();
const [visible, setVisible] = React.useState(false);
const progress = useMotionValue(0);
const offset = useTransform(progress, (p) => C * (1 - p));
React.useEffect(() => {
const el = target?.current ?? null;
const read = () => {
const top = el ? el.scrollTop : window.scrollY;
const room = el
? el.scrollHeight - el.clientHeight
: document.documentElement.scrollHeight - window.innerHeight;
progress.set(room > 0 ? Math.min(1, top / room) : 0);
setVisible(top > threshold);
};
const source: HTMLElement | Window = el ?? window;
source.addEventListener("scroll", read, { passive: true });
window.addEventListener("resize", read);
const frame = requestAnimationFrame(read);
return () => {
cancelAnimationFrame(frame);
source.removeEventListener("scroll", read);
window.removeEventListener("resize", read);
};
}, [target, threshold, progress]);
function scrollUp(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) return;
const behavior: ScrollBehavior = reduced ? "auto" : "smooth";
const el = target?.current;
if (el) el.scrollTo({ top: 0, behavior });
else window.scrollTo({ top: 0, behavior });
}
return (
<MotionConfig reducedMotion="user">
<AnimatePresence>
{visible && (
<motion.div
key="back-to-top"
initial={{ opacity: 0, y: 12, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.9 }}
transition={{ type: "spring", stiffness: 480, damping: 32 }}
className={cn(
"z-40",
target ? "absolute" : "fixed",
position === "bottom-right" && "right-4 bottom-4",
position === "bottom-left" && "bottom-4 left-4",
position === "bottom-center" && "inset-x-0 bottom-4 mx-auto w-fit",
)}
>
<button
type="button"
data-slot="back-to-top"
aria-label={label}
title={label}
onClick={scrollUp}
className={cn(
"group/top relative grid size-11 cursor-pointer place-items-center rounded-full bg-card text-foreground",
"shadow-raised-md ring-1 inset-shadow-highlight ring-hairline",
"transition-[box-shadow,transform] duration-(--motion-fast) ease-(--motion-move) hover:shadow-raised-lg active:scale-95",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:active:scale-100",
className,
)}
{...props}
>
{showProgress && (
<svg viewBox="0 0 44 44" aria-hidden="true" className="absolute inset-0 -rotate-90">
<circle
cx="22"
cy="22"
r={R}
fill="none"
strokeWidth="2"
className="stroke-border/70"
/>
<motion.circle
cx="22"
cy="22"
r={R}
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={C}
style={{ strokeDashoffset: offset }}
className="stroke-accent"
/>
</svg>
)}
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="relative size-4 transition-transform duration-(--motion-fast) group-hover/top:-translate-y-0.5 motion-reduce:transition-none"
>
<path d="M8 13V3.5M3.5 8 8 3.5 12.5 8" />
</svg>
</button>
</motion.div>
)}
</AnimatePresence>
</MotionConfig>
);
}
