Affix / Sticky

Free

Pin 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.

</>sv-affix affixstickypinscrolllayout
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 demo

More examples

Bounded by a container

example.htmlAngular
<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

example.htmlAngular
<sv-affix [disabled]="isMobile()">…</sv-affix>

React to the mode

example.htmlAngular
<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.

example.htmlAngular
<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.

example.htmlAngular
<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.

example.htmlAngular
<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

NameTypeDefaultDescription
offsetTopnumber0Gap between the viewport top and the pinned element, in px.
containerHTMLElement | nullnullStop 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.
disabledbooleanfalseDisable pinning entirely, e.g. below a breakpoint.
holdRestingHeightbooleanfalseKeep 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.
floatingAnchorSvFloatingAnchor | nullnullJoin 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.
floatingOrdernumberSV_FLOATING_ORDER.affixRank in the floating layer; lower sits nearer the edge.

Outputs

EventPayload typeDescription
affixChangeAffixModeEmitted whenever the mode changes.
containingBlockChangeSvAffixContainingBlock | nullEmitted 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.

Loads giscus.app and GitHub — nothing is fetched until you click.