08 / Wayfinding
Scroll to Top Button
A floating way back after someone has moved down a long page or scroll area. It stays hidden near the top.
A short page about long pages
Give every section a job.
01
Start with the purpose
Say what the page helps someone do.
02
Keep the path clear
One primary action is usually enough.
03
Show the real thing
A working example beats another promise.
04
Answer the practical bits
Cover setup, ownership, and access.
05
Finish usefully
End with the next step and a way back.
"use client"
import * as React from "react"
import { ScrollToTopButton } from "mischief-ui/scroll-to-top-button"
const sections = [
["Start with the purpose", "Say what the page helps someone do."],
["Keep the path clear", "One primary action is usually enough."],
["Show the real thing", "A working example beats another promise."],
["Answer the practical bits", "Cover setup, ownership, and access."],
["Finish usefully", "End with the next step and a way back."],
] as const
export function ScrollToTopDemo() {
const containerRef = React.useRef<HTMLDivElement>(null)
return (
<div className="bg-background border-border relative h-80 w-full max-w-[32rem] overflow-hidden rounded-[1.25rem] border shadow-sm">
<div
className="h-full overflow-y-auto px-5 py-6 md:px-8"
ref={containerRef}
>
<p className="text-muted-foreground text-sm font-semibold">
A short page about long pages
</p>
<h3 className="mt-2 text-2xl font-semibold">
Give every section a job.
</h3>
<div className="mt-8 grid gap-10 pb-8">
{sections.map(([title, description], index) => (
<section className="border-border border-t pt-5" key={title}>
<p className="text-muted-foreground text-xs font-semibold tracking-[0.08em] uppercase">
{String(index + 1).padStart(2, "0")}
</p>
<h4 className="mt-2 font-semibold">{title}</h4>
<p className="text-muted-foreground mt-1 text-sm leading-relaxed">
{description}
</p>
</section>
))}
</div>
</div>
<ScrollToTopButton
className="absolute right-4 bottom-4"
containerRef={containerRef}
showAfter={120}
/>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-to-top-buttonimport { ScrollToTopButton } from "mischief-ui/scroll-to-top-button"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { ArrowUpToLine } from "lucide-react"import { cn } from "@/lib/utils" export interface ScrollToTopButtonProps extends Omit< React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> { containerRef?: React.RefObject<HTMLElement | null> container?: HTMLElement | null behavior?: ScrollBehavior label?: stringUsage
export function LongPage() {
return (
<>
<main>{/* Long page content */}</main>
<ScrollToTopButton />
</>
)
}When the page has its own scroller
Smooth-scroll libraries such as Lenis take the page's scrolling away from the browser, and a native scrollTo either fights them or does nothing. Claim the click and do it yourself: onClick runs first, and calling preventDefault stops the built-in scroll.
<ScrollToTopButton
showAfter={720}
onClick={(event) => {
event.preventDefault()
lenis.scrollTo(0, { immediate: prefersReducedMotion })
}}
/>When it appears
The button stays out of the way until the reader is showAfter pixels down, so a short page never grows a control for a journey nobody took. It fades in and out rather than appearing, and while hidden it is completely inert: not clickable, not focusable, and not announced.
Like the floating index, it watches the window unless you hand it a container, which is what you want when the thing that scrolls is a panel rather than the page.
<ScrollToTopButton
containerRef={panel}
showAfter={600}
behavior="smooth"
/>Smooth, and when not to be
behavior is passed straight to the browser, so "smooth" animates and "auto" jumps. A long page smooth-scrolled from the bottom can take an unpleasantly long time to arrive; if your pages are long, "auto" is the kinder default.
Browsers already honour a reduced-motion preference for smooth scrolling, so you do not need to switch the value yourself for that reason.
API
containerHTMLElement | nullScrolls a controlled container that can change after mount.containerRefRefObject<HTMLElement>Scrolls a container instead of the page.showAfternumberScroll distance before the button appears. Defaults to 320.behavior"auto" | "instant" | "smooth"The requested scroll behavior. Defaults to smooth.iconReactNodeReplaces the default arrow. Keeps the hover lift.labelstringThe accessible name and title.classNamestringClasses for placement and appearance....buttonPropsButtonHTMLAttributesNative button attributes.Accessibility
The control is a named native button with a 48px target. While there is nothing to scroll back from it is hidden from assistive technology and taken out of the tab order, so it is never a stop on the way through the page. Scrolling is immediate when reduced motion is requested, and the fade stops with it.