---
title: "Scroll position"
description: "Two htmx extensions: preserve-scroll keeps the scroll position of marked containers when htmx swaps them, and sidebar-active-scroll also keeps a sidebar's current page in view, on a fresh page load too."
url: "https://susonwaiba.github.io/htmx-ui/docs/preserve-scroll"
section: "Guides"
---

# Scroll position

Two htmx extensions: preserve-scroll keeps the scroll position of marked containers when htmx swaps them, and sidebar-active-scroll also keeps a sidebar's current page in view, on a fresh page load too.

When htmx swaps content it inserts new elements, and a new element starts scrolled to the top. That is right for most content, but not for a sidebar the reader has scrolled to the current page, a log refreshed by polling, or a table re-rendered after a filter. Mark those containers with `hx-preserve-scroll` and they come back where they were.

```html
    <div class="w-full">
      <div id="scroll-demo">
<div class="grid gap-4 sm:grid-cols-2">
  <div class="space-y-2">
    <p class="text-sm font-medium">With hx-preserve-scroll</p>
    <ol class="h-48 space-y-1 overflow-y-auto rounded-(--radius) border border-border bg-surface p-3 text-sm"
hx-preserve-scroll="demo-list" tabindex="0" aria-label="Kept list">
      <li class="flex justify-between gap-4"><span>Item 1</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 2</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 3</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 4</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 5</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 6</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 7</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 8</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 9</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 10</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 11</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 12</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 13</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 14</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 15</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 16</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 17</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 18</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 19</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 20</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 21</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 22</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 23</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 24</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 25</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 26</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 27</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 28</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 29</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 30</span><span class="text-muted-foreground tabular-nums">page load</span></li>
    </ol>
  </div>
  <div class="space-y-2">
    <p class="text-sm font-medium">Without</p>
    <ol class="h-48 space-y-1 overflow-y-auto rounded-(--radius) border border-border bg-surface p-3 text-sm"
 tabindex="0" aria-label="Reset list">
      <li class="flex justify-between gap-4"><span>Item 1</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 2</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 3</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 4</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 5</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 6</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 7</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 8</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 9</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 10</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 11</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 12</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 13</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 14</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 15</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 16</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 17</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 18</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 19</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 20</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 21</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 22</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 23</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 24</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 25</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 26</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 27</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 28</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 29</span><span class="text-muted-foreground tabular-nums">page load</span></li>
      <li class="flex justify-between gap-4"><span>Item 30</span><span class="text-muted-foreground tabular-nums">page load</span></li>
    </ol>
  </div>
</div>
<div class="mt-4 space-y-2">
  <p class="text-sm font-medium">Two rows sharing hx-preserve-scroll="demo-row"</p>
  <div class="flex gap-2 overflow-x-auto rounded-(--radius) border border-border bg-surface p-3" hx-preserve-scroll="demo-row" tabindex="0" aria-label="Row 1">
    <span class="badge badge-outline shrink-0">1.1</span>
    <span class="badge badge-outline shrink-0">1.2</span>
    <span class="badge badge-outline shrink-0">1.3</span>
    <span class="badge badge-outline shrink-0">1.4</span>
    <span class="badge badge-outline shrink-0">1.5</span>
    <span class="badge badge-outline shrink-0">1.6</span>
    <span class="badge badge-outline shrink-0">1.7</span>
    <span class="badge badge-outline shrink-0">1.8</span>
    <span class="badge badge-outline shrink-0">1.9</span>
    <span class="badge badge-outline shrink-0">1.10</span>
    <span class="badge badge-outline shrink-0">1.11</span>
    <span class="badge badge-outline shrink-0">1.12</span>
    <span class="badge badge-outline shrink-0">1.13</span>
    <span class="badge badge-outline shrink-0">1.14</span>
    <span class="badge badge-outline shrink-0">1.15</span>
    <span class="badge badge-outline shrink-0">1.16</span>
    <span class="badge badge-outline shrink-0">1.17</span>
    <span class="badge badge-outline shrink-0">1.18</span>
    <span class="badge badge-outline shrink-0">1.19</span>
    <span class="badge badge-outline shrink-0">1.20</span>
  </div>
  <div class="flex gap-2 overflow-x-auto rounded-(--radius) border border-border bg-surface p-3" hx-preserve-scroll="demo-row" tabindex="0" aria-label="Row 2">
    <span class="badge badge-outline shrink-0">2.1</span>
    <span class="badge badge-outline shrink-0">2.2</span>
    <span class="badge badge-outline shrink-0">2.3</span>
    <span class="badge badge-outline shrink-0">2.4</span>
    <span class="badge badge-outline shrink-0">2.5</span>
    <span class="badge badge-outline shrink-0">2.6</span>
    <span class="badge badge-outline shrink-0">2.7</span>
    <span class="badge badge-outline shrink-0">2.8</span>
    <span class="badge badge-outline shrink-0">2.9</span>
    <span class="badge badge-outline shrink-0">2.10</span>
    <span class="badge badge-outline shrink-0">2.11</span>
    <span class="badge badge-outline shrink-0">2.12</span>
    <span class="badge badge-outline shrink-0">2.13</span>
    <span class="badge badge-outline shrink-0">2.14</span>
    <span class="badge badge-outline shrink-0">2.15</span>
    <span class="badge badge-outline shrink-0">2.16</span>
    <span class="badge badge-outline shrink-0">2.17</span>
    <span class="badge badge-outline shrink-0">2.18</span>
    <span class="badge badge-outline shrink-0">2.19</span>
    <span class="badge badge-outline shrink-0">2.20</span>
  </div>
</div>
</div>
      <button type="button" class="btn btn-outline btn-sm mt-4" hx-get="/api/preserve-scroll" hx-target="#scroll-demo">Re-render from the server</button>
    </div>
```

Scroll the lists and rows, then re-render them: every element is replaced (the times change), but the marked ones keep their position. For a navigation sidebar, use [sidebar-active-scroll](#sidebar) instead.

## Setup

Register the extension once, after htmx has loaded:

```ts app.ts
import "htmx.org";
import { registerPreserveScroll } from "htmx-ui";

registerPreserveScroll(); // uses window.htmx; or registerPreserveScroll(htmx)
```

Like every htmx 4 extension it applies to the whole page once registered; there is no `hx-ext`. If you restrict extensions with `htmx.config.extensions`, add `preserve-scroll` to the list. Registering twice is harmless.

## Markup

Put `hx-preserve-scroll` on the element that scrolls (the one with `overflow: auto`), and give it a key:

```html
<div class="h-96 overflow-y-auto" hx-preserve-scroll="results">…</div>

<!-- An empty attribute uses the element's id as the key <!-- An empty attribute uses the element's id as the key -->
<div id="log" class="h-80 overflow-y-auto" hx-preserve-scroll>…</div>
```

- The key is what connects the old element with the new one. Use as many containers on a page as you like, each with its own key. An element with no key and no id is ignored.
- **Repeated keys** are matched in document order: the first old element with the first new one, and so on. A list of horizontally scrolling rows can share one key, as the two rows in the demo above do.
- The swap can replace the container itself (`outerHTML`), its parent, or the whole body in a boosted navigation. It works with OOB swaps and partials too.
- `hx-preserve-scroll="false"` opts an element out, for a fragment that should start at the top even though it has the same id.

## How it works

- Before each swap (`htmx:before:swap`) it records the scroll position of every marked element in the document.
- New content is scrolled to the recorded position at `htmx:before:settle`, before the browser paints it, so it never flashes at the top. A last pass at `htmx:finally:swap` catches content from every task.
- Elements the swap didn't replace are left alone, so a container the reader scrolls while the request is in flight keeps the reader's position. Morph swaps keep the element and therefore its scroll already.
- Nothing is stored: positions are kept only for the duration of a swap.

> **Content that grows after the swap** Positions are applied once, when the content is inserted. If images or lazy content above the position load later and change its height, give them a fixed size (`width` and `height` on images) so the position still lands on the same content.

## Sidebars: sidebar-active-scroll

Keeping the position is not enough for a navigation sidebar. Open a link in a new tab, or reload, and there is no position to keep: the sidebar starts at the top, and the current page may be far below. A link in the page itself can lead to a page whose sidebar entry is out of view. `sidebar-active-scroll` handles both:

- **Fresh page load** (new tab, reload, a sidebar new to the page): the active item is centred in the sidebar.
- **After a swap**: the old position comes back, as with `hx-preserve-scroll`. If the new active item is then out of view, the sidebar scrolls just enough to show it, with a neighbouring item's height to spare. Clicking a link that is already visible doesn't move anything.
- **A hidden sidebar** (an off-canvas panel with `display: none`) is centred the first time it gets a size.
- Only the sidebar scrolls; the page never jumps.

```ts app.ts
import { registerSidebarActiveScroll } from "htmx-ui";

registerSidebarActiveScroll();
```

```html
<div class="sidebar-content" hx-sidebar-active-scroll="docs-nav">
  …
  <a class="sidebar-menu-button" href="/docs/theming" aria-current="page">Theming</a>
  …
</div>
```

- Put `hx-sidebar-active-scroll` on the element that scrolls. Its value is the key, with the same rules as `hx-preserve-scroll` (empty: the id; `"false"`: off).
- The active item is the first `[aria-current="page"]` or `[data-active]` inside, as the [Sidebar](https://susonwaiba.github.io/htmx-ui/docs/components/sidebar) component marks it. `hx-sidebar-active-item="<selector>"` picks something else.
- Use one extension per container. This site's docs sidebar uses `sidebar-active-scroll`.

## API

Exported from `htmx-ui`:

- `registerPreserveScroll(htmx?)`: registers the extension as `preserve-scroll`.
- `snapshotScroll(root?)` and `restoreScroll(snapshot, root?)`: the same capture and restore, for DOM you replace yourself.
- `registerSidebarActiveScroll(htmx?)`: registers `sidebar-active-scroll` and centres the active item of sidebars already on the page.
- `revealActive(container, mode?)`: scrolls a container so its active item shows, `"nearest"` (the default: only if it is out of view) or `"center"`. `revealAllActive(root?, mode?)` does the same for every `[hx-sidebar-active-scroll]`; call it after changing `aria-current` yourself.

```ts
import { restoreScroll, snapshotScroll } from "htmx-ui";

const snapshot = snapshotScroll();
panel.innerHTML = html;
restoreScroll(snapshot);
```
