# 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"`
- **Page:** <https://ui.booleanpress.com/components/scroll-top> · @booleanpress/ui 0.2.0

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

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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 `scrollToTop` string; pass `aria-label` to 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's `body`. 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 a `role` and a name) so it can be scrolled from the keyboard; the button does not replace that.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter or Space | 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.
