# Hide on Scroll

A bar that slides out of the way as you read down, and comes back when you scroll up.

Source: [hide-on-scroll.tsx](https://github.com/jakejarvis/jarvis-ui/blob/main/src/components/ui/hide-on-scroll.tsx)

```tsx
import { InboxIcon, SquarePenIcon, SearchIcon, SettingsIcon } from "lucide-react";

import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { HideOnScroll } from "@/components/ui/hide-on-scroll";
import { ScrollArea } from "@/components/ui/scroll-area";

const messages = [
  { from: "Maya Chen", time: "9:41", subject: "Deploy to production is green" },
  { from: "Stripe", time: "9:12", subject: "Invoice INV-2041 paid" },
  { from: "Leo Park", time: "8:57", subject: "Re: SSO rollout checklist" },
  { from: "Priya Raman", time: "8:30", subject: "Updated Q3 forecast" },
  { from: "GitHub", time: "Yesterday", subject: "#1182 Retry webhooks with backoff" },
  { from: "Tom Weiss", time: "Yesterday", subject: "Office hours moved to Thursday" },
  { from: "Linear", time: "Mon", subject: "INC-88 resolved" },
  { from: "Ana Souza", time: "Mon", subject: "Two quotes for invoice translation" },
  { from: "Jordan Lee", time: "Sun", subject: "Joined the Platform team" },
  { from: "Maya Chen", time: "Sat", subject: "Storage migration dry run" },
  { from: "Vercel", time: "Fri", subject: "Usage alert: 80% of bandwidth" },
  { from: "Leo Park", time: "Fri", subject: "Audit log export design" },
];

const tabs = [
  { label: "Inbox", icon: InboxIcon },
  { label: "Search", icon: SearchIcon },
  { label: "Settings", icon: SettingsIcon },
];

export default function HideOnScrollInbox() {
  return (
    <Card className="h-[28rem] w-full max-w-xs gap-0 py-0">
      <ScrollArea scrollFade={false} className="min-h-0 flex-1 rounded-[inherit]">
        <HideOnScroll render={<header />} className="flex h-12 items-center justify-between px-4">
          <span className="font-medium">Inbox</span>
          <Button variant="ghost" size="icon" aria-label="New message">
            <SquarePenIcon />
          </Button>
        </HideOnScroll>
        <ul className="divide-y-[0.5px] divide-border-muted">
          {messages.map((message) => (
            <li key={`${message.from}-${message.subject}`} className="px-4 py-3">
              <div className="flex items-baseline justify-between gap-3">
                <span className="truncate font-medium">{message.from}</span>
                <span className="shrink-0 text-2xs text-faint">{message.time}</span>
              </div>
              <p className="truncate text-muted-foreground">{message.subject}</p>
            </li>
          ))}
        </ul>
        <HideOnScroll
          render={<nav aria-label="Main" />}
          edge="bottom"
          className="grid grid-cols-3 p-1"
        >
          {tabs.map(({ label, icon: Icon }) => (
            <Button key={label} variant="ghost" className="h-auto flex-col gap-0.5 py-1.5 text-2xs">
              <Icon />
              {label}
            </Button>
          ))}
        </HideOnScroll>
      </ScrollArea>
    </Card>
  );
}
```

## Installation

Add the registry, once per project:

```bash
npx shadcn@latest registry add @jarvis=https://ui.jarv.is/r/{name}.json
```

Then add the component:

```bash
npx shadcn@latest add @jarvis/hide-on-scroll
```

## Usage

```tsx
import { HideOnScroll } from "@/components/ui/hide-on-scroll";

export function Toolbar() {
  return (
    <HideOnScroll render={<header />} className="flex h-12 items-center px-4">
      Inbox
    </HideOnScroll>
  );
}
```

`HideOnScroll` sticks to an edge of the nearest container that scrolls, or of the page, and
slides away once you've read down `hideAfter` pixels without stopping. It takes a longer scroll
up, `revealAfter` pixels, or a quick flick to bring it back, so the small corrections people make
while reading don't drop it over the text. It's always shown near the top, and comes back at the
end of the content too, where there's nothing left to read.

A bar at the top is clear until content scrolls beneath it, and `data-at-top` marks that. Set
`edge="bottom"` for a tab bar, and render it last in the scrolling region. `data-hidden` marks it
while it's away. Like [Sticky Header](https://ui.jarv.is/components/sticky-header.md), it takes the corners of the edge
it sticks to from its parent.

Tabbing into a hidden bar brings it back without the page jumping, and keeps it shown while
keyboard focus is inside. Clicking a button in it doesn't, so a tab bar still hides once you read
on. Set `pinned` to keep it shown yourself, such as while a menu in it is open.

With reduced motion, it still hides, but without sliding.

## Props

| Part | Extends |
| --- | --- |
| `HideOnScroll` | `<div>` |

The tables leave out `className`, `style` and `render`. Parts built on Base UI take them as its [styling](https://base-ui.com/react/handbook/styling) and [composition](https://base-ui.com/react/handbook/composition) guides describe.

### HideOnScroll

| Prop              | Type                                   | Description                                                                                                        |
| ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `root?`           | `React.RefObject<HTMLElement \| null>` | The scroll container. Defaults to the nearest ancestor that scrolls, or the page.                                  |
| `hideAfter?`      | `number`                               | How far to scroll down without stopping before it hides, in pixels.                                                |
| `revealAfter?`    | `number`                               | How far to scroll up without stopping before it comes back, in pixels.                                             |
| `revealVelocity?` | `number`                               | A flick up faster than this, in pixels per millisecond, brings it back at once.                                    |
| `topZone?`        | `number`                               | It stays shown within this many pixels of the top. Defaults to the element's own height.                           |
| `revealAtEnd?`    | `boolean`                              | Brings it back at the end of the content, where there's nothing left to read.                                      |
| `pinned?`         | `boolean`                              | Keeps it shown, such as while a menu inside it is open.                                                            |
| `edge?`           | `"bottom" \| "top"`                    | The edge it sticks to. A bar at the top slides up out of view, and one at the bottom slides down. Default: `"top"` |
| `onHiddenChange?` | `((hidden: boolean) => void)`          | Called when it hides or comes back.                                                                                |

## useHideOnScroll

`HideOnScroll` decides when to hide with the `use-hide-on-scroll` hook, which comes with it. Use
it to hide another element, such as a [Sticky Header](https://ui.jarv.is/components/sticky-header.md). It takes the
same options and returns whether to hide the element and whether the container is scrolled to the
top.

```tsx
"use client";

import * as React from "react";

import { StickyHeader } from "@/components/ui/sticky-header";
import { useHideOnScroll } from "@/hooks/use-hide-on-scroll";

export function Header() {
  const header = React.useRef<HTMLElement>(null);
  const { hidden } = useHideOnScroll(header);

  return (
    <StickyHeader
      ref={header}
      data-hidden={hidden || undefined}
      className="h-12 transition-[translate,background-color,border-color] data-hidden:-translate-y-full"
    >
      Inbox
    </StickyHeader>
  );
}
```
