Scroll to top
A corner button that appears after scrolling and brings the page back to the top.
npx shadcn@latest add https://ui.goodtabs.co/r/scroll-to-top.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/scroll-to-top
Changelog
- v2.4 adds keyboard shortcuts to every list view.
- v2.3 moves the sidebar search to a command menu.
- v2.2 lets you save resources without an account.
- v2.1 introduces the New this week view.
- v2.0 is the Linear-inspired redesign, dark first.
- v1.9 adds pricing badges to every card.
- v1.8 brings sub-category filters to each section.
- v1.7 makes every page static for faster loads.
- v1.6 adds the email waitlist.
- v1.5 is the first public release.
Watches a scrolling element through container. Scroll the list to make it appear.
Window mode
Window mode
Without a container the button watches the window. Scroll this page past 400px and it appears fixed in the bottom right corner.
The default: fixed to the viewport, watching the page scroll position.
Small
01 request handled in 12ms
02 request handled in 15ms
03 request handled in 18ms
04 request handled in 21ms
05 request handled in 24ms
06 request handled in 27ms
07 request handled in 30ms
08 request handled in 33ms
09 request handled in 36ms
10 request handled in 39ms
11 request handled in 42ms
12 request handled in 45ms
13 request handled in 48ms
14 request handled in 51ms
15 request handled in 54ms
16 request handled in 57ms
17 request handled in 60ms
18 request handled in 63ms
19 request handled in 66ms
20 request handled in 69ms
21 request handled in 72ms
22 request handled in 75ms
23 request handled in 78ms
24 request handled in 81ms
The icon-sm size with a tighter offset, for logs and narrow panels.
Usage
import { ScrollToTop } from "@/components/ui/scroll-to-top"
<ScrollToTop />
<ScrollToTop container={listRef} threshold={200} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| threshold | number | 400 | How far down, in px, before the button appears. |
| label | string | "Back to top" | Accessible name of the icon button. |
| container | RefObject<HTMLElement | null> | A scrolling element to watch and scroll instead of the window. | |
| position | "fixed" | "absolute" | "fixed", or "absolute" with container | fixed sits on the viewport, absolute in the nearest positioned parent. |
| variant | Button variant | "outline" | Any shadcn button variant. |
| size | Button size | "icon" | icon is 32px, icon-sm is 28px. |
Source
scroll-to-top/scroll-to-top.tsx
"use client";
import * as React from "react";
import { ArrowUpIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ScrollToTopProps = Omit<React.ComponentProps<typeof Button>, "onClick" | "children"> & {
/** How far down, in px, before the button appears. */
threshold?: number;
/** Accessible name. */
label?: string;
/** A scrolling element to watch instead of the window. */
container?: React.RefObject<HTMLElement | null>;
/** fixed sits on the viewport, absolute sits in the nearest positioned parent. Defaults to absolute when container is set. */
position?: "fixed" | "absolute";
};
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/**
* An icon button in the bottom right corner that appears after scrolling past a threshold
* and scrolls back to the top. Watches the window, or a scrolling element through container.
*/
export function ScrollToTop({
threshold = 400,
label = "Back to top",
container,
position = container ? "absolute" : "fixed",
variant = "outline",
size = "icon",
className,
...props
}: ScrollToTopProps) {
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const element = container?.current;
const target: HTMLElement | Window = element ?? window;
const read = () => (element ? element.scrollTop : window.scrollY) > threshold;
const onScroll = () => setVisible(read());
target.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => target.removeEventListener("scroll", onScroll);
}, [container, threshold]);
const scrollUp = () => {
const target: HTMLElement | Window = container?.current ?? window;
target.scrollTo({ top: 0, behavior: reducedMotion() ? "auto" : "smooth" });
};
return (
<Button
type="button"
variant={variant}
size={size}
aria-label={label}
inert={!visible}
data-state={visible ? "visible" : "hidden"}
onClick={scrollUp}
className={cn(
"right-4 bottom-4 z-40 shadow-md transition-[opacity,transform,background-color,color] duration-150 ease-out motion-reduce:transform-none",
position === "fixed" ? "fixed" : "absolute",
visible ? "opacity-100" : "pointer-events-none translate-y-1 opacity-0",
className,
)}
{...props}
>
<ArrowUpIcon aria-hidden="true" />
</Button>
);
}
For AI agents
Hand your agent this page, the markdown at /components/scroll-to-top/prompt.md, or the Copy AI prompt button above.
When to use
- Long pages such as docs, changelogs and feeds where the header scrolls away
- Scrolling panels and logs that grow past a few screens
- Anywhere a person would otherwise drag the scrollbar back up
When not to use
- Short pages that fit in one or two screens; the button would never appear
- Pages with a sticky header that already holds a link to the top
- Over content that needs the bottom right corner, such as a chat composer
Agent notes
- Hidden until the scroll position passes threshold; while hidden it is inert, so it is skipped by Tab and screen readers
- With container it switches to absolute positioning: put it as a sibling of the scroll box inside a relative wrapper, not inside the box
- Fades in with opacity and a 4px rise over 150ms; under prefers-reduced-motion it does not move and scrolls with behavior auto
- Override the offset with className, for example right-3 bottom-3