ComponentsMisc
Scroll top
A round button that appears once the page, or a box, has scrolled down, and takes it back to the top.
Import
import { ScrollTop } from "@booleanpress/ui/scroll-top"Usage
Add ScrollTop once to a long page, or inside a scrolling box. It renders nothing until the target has scrolled past threshold px, then fades in.
import { ScrollTop } from "@booleanpress/ui/scroll-top"
export function DeliveryLog() {
return (
<main>
{/* a long list */}
<ScrollTop />
</main>
)
}target="window" (default) watches the page and shows the button fixed 20 px from the window's bottom and end corner. target="parent" watches the element it is placed in: make that element the scrolling box, put ScrollTop last in it, and the button sticks to the box's bottom end corner. threshold is 400 by default. The scroll is smooth (behavior="auto" jumps), and always jumps under reduced motion. The button is named by the provider's scrollToTop string; icon replaces the chevron, and it takes Button's variant and severity.
The Window example is shown in a frame of its own, 360 px tall, so scrolling the frame's page shows the button in the frame's corner without covering this page.
Examples
Window
Scrolling the page (here, the framed page) past 200 px shows the button in the corner.
import { ScrollTop } from "@booleanpress/ui/scroll-top"
// Twenty fixed entries, enough to scroll the frame this example is shown in.
const ENTRIES = Array.from({ length: 20 }, (_, index) => ({
id: 4820 - index,
to: `customer${index + 1}@example.com`,
status: index % 7 === 3 ? "Failed" : "Delivered",
}))
export default function ScrollTopWindow() {
return (
<main className="mx-auto w-full max-w-xl p-6 text-sm">
<h4 className="mb-2 font-semibold">Delivery log</h4>
<p className="mb-4 text-muted-foreground">Scroll down: the button appears in the corner after 200 px.</p>
<ul className="flex flex-col divide-y rounded-md border">
{ENTRIES.map((entry) => (
<li key={entry.id} className="flex justify-between px-4 py-3">
<span>
#{entry.id} to {entry.to}
</span>
<span className="text-muted-foreground">{entry.status}</span>
</li>
))}
</ul>
<ScrollTop threshold={200} />
</main>
)
}Element
target="parent" inside a scrolling box, after 100 px.
import { ScrollTop } from "@booleanpress/ui/scroll-top"
const PARAGRAPHS = [
"Each email the site sends is written to the delivery log: the recipient, the subject, the status and the mail server's reply.",
"Failed emails are retried after 5 minutes, 30 minutes and 2 hours. Each attempt gets its own line.",
"Entries are kept for 30 days, then deleted. Export the log as CSV to keep it longer.",
"Filter by status, by connection or by date. A filter stays in the address, so a filtered view can be shared.",
"Open an entry to see the message's headers and body, and to resend it through any connection.",
"Bounces and complaints reported by the provider are matched to their entry and shown beside it.",
]
export default function ScrollTopElement() {
return (
// The scrolling box takes focus, so it can be scrolled from the keyboard; the button scrolls it and returns focus to it.
<div
tabIndex={0}
role="region"
aria-label="About the delivery log"
className="h-60 w-full max-w-sm overflow-y-auto rounded-md border p-4 text-sm outline-none focus-visible:border-ring"
>
{PARAGRAPHS.map((text) => (
<p key={text} className="mb-4">
{text}
</p>
))}
<ScrollTop target="parent" threshold={100} className="size-9 [&_svg:not([class*='size-'])]:size-3.5" />
</div>
)
}Custom icon
icon and a secondary variant, inside a scrolling box.
import { ArrowUpToLineIcon } from "lucide-react"
import { ScrollTop } from "@booleanpress/ui/scroll-top"
const PARAGRAPHS = [
"An API key belongs to one organisation and carries scopes: send, read logs, or manage connections.",
"A key is shown once, when it is created. Store it in your secrets manager, never in the code.",
"Rotate a key by creating a new one, moving your sites to it, then revoking the old one.",
"A revoked key stops working at once. Requests with it get a 401 reply and are logged.",
"Keys that have not been used for 90 days are flagged on the API keys page.",
"Every request names its key in the audit log, with the address it came from.",
]
export default function ScrollTopCustomIcon() {
return (
<div
tabIndex={0}
role="region"
aria-label="About API keys"
className="h-60 w-full max-w-sm overflow-y-auto rounded-md border p-4 text-sm outline-none focus-visible:border-ring"
>
{PARAGRAPHS.map((text) => (
<p key={text} className="mb-4">
{text}
</p>
))}
<ScrollTop
target="parent"
threshold={100}
variant="secondary"
icon={<ArrowUpToLineIcon />}
className="size-9 [&_svg:not([class*='size-'])]:size-4"
/>
</div>
)
}Accessibility
- Semantics
- A native
button, rendered only while the target is scrolled past the threshold. - Labels
- Named by the provider's
scrollToTopstring; passaria-labelto name it yourself. - Focus
- While shown, the button is a tab stop where it sits in the source: put it last. It leaves once the target is back at the top, and focus moves to the top of what it scrolled: the scrolling box (one that cannot take focus takes it for the moment, with
tabindex="-1"), or the page'sbody. The next Tab then starts from the top, not from where the button was. - Known limits
- The button covers whatever is under the corner; leave room there, or set its place with
className. - A scrolling box should be focusable (
tabIndex={0}, with aroleand a name) so it can be scrolled from the keyboard; the button does not replace that.
- The button covers whatever is under the corner; leave room there, or set its place with
Keyboard
| Key | Behaviour |
|---|---|
| EnterorSpace | Scrolls the target back to the top. |
API
ScrollTop
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
behavior | "auto" | "instant" | "smooth" | smooth | smooth (default) or auto (a jump). With reduced motion the scroll always jumps. |
icon | ReactNode | Replaces the chevron. | |
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | ||
rounded | boolean | null | ||
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | Button's severity colour. | |
target | "parent" | "window" | window | What it watches and scrolls: window (default), shown fixed in the window's bottom corner; or parent, the
scrolling box it is placed in, at the end of the box's content, where it sticks to the box's bottom corner. |
threshold | number | 400 | How far, in px, the target must scroll down before the button appears. 400 by default. |
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | Button's look; default (the primary fill) by default. |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="scroll-top-anchor" (ScrollTop), and data-state, data-target.
Provider strings: scrollToTop (BooleanUIProvider's strings).
Theming
A 48 px --primary circle with a 24 px icon, 20 px from the bottom and end edges. It fades in at the overlay entrance duration and out at the exit duration, as the theme's motion sets.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|