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.
With hx-preserve-scroll
- Item 1page load
- Item 2page load
- Item 3page load
- Item 4page load
- Item 5page load
- Item 6page load
- Item 7page load
- Item 8page load
- Item 9page load
- Item 10page load
- Item 11page load
- Item 12page load
- Item 13page load
- Item 14page load
- Item 15page load
- Item 16page load
- Item 17page load
- Item 18page load
- Item 19page load
- Item 20page load
- Item 21page load
- Item 22page load
- Item 23page load
- Item 24page load
- Item 25page load
- Item 26page load
- Item 27page load
- Item 28page load
- Item 29page load
- Item 30page load
Without
- Item 1page load
- Item 2page load
- Item 3page load
- Item 4page load
- Item 5page load
- Item 6page load
- Item 7page load
- Item 8page load
- Item 9page load
- Item 10page load
- Item 11page load
- Item 12page load
- Item 13page load
- Item 14page load
- Item 15page load
- Item 16page load
- Item 17page load
- Item 18page load
- Item 19page load
- Item 20page load
- Item 21page load
- Item 22page load
- Item 23page load
- Item 24page load
- Item 25page load
- Item 26page load
- Item 27page load
- Item 28page load
- Item 29page load
- Item 30page load
Two rows sharing hx-preserve-scroll="demo-row"
<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 instead.
Setup#
Register the extension once, after htmx has loaded:
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:
<div class="h-96 overflow-y-auto" hx-preserve-scroll="results">…</div>
<!-- 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 athtmx:finally:swapcatches 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.
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.
import { registerSidebarActiveScroll } from "htmx-ui";
registerSidebarActiveScroll();
<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-scrollon the element that scrolls. Its value is the key, with the same rules ashx-preserve-scroll(empty: the id;"false": off). -
The active item is the first
[aria-current="page"]or[data-active]inside, as the 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 aspreserve-scroll.-
snapshotScroll(root?)andrestoreScroll(snapshot, root?): the same capture and restore, for DOM you replace yourself. registerSidebarActiveScroll(htmx?): registerssidebar-active-scrolland 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 changingaria-currentyourself.
import { restoreScroll, snapshotScroll } from "htmx-ui";
const snapshot = snapshotScroll();
panel.innerHTML = html;
restoreScroll(snapshot);