Mischief

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.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/scroll-to-top-button
import { ScrollToTopButton } from "mischief-ui/scroll-to-top-button"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/scroll-to-top-button/scroll-to-top-button.tsx
"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?: string

Usage

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 })
  }}
/>
The same hook works for a virtualised list, or any scroller you own.

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.