<!-- Protocore Design System — Affix -->
# Affix

- **Category:** Utilities (`utilities`)
- **Slug:** `utilities/affix`
- **Status:** stable
- **Platforms:** web
- **Import:** `import { Affix } from "@protocore/pds";`
- **Docs:** https://pds.protocore.io/components/utilities/affix

> Pins its children to the viewport with fixed positioning in a portal, with an optional scroll threshold to reveal it.

## When to use it

Use `Affix` for viewport-anchored UI that must float above the page and survive scrolling — a back-to-top button, a floating action button, a persistent cookie or status notice. It portals to the document root so it escapes any `overflow` or `transform` ancestor that would otherwise clip a fixed element, and sits at the sticky z-index. The optional `scrollThreshold` reveals it only once the reader has scrolled past a point. For content that should scroll *with* a column but stick within it, use native `position: sticky` instead — `Affix` is specifically for viewport-fixed elements.

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `className` | `string` | no | — | — |
| `position` | `AffixPosition` | no | `{ bottom: 20, right: 20 }` | Viewport offsets. |
| `scrollThreshold` | `number` | no | — | Only show the affix once the window has scrolled at least this many pixels vertically. Omit to always show it. |
| `style` | `CSSProperties` | no | — | — |
| `target` | `HTMLElement \| null` | no | `document.body` | Portal target. |
| `zIndex` | `string \| number` | no | `var(--pds-z-sticky)` | Override the stacking order. |

## Examples

### Basics

`Affix` renders its children `position: fixed` relative to the viewport, in a portal at the document root. Set `position` with viewport offsets — `left`/`right` resolve to logical insets, so it mirrors correctly under RTL.

```tsx
import { useState } from "react";
import { Affix, Button, Panel, Text } from "@protocore/pds";

export default function AffixBasics() {
  const [shown, setShown] = useState(false);
  return (
    <Panel>
      <Text size="sm" color="secondary">
        Toggle a viewport-pinned notice. It renders fixed to the bottom-end corner of the window, in
        a portal, above page content.
      </Text>
      <Button
        size="sm"
        variant="secondary"
        onClick={() => setShown((s) => !s)}
        style={{ marginTop: "var(--pds-space-3)" }}
      >
        {shown ? "Dismiss notice" : "Show notice"}
      </Button>
      {shown && (
        <Affix position={{ bottom: 24, right: 24 }}>
          <Panel style={{ maxWidth: 260 }}>
            <Text size="sm">Mainnet endpoint is live across 42 chains.</Text>
          </Panel>
        </Affix>
      )}
    </Panel>
  );
}
```

### Back to top

Pass `scrollThreshold` to reveal the affix only after the page has scrolled a given distance — the classic back-to-top button. Below the threshold it renders nothing.

```tsx
import { Affix, IconButton, Stack, Text } from "@protocore/pds";
import { ArrowUp } from "lucide-react";

export default function AffixBackToTop() {
  return (
    <Stack gap={3}>
      <Text size="sm" color="secondary">
        Scroll the page down: once you pass 300px the back-to-top control fades in, pinned to the
        bottom-end corner of the viewport. It portals out of this demo frame.
      </Text>
      <Affix scrollThreshold={300} position={{ bottom: 24, right: 24 }}>
        <IconButton
          aria-label="Back to top"
          variant="secondary"
          onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
        >
          <ArrowUp size={18} aria-hidden />
        </IconButton>
      </Affix>
    </Stack>
  );
}
```

## Do & don't

**Do**

- Use Affix for viewport-fixed floating UI (back-to-top, FAB, persistent notice).
- Set a scrollThreshold for reveal-on-scroll affordances so they don't clutter the top of the page.
- Give the affixed control a clear aria-label — it's detached from surrounding context.

**Don't**

- Use Affix for content that should stick within a scrolling column — that's position: sticky.
- Stack many affixes at the same corner; they'll overlap and trap focus.
- Assume left/right are physical — they are logical insets and flip under RTL.

## Accessibility

**Notes**

- Affix is a portaled container; the interactive control inside it must be a real button/link with its own accessible name.
- Because it is visually detached, give affixed controls an explicit label (e.g. aria-label="Back to top").
- Its appearance fades in with a short animation that honors prefers-reduced-motion.

## Related

`scroll-area`, `pds-provider`

---

© Protocore. All rights reserved. Use of the Protocore Design System requires prior written authorization from Protocore (contact@protocore.io). These machine-readable materials must not be ingested into ML-training datasets or derivative design systems. See https://pds.protocore.io/legal/
