# Sticky Header

A header that stays at the top as you scroll, and gains an edge once content passes under it.

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

```tsx
"use client";

import { ChevronRightIcon } from "lucide-react";
import * as React from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { StickyHeader, StickyHeaderTitle } from "@/components/ui/sticky-header";

const milestones = [
  { title: "Usage-based billing in beta", owner: "Priya Raman", due: "Jul 28", status: "Shipped" },
  { title: "SSO on every paid plan", owner: "Leo Park", due: "Aug 15", status: "On track" },
  { title: "Audit log export to S3", owner: "Maya Chen", due: "Aug 29", status: "At risk" },
  { title: "Self-serve seat changes", owner: "Tom Weiss", due: "Sep 12", status: "On track" },
  { title: "Invoices in nine languages", owner: "Ana Souza", due: "Sep 26", status: "Not started" },
] as const;

const variants = {
  Shipped: "success",
  "On track": "info",
  "At risk": "warning",
  "Not started": "muted",
} as const;

export default function StickyHeaderDocument() {
  const heading = React.useRef<HTMLHeadingElement>(null);

  return (
    <Card className="h-96 w-full max-w-lg gap-0 py-0">
      <ScrollArea scrollFade={false} className="min-h-0 flex-1 rounded-[inherit]">
        <StickyHeader className="flex h-12 items-center gap-1.5 px-4">
          <span className="shrink-0 text-sm text-muted-foreground">Projects</span>
          <ChevronRightIcon className="size-3.5 shrink-0 text-icon-muted" />
          <StickyHeaderTitle watch={heading}>Q3 planning</StickyHeaderTitle>
          <Button size="sm" className="ms-auto">
            Share
          </Button>
        </StickyHeader>
        <div className="flex flex-col gap-4 px-4 pt-2 pb-6">
          <div>
            <h2 ref={heading} className="text-xl font-medium">
              Q3 planning
            </h2>
            <p className="text-sm text-faint">Updated 2h ago by Maya Chen</p>
          </div>
          <p className="text-muted-foreground">
            Billing is the loudest source of support tickets, and most come from teams that outgrew
            the plan they signed up on. This quarter, plan changes become self-serve and metered
            usage leaves beta.
          </p>
          <h3 className="font-medium">Milestones</h3>
          <ul className="flex flex-col divide-y-[0.5px] divide-border-muted">
            {milestones.map((milestone) => (
              <li key={milestone.title} className="flex items-center gap-3 py-2.5">
                <div className="min-w-0 flex-1">
                  <p className="truncate">{milestone.title}</p>
                  <p className="text-2xs text-faint">
                    {milestone.owner} · {milestone.due}
                  </p>
                </div>
                <Badge variant={variants[milestone.status]}>{milestone.status}</Badge>
              </li>
            ))}
          </ul>
          <h3 className="font-medium">Risks</h3>
          <p className="text-muted-foreground">
            The audit log export depends on the storage migration. If that slips past August 15,
            export goes to our own bucket first and S3 follows in Q4.
          </p>
          <p className="text-muted-foreground">
            Invoices in more languages need a vendor. There are two quotes, and we decide by the end
            of July.
          </p>
        </div>
      </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/sticky-header
```

## Usage

```tsx
"use client";

import * as React from "react";

import { StickyHeader, StickyHeaderTitle } from "@/components/ui/sticky-header";

export function Document() {
  const heading = React.useRef<HTMLHeadingElement>(null);

  return (
    <div className="h-dvh overflow-y-auto">
      <StickyHeader className="flex h-12 items-center px-4">
        <StickyHeaderTitle watch={heading}>Q3 planning</StickyHeaderTitle>
      </StickyHeader>
      <h1 ref={heading}>Q3 planning</h1>
    </div>
  );
}
```

`StickyHeader` sticks to the top of the nearest container that scrolls, or of the page, or of
the one given as `root`. It's clear at rest, so it sits on the page like any other header, and
gains a translucent surface and a hairline once the content after it scrolls beneath it, which
`data-scrolled` marks. Inside a [Card](https://ui.jarv.is/components/card.md) the surface takes the card's color. It
takes its top corners from its parent, since browsers don't round a blurred surface by its
container's corners. In a rounded container, give whatever lies between the two
`rounded-[inherit]`, as the example's scroll area has. In a [Scroll Area](https://ui.jarv.is/components/scroll-area.md),
turn off `scrollFade`, or the fade at its top edge covers the header.

It renders a `<header>`, which is the page's banner landmark when it isn't inside an `<article>`,
`<aside>`, `<main>`, `<nav>` or `<section>`. Give it `render={<div />}` when it heads a panel
that isn't the page's own.

`StickyHeaderTitle` echoes the page's own heading, given by `watch`. It rises into the header
once that heading has gone under it, and screen readers skip it, since they already reach the
heading itself.

The header sets the container's `scroll-padding-top` to its own height, so a link to an anchor,
or a field that takes focus, lands below it rather than under it. Pass `scrollPadding={false}` to
leave the container alone.

## Composition

- `StickyHeader`
  - `StickyHeaderTitle`

## Props

| Part | Extends |
| --- | --- |
| `StickyHeader` | `<header>` |
| `StickyHeaderTitle` | `<span>` |

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.

### StickyHeader

| Prop                | Type                                   | Description                                                                                                                                    |
| ------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `offset?`           | `number`                               | How far below the top of the scroll container it sticks, in pixels. Default: `0`                                                               |
| `root?`             | `React.RefObject<HTMLElement \| null>` | The scroll container. Defaults to the nearest ancestor that scrolls, or the page.                                                              |
| `scrollPadding?`    | `boolean`                              | Sets the scroll container's `scroll-padding-top` to the header's height, so links to anchors and focused fields land below it. Default: `true` |
| `onScrolledChange?` | `((scrolled: boolean) => void)`        | Called when content starts or stops passing beneath it.                                                                                        |

### StickyHeaderTitle

| Prop    | Type                                   | Description                                                                                  |
| ------- | -------------------------------------- | -------------------------------------------------------------------------------------------- |
| `watch` | `React.RefObject<HTMLElement \| null>` | The page's own heading. The title rises into the header once that heading has gone under it. |

## useScrolledPast

The title tells when the page's heading has gone under the header with the `use-scrolled-past`
hook, which comes with them. It returns `true` once the element given has
scrolled up past the top of its container, or past a line `offset` pixels below it, using an
`IntersectionObserver` rather than listening to every scroll.

```tsx
"use client";

import * as React from "react";

import { useScrolledPast } from "@/hooks/use-scrolled-past";

export function BackToTop() {
  const intro = React.useRef<HTMLDivElement>(null);
  const past = useScrolledPast(intro);

  return (
    <>
      <div ref={intro}>Introduction</div>
      {past && <a href="#top">Back to top</a>}
    </>
  );
}
```

It takes the scroll container as `root` when it isn't the nearest one, and stops watching while
`enabled` is `false`.
