# Sticky

**When to use:** Pin a piece of content to the top of the viewport once the page scrolls past it — section headers, toolbars, filter bars, table headers. The content sticks while its original position is above the fold and releases the moment that position scrolls back into view. A placeholder preserves the original space, so nothing below jumps when it detaches.

**Keywords:** sticky header, pinned toolbar, scroll container, app shell, scrollable panel, stop at the end, confine, within a section, unpin, release

**Import:** `import { Sticky } from '@ahrowe/ui'`

```tsx
import { Sticky } from '@ahrowe/ui';

// Basic — sticks to the very top
<Sticky>
  <SectionHeader>Pricing</SectionHeader>
</Sticky>

// Stick below a fixed app bar (64px tall)
<Sticky offsetTop={64}>
  <FilterBar />
</Sticky>

// Stick below a header that isn't a Sticky itself — offset tracks its live height
const headerRef = useRef<HTMLElement>(null);
<header ref={headerRef}>...</header>
<Sticky offsetElement={headerRef}>
  <FilterBar />
</Sticky>

// App-shell layout: a separately-scrollable content pane below a header that's
// outside that scroll container. Stick to the pane's own top edge instead of the
// browser viewport's, so it naturally stops below the header with no offset needed
<div style={{ height: '100vh', overflowY: 'auto' }}>
  <Sticky stickToScrollParent offsetTop={0}>
    <FilterBar />
  </Sticky>
  <Content />
</div>

// Let it go once the section it labels ends, instead of pinning down the page.
// A toolbar and a header pointed at the same boundary leave together.
const section = useRef<HTMLDivElement>(null);
<div ref={section}>
  <Sticky boundary={section} boundaryInset={120}>
    <Toolbar />
  </Sticky>
  <Sticky boundary={section} boundaryInset={120}>
    <TableHeader />
  </Sticky>
  <Rows />
</div>

// React to the stuck state (e.g. add a shadow)
<Sticky onStuckChange={(stuck) => setElevated(stuck)}>
  <Toolbar />
</Sticky>

// Temporarily turn it off
<Sticky disabled={isEditing}>
  <Header />
</Sticky>

// Two Stickies that overlap horizontally automatically stack — the second
// pins below the first's height instead of on top of it
<Sticky offsetTop={0}>
  <TopBar />
</Sticky>
<Sticky offsetTop={0}>
  <FilterBar />
</Sticky>
```

**How it works:** while stuck, the content switches to `position: fixed` (measured to keep the same width and horizontal position) and a placeholder of the same height takes its place in the flow. It tracks scroll on the window *and* any ancestor scroll container, so it works inside scrollable panels too. By default it pins to the top of the browser viewport (offset by `offsetTop`), regardless of any scroll container it's nested in — set `stickToScrollParent` to pin to that container's own top edge instead (see below).

**`stickToScrollParent`:** by default `Sticky` always pins relative to the browser viewport, which can overlap other fixed/pinned chrome (e.g. an app header) that sits outside the element's own scroll container. Set `stickToScrollParent` to pin relative to the nearest ancestor with its own scrollbar (`overflow-y: auto`/`scroll`/`overlay`) instead — found once on mount by walking up from the root. Falls back to the viewport if no scrollable ancestor is found. Combine with `offsetTop` for extra spacing from that container's top (e.g. its own internal header).

**`boundary`:** without it the content stays pinned for as long as the page scrolls, which is right for an app bar and wrong for anything that labels a section: a table header outlives its table and ends up captioning whatever scrolls under it next. Point `boundary` at the element the content belongs to, and once that element's bottom edge reaches the pinned content, the content is pushed up with it and leaves the screen together with it. This is what native `position: sticky` gets from its containing block, which a `position: fixed` implementation has no equivalent of, and it works whether the pin is relative to the viewport or to a `stickToScrollParent` container.

**A group confined to one boundary leaves as one block.** Several `Sticky` elements stacked on top of each other and pointed at the same boundary are all lifted by the same amount, the largest any of them needs, so they keep their spacing and the lowest one's bottom edge lands on the boundary. Letting each clamp its own bottom edge instead would pile them all onto that edge and slide the lower ones up through the ones above.

**`boundaryInset`** decides how much of the boundary's lower end stays uncovered while the content leaves. At `0` the content rests on the very bottom edge, so the last stretch of the boundary, as tall as the pinned content itself, is hidden underneath it for the whole exit: a table header ends up floating over nothing with its final rows behind it. Set it to how much you want to stay in sight, which for a table header is a few rows, and the content lifts off that much earlier and keeps that gap all the way out.

**Stacking:** every mounted `Sticky` coordinates automatically, no extra markup needed. When more than one is stuck at the same time, a later one (in DOM order) that horizontally overlaps an earlier stuck one pins below it — `offsetTop` plus the overlapping ones' heights — instead of both landing on the same spot. Stickies that don't overlap horizontally (e.g. two half-width ones side by side under a full-width one) land at the same `top`, next to each other, rather than pushing each other down.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `children` | `ReactNode` | The content to make sticky |
| `offsetTop` | `number` | Distance in px from the top of the viewport the content sticks to (default `0`). Ignored if `offsetElement` is set |
| `offsetElement` | `RefObject<HTMLElement \| null>` | Element to stick below (e.g. a fixed/pinned header outside this library) — its live height is used as the offset instead of a fixed `offsetTop` |
| `stickToScrollParent` | `boolean` | Stick to the top of the nearest scrollable ancestor instead of the browser viewport (default `false`). Falls back to the viewport if no scrollable ancestor is found |
| `boundary` | `RefObject<HTMLElement \| null>` | Element the content is confined to. Once its bottom edge reaches the pinned content, the content rides it up and off screen instead of staying pinned over what follows. Several Stickies sharing one leave together |
| `boundaryInset` | `number` | How much of the boundary's lower end stays uncovered while the content leaves, in px (default `0`). At `0` the last stretch, as tall as the content, is hidden under it for the whole exit. Ignored without `boundary` |
| `zIndex` | `number` | z-index applied while stuck (default `20`) |
| `disabled` | `boolean` | Disable sticking — content stays in normal flow |
| `onStuckChange` | `(isStuck: boolean) => void` | Fired whenever the stuck state flips |

**Slots:** `root` `placeholder` `content`
