# Stuck.js

[![CI](https://github.com/RyoNkmr/stuck-js/actions/workflows/ci.yml/badge.svg)](https://github.com/RyoNkmr/stuck-js/actions/workflows/ci.yml)
[![npm version](https://badge.fury.io/js/stuck-js.svg)](https://badge.fury.io/js/stuck-js)
[![downloads](https://img.shields.io/npm/dt/stuck-js.svg)](https://www.npmjs.com/package/stuck-js)

A sticky library that stacks multiple sticky elements on top of each other and keeps them aligned when the page scrolls horizontally. No dependencies — jQuery not required.

Demo: https://ryonkmr.github.io/stuck-js/

> [!IMPORTANT]
> **v3 builds on the browser's own `position: sticky`, so it does not run on IE11.**
> Every other browser released since 2017 supports it. If you still need IE11,
> stay on the 2.x line — it computes positions in JavaScript and has no such
> requirement:
>
> ```bash
> $ npm i -S stuck-js@^2
> ```
>
> 2.x is frozen at v2.1.5 on the [`v2` branch](https://github.com/RyoNkmr/stuck-js/tree/v2)
> and is no longer developed. That is a safe place to sit: the package ships no
> runtime dependencies, so there is no supply chain underneath it to rot, and
> nothing to patch when someone else's package has a CVE. It will keep working
> for as long as the browsers you target do.
>
> See [Migrating from v2](#migrating-from-v2) for what changed in v3.

## Quickstart

### Setup

Install it from [npm](https://www.npmjs.com/package/stuck-js):

```bash
$ npm i -S stuck-js
```

Or grab a build from the [GitHub releases](https://github.com/RyoNkmr/stuck-js/releases).

### Usage
```html
<style>
  header {
    height: 100px;
    z-index: 100;
  }
  .ad {
    width: 300px;
    height: 250px;
  }
</style>
<body>
  <header style="height: 100px; z-index: 100;">
    <h1>This is my first website</h1>
    <!-- header contents -->
  </header>
  <div>
    <main>
      <!-- main contents -->
    </main>
    <div id="js-sidebar">
      <aside class="js-sticky-ad ad ad--01"><!-- ad contents --></aside>
      <aside class="js-sticky-ad ad ad--02"><!-- ad contents --></aside>
    </div>
  </div>
  <script src="https://unpkg.com/stuck-js"></script>
  <script>
  const Stuck = StuckJs.Stuck;
  const instances = new Stuck([
    { selector: '#js-header', marginTop: 0 },
    { selector: '.js-sticky-ad', wrapper: '#js-sidebar' },
  ], { marginTop: 10 });
  </script>
</body>
```

Or with a bundler:

```js
import { Stuck } from 'stuck-js'

const instances = new Stuck([
  { selector: '#js-header', marginTop: 0 },
  { selector: '.js-sticky-ad', wrapper: '#js-sidebar' },
], { marginTop: 10 })
```

## API

The package exports `Stuck` (also the default export) and `Sticky`. `Stuck` is what
you normally use: it resolves selectors, creates one `Sticky` per element and keeps
them stacked.

Positioning itself is left to the browser. Each element gets `position: sticky`,
and the library only decides what `top` it should stick at — the sum of the
heights of the stickies above it. There is no scroll handler.

### Options

Every setting accepts these, and `Stuck` takes the same shape as per-instance
defaults:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `marginTop` | `number` | `0` | Gap left above the element once it sticks, measured from the bottom of the sticky above it, or from the top of the window when nothing is stacked above. |
| `wrapper` | `string \| HTMLElement` | the element's `parentElement`, else `document.body` | Node that bounds the stacking. The sticky stops once it reaches the wrapper's bottom edge. Because this is `position: sticky`'s own containing block, the wrapper has to be an ancestor of the element. |
| `observe` | `boolean` | `true` | Watch the element with a `ResizeObserver` so the stack re-stacks when its height changes. Set to `false` if the element never resizes. |

A setting also needs a target, given as **either** a selector **or** elements:

```ts
type StickySetting = StickyOptions & (
  | { selector: string }
  | { element: HTMLElement | HTMLElement[] | NodeList }
)
```

### `new Stuck(settings?, defaultOptions?, sharedStacking?)`

| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `settings` | `StickySetting \| StickySetting[]` | `[]` | What to stick. Elements already registered by another instance are skipped. |
| `defaultOptions` | `StickyOptions` | `{ observe: true }` | Applied to every setting; a setting's own options win. |
| `sharedStacking` | `boolean` | `true` | Whether these stickies join the stack shared with other `Stuck` instances. With `false` they stack only among themselves. |

Throws if a setting has neither `selector` nor `element`.

#### `stuck.create(settings, sharedStacking?)` → `Sticky[]`

Registers more elements on an existing instance and re-stacks everything.
Returns the newly created stickies (empty if every element was already
registered).

#### `stuck.stickies` → `readonly Sticky[]`

The stickies this instance owns, ordered by their position on the page.

#### `stuck.destroy()`

Destroys every sticky it owns, restores the original DOM and re-stacks the
remaining stickies. The instance should not be reused afterwards — create a new
one instead.

### `new Sticky(element, options?, activate?, onUpdate?)`

The single-element primitive `Stuck` builds on. Use it directly when you already
have the element and do not need stacking across instances.

| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `element` | `HTMLElement` | — | Required. |
| `options` | `StickyOptions` | `{ observe: true }` | Same options as above. |
| `activate` | `boolean` | `true` | Join the shared stack immediately. |
| `onUpdate` | `() => void` | no-op | Called after a resize triggers a re-stack. |

| Member | Type | Description |
| --- | --- | --- |
| `element` | `HTMLElement` | The element being stuck. |
| `options` | `StickyOptions` | Resolved options, with `marginTop` always present. |
| `offsetTop` | `number` | The `top` it sticks at, including the stickies stacked above it. |
| `update()` | `void` | Re-runs the stack calculation. Called for you when a height changes. |
| `destroy()` | `void` | Restores the DOM and stops observing. Safe to call twice. |

### DOM and styling

The element gets a `data-stuck` attribute — `"true"` while stuck, `""` otherwise —
so you can style both states:

```css
.my-sticky[data-stuck='true'] { box-shadow: 0 2px 8px rgba(0, 0, 0, .2); }
```

To know whether an element is currently stuck, a zero-height `div` is inserted
immediately before it and watched with an `IntersectionObserver` — the element
itself cannot tell you, since a sticky element pinned at `top: 0` and one that
simply starts there look identical.

That sentinel counts as a sibling, so **structural selectors shift by one**:

```css
/* does not do what you want: the sentinel is now the first child */
.item:nth-child(n + 2) { margin-top: 30px; }

/* address the elements directly instead */
.item { margin-top: 30px; }
.item:first-of-type { margin-top: 0; }
```

`:first-of-type` is safe as long as your sticky elements are not `div`s. When they
are, target them by class or id.

### Migrating from v2

| v2 | v3 |
| --- | --- |
| Wrapped every sticky in a placeholder `div` | Inserts a zero-height sentinel `div` before it |
| `Placeholder` was exported | Removed — the browser reserves the space now |
| `sticky.marginTop` was the computed offset | `sticky.offsetTop` |
| `sticky.rect`, `sticky.floor`, `sticky.isStickToBottom` | Removed — the browser owns positioning |
| `wrapper` could be any element | Must be an ancestor of the sticky element |
| Ran a scroll handler on every frame | No scroll handler |

`new Stuck(...)`, `stuck.create()`, `stuck.stickies`, `stuck.destroy()`, the three
options and the `data-stuck` attribute all behave as before.

