Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsButtons & actions

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.json

Props

PropTypeDefaultDescription
targetRefObject<HTMLElement | null>— (window)The scroll container. With one, the button is absolute; put it in a relative wrapper beside the scroller.
thresholdnumber240Show once scrolled this many px.
position"bottom-right" | "bottom-left" | "bottom-center""bottom-right"Where it floats.
showProgressbooleantrueDraw the ring that fills with scroll position.
labelstring"Back to top"Accessible name and title.
onClickMouseEventHandler—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>
  );
}
Pebble

Small details.
Better interfaces.

React components to explore, make your own, and build on.

Library

  • Components
  • Installation
  • Pricing

Resources

  • For agents
  • About
  • Support

Legal

  • License
  • Terms
  • Privacy
  • Final sale

Built by Pebble

A product of Praxys Solutions LLC ↗

Pebble Pro is live

$49 one time, lifetime updates ↗