Affix / Sticky
FreePin an element while the page scrolls, with the placeholder that stops the jump. Optional container boundary so a pinned sidebar parks at the end of its section instead of floating over the footer.
import { SvAffixComponent } from '@styloviz/affix';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Bounded by a container
<div #section class="relative"> <sv-affix #affix [offsetTop]="16" [container]="section" (window:scroll)="affix.onWindowScroll($event)"> <aside>On this page…</aside> </sv-affix> </div>
Disabled below a breakpoint
<sv-affix [disabled]="isMobile()">…</sv-affix>
React to the mode
<sv-affix (affixChange)="mode = $event">…</sv-affix>
Window scroll root
The affix does not listen to scroll itself: the host names the scroll root. For the page, forward the window scroll event.
<sv-affix #affix [offsetTop]="16" (window:scroll)="affix.onWindowScroll($event)">…</sv-affix>
Shrink-on-scroll header
The header compacts once pinned; holdRestingHeight keeps the placeholder at its in-flow height so the page below never moves while it animates.
<sv-affix [holdRestingHeight]="true" (affixChange)="compact.set($event !== 'static')"> <header [class.h-20]="!compact()" [class.h-12]="compact()" class="transition-[height]">…</header> </sv-affix>
Detect a transformed ancestor
A transform, filter, perspective, backdrop-filter, will-change, contain, content-visibility, preserve-3d or container-type on any ancestor makes it the containing block for position: fixed. The affix reports it (and warns once in dev mode) instead of silently pinning to the wrong box.
<sv-affix (containingBlockChange)="cb = $event">…</sv-affix> <!-- cb?.element is the ancestor to fix; the placeholder also carries data-sv-containing-block="ancestor" -->
When to use
- Keeping a toolbar, filter bar or table of contents in view while a long page scrolls.
- You need the pinned element to stop at the end of a section rather than travel the whole page.
- If `position: sticky` alone does what you need, use it — this exists for the cases it cannot express (a container boundary, a measured placeholder, a mode you want to react to).
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| offsetTop | number | 0 | Gap between the viewport top and the pinned element, in px. |
| container | HTMLElement | null | null | Stop the element at the bottom of this container. WITHOUT A BOUNDARY, A PINNED SIDEBAR FLOATS OVER THE FOOTER — and, being fixed, over whatever the reader scrolled down to look at. |
| disabled | boolean | false | Disable pinning entirely, e.g. below a breakpoint. |
| holdRestingHeight | boolean | false | Keep the placeholder at the height the content had IN FLOW while it is pinned, instead of following the content's live height. FOR SHRINK-ON-SCROLL HEADERS. A header that compacts from 80px to 48px once pinned would otherwise pull everything below it up by 32px mid-scroll. With this on, the page below never moves while the header animates; the pin threshold is measured from the placeholder either way, so a changing header height never moves the threshold and cannot re-pin it. |
| floatingAnchor | SvFloatingAnchor | null | null | Join the shared floating layer while PINNED, as an occupied region at this anchor — usually `'top'` for a full-width bar, or a corner for a pinned card. Floating controls at that edge then stack clear of it, and regions ranked before it (a host's top banner) push it down through `collisionOffset`, which is added to `offsetTop`. `null` registers nothing and leaves the offset at 0 — the behaviour before this input existed. |
| floatingOrder | number | SV_FLOATING_ORDER.affix | Rank in the floating layer; lower sits nearer the edge. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| affixChange | AffixMode | Emitted whenever the mode changes. |
| containingBlockChange | SvAffixContainingBlock | null | Emitted when an ancestor turns out to be the containing block for `position: fixed` — a `transform`, `filter`, `perspective`, `backdrop-filter`, `will-change`, `contain`, `content-visibility`, `transform-style: preserve-3d` or `container-type` on any ancestor — so the pinned element is positioned against that ancestor and scrolls away with it. Checked each time the element pins; `null` once the viewport is the containing block again. |
Customization & theming
- Gap from the viewport top via `offsetTop`.
- Stop at the bottom of any element with `container`.
- Turn pinning off entirely with `disabled`, e.g. below a breakpoint.
- `affixChange` reports the mode — static, fixed or docked — and only when it actually changes.
- Measurement (X-4): the pinned content's height is observed automatically (`svElementSize`, at most once per frame), so content that grows or shrinks while pinned — or was hidden at init and is revealed — keeps the placeholder in step and re-reads the anchor with no host code. The SCROLL position is manual: the host calls `measure(scrollTop)` from its scroll root (or binds `onWindowScroll`). Until the first `measure()`, and while the content has no box, the element stays in flow.
- Shrink-on-scroll strategy: the pin threshold is measured from the placeholder, never the content, so a header that changes height cannot move its own threshold or re-pin; height changes arrive through the observer only, never through a per-scroll read. By default the placeholder follows the live height; `holdRestingHeight` freezes it at the in-flow height while pinned.
- Scroll roots and containing blocks (X-9): `position: fixed` pins to the VIEWPORT, and `offsetTop` is measured from the viewport top. Supported roots: the document (forward `window:scroll` to `onWindowScroll`, which reads the event's own `defaultView`, so it also works inside an iframe for that iframe's window) and a scroll container that starts at the viewport top, such as an app-shell main region (call `measure(el.scrollTop)`). For a scroll panel lower on the page, add its top to `offsetTop`. Only the root you feed to `measure()` is tracked; nested scrollers inside it are not. Any ancestor with `transform`, individual `translate`/`rotate`/`scale`, `perspective`, `filter`, `backdrop-filter`, a `will-change` naming one of those, `contain: paint|layout|strict|content`, `content-visibility: auto|hidden`, `transform-style: preserve-3d` or a `container-type` becomes the containing block (a `filter` on `<html>` itself does not): the element is then placed against that box and scrolls with it. The affix checks this each time it pins, exposes it as the `containingBlock` signal and `containingBlockChange` output, marks the placeholder `data-sv-containing-block="ancestor"`, and warns once in dev mode — it cannot fix it, because the property is the host's. `container` must be an element whose bottom edge is the parking line; the docked element is absolutely positioned inside the placeholder. There are no built-in breakpoints: bind `disabled` to your own media query.
- Variant axes (X-8): there is no appearance input — the look is the projected content's. `offsetTop` and `container` are layout; `disabled`, `holdRestingHeight` and the floating-layer inputs are behavioural modes. Every combination is valid; `disabled` wins over all of them and also removes the placeholder.
- Browser limits (X-11): without `ResizeObserver` the height is re-read on every `measure()` instead of observed, so a resize with no scroll is picked up on the next scroll. `container-type` and individual transform properties are only detected on engines that report them in computed style. Pinning is `position: fixed`, so it does not follow a pinch-zoomed visual viewport on mobile.
- Floating layer (`floatingAnchor`, `floatingOrder`, `collisionOffset`): while pinned, the block registers as an occupied region (usually `'top'`), so floating controls at that edge stack clear of it, and a host banner registered before it pushes it down — `collisionOffset` is added to `offsetTop` and exposed for companions. `null` (default) registers nothing.
Best practices
- Do: Pass a `container` for anything in a column, so it parks rather than floating over the next section.
- Do: Disable pinning at small breakpoints — a pinned block on a phone eats the screen.
- Tip: Set `offsetTop` to the height of any fixed header so the pinned element does not slide underneath it.
- Don't: Don't put the affix inside an ancestor with `transform`, `filter`, `backdrop-filter`, `perspective`, `will-change: transform`, `contain: paint` or a `container-type` — that ancestor becomes the containing block and the "fixed" element scrolls away with it. Listen to `containingBlockChange` (or watch for the dev-mode warning) to find it.
- Do: Use `holdRestingHeight` for a header that shrinks once pinned, so the content below does not jump up mid-scroll.
Accessibility
- A placeholder of the measured height holds the element's space, so nothing below it moves when it pins — the jump that makes a page unreadable at exactly the scroll position someone is reading.
- The pinned state is described in a visually-hidden read-out, since a purely visual change of position tells assistive tech nothing.
- The scroll position is read from the event target rather than a global, so the component works inside an iframe or a server-rendered page.
Feedback on Affix / Sticky
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.