How to Create a Persistent Sidebar with Astro

One thing that’s always been challenging about Astro is creating nested layouts. Unlike frameworks like Next.js and Remix, Astro typically does a full page reload, wiping away things like scroll position, even for parts of the page that you don’t want to change.

However, using some classic tricks and leveraging Astro-specific events, we can build components like a sidebar menu that stays in place while the rest of the page updates. Let’s get started:

For the purposes of this demo, we’ll just create a bunch of pages to ensure our sidebar is big enough to scroll. (I’m also using Tailwind for some light styling.)

./utils/get-pages
export const getPages = () => {
return [...Array(50)].map((_, idx) => ({
id: String(idx + 1),
}))
}

And we can use that function to create a bunch of dynamic pages to click through.

./pages/[slug].astro
---
import Layout from '../layouts/Layout.astro'
import { getPages } from '../utils/get-pages.ts'
export const getStaticPaths = (async () => {
const allPages = getPages()
return allPages.map((entry) => ({
params: { slug: entry.id },
props: { entry },
}))
}) satisfies GetStaticPaths
type Props = InferGetStaticPropsType<typeof getStaticPaths>
const { entry } = Astro.props
---
<Layout title={entry.id}>
<main class="h-full p-12">
<h1 class="font-bold text-5xl">
This is page: {entry.id}
</h1>
</main>
</Layout>

Now, let’s add the sidebar to our Layout and fill it with links.

./layouts/Layout.astro
---
import { ViewTransitions } from 'astro:transitions'
import { getPages } from '../utils/get-pages.ts'
interface Props {
title: string
}
const allPages = getPages()
const { title } = Astro.props
const { pathname } = Astro.url
const path = pathname.replace(/\/+$/, '')
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Astro description" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<div class="w-64 fixed inset-y-0">
<nav class="h-full overflow-y-auto">
<ul class="divide-y border-r">
{
allPages.map((page) => (
<li>
<a
href={`/${page.id}`}
class="block p-4 aria-[current=page]:bg-blue-500"
aria-current={path === `/${page.id}` ? 'page' : false}
>
{page.id}
</a>
</li>
))
}
</ul>
</nav>
</div>
<div class="ml-64 h-full">
<slot />
</div>
</body>
</html>

We created a layout that has a fixed sidebar and added a slot where our pages’ content will live. When the current pathname matches the path of a specific page.id, we assign the link element an attribute, aria-current='page'. We use this attribute to style the active link with a blue background.

Great, that’s everything we need to have a functioning site with a sidebar. However, you’ll notice that when you scroll the sidebar and click a new page, the scroll position is lost and our sidebar loads back at the top.

Let’s add some functionality to make this experience a bit better.

./layouts/Layout.astro
<body>
<div class="w-64 fixed inset-y-0">
<nav id="sidebar" class="h-full overflow-y-auto">
<ul class="divide-y border-r">
{
allPages.map((page) => (
<li>
<a
href={`/${page.id}`}
class="block p-4 aria-[current=page]:bg-blue-500"
aria-current={path === `/${page.id}` ? 'page' : false}
>
{page.id}
</a>
</li>
))
}
</ul>
</nav>
</div>
<div class="ml-64 h-full">
<slot />
</div>
<script>
;(function () {
const sidenav = document.querySelector('#sidenav')
const sidenavActiveLink = sidenav.querySelector('[aria-current=page]')
sidenavActiveLink.scrollIntoView({
behavior: 'instant',
block: 'center',
})
})()
</script>
</body>

Inspired by the approach used in the Bootstrap docs, when a user lands on a page or clicks a link, the browser will always try to scroll the active link to the middle of the page.

That’s a pretty good approach, but we can take it a step further. (This is inspired by an open PR for Astro’s Starlight sidebar)

./layouts/Layout.astro
<script>
;(function () {
const sidenav = document.querySelector('#sidenav')
const sidenavActiveLink = sidenav.querySelector('[aria-current=page]')
const key = 'scroll-state'
const savedStateJson = sessionStorage.getItem(key)
let savedState
if (savedStateJson) {
try {
savedState = JSON.parse(savedStateJson)
} catch (e) {
console.error('Error parsing saved position:', e)
}
}
if (savedState) {
sidenav.scrollTop = savedState.scrollTop
} else {
sidenavActiveLink.scrollIntoView({
behavior: 'instant',
block: 'center',
})
}
window.addEventListener('beforeunload', () => {
const sidenav = document.querySelector('#sidenav')
sessionStorage.setItem(
key,
JSON.stringify({
scrollTop: sidenav.scrollTop,
}),
)
})
})()
</script>

Now when we click a link, the scroll position is restored to the previous state. We do this by saving the scroll position in our session storage when the page “unloads” and restore it when the new page loads. Pretty cool!

This is starting to feel really nice. And if this is all you wanted, you could stop here.

But in order to make our site really smooth, we can introduce View Transitions. However, this will cause our site to use client-side navigation, undoing some of the functionality that we just added.

Instead, we can leverage Astro’s lifecycle events and reuse our existing code to get an even better experience while navigating.

./layouts/Layout.astro
---
import { ViewTransitions } from 'astro:transitions'
import Sidebar from '../components/Sidebar.astro'
import { getPages } from '../utils/get-pages.ts'
interface Props {
title: string
}
const allPages = getPages()
const { title } = Astro.props
const { pathname } = Astro.url
const path = pathname.replace(/\/+$/, '')
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Astro description" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ViewTransitions />
</head>
<body>
<div class="w-64 fixed inset-y-0">
<nav id="sidebar" class="h-full overflow-y-auto">
<ul class="divide-y border-r">
{
allPages.map((page) => (
<li>
<a
href={`/${page.id}`}
class="block p-4 aria-[current=page]:bg-blue-500"
aria-current={path === `/${page.id}` ? 'page' : false}
>
{page.id}
</a>
</li>
))
}
</ul>
</nav>
</div>
<div class="ml-64 h-full">
<slot />
</div>
<script>
;(function () {
document.addEventListener('astro:page-load', () => {
const sidenav = document.querySelector('#sidenav')
const sidenavActiveLink = sidenav.querySelector('[aria-current=page]')
const key = 'scroll-state'
const savedStateJson = sessionStorage.getItem(key)
let savedState
if (savedStateJson) {
try {
savedState = JSON.parse(savedStateJson)
} catch (e) {
console.error('Error parsing saved position:', e)
}
}
if (savedState) {
sidenav.scrollTop = savedState.scrollTop
} else {
sidenavActiveLink.scrollIntoView({
behavior: 'instant',
block: 'center',
})
}
})
window.addEventListener('beforeunload', () => {
document.addEventListener('astro:before-swap', () => {
const key = 'scroll-state'
const sidenav = document.querySelector('#sidenav')
sessionStorage.setItem(
key,
JSON.stringify({
scrollTop: sidenav.scrollTop,
}),
)
})
})()
</script>
</body>
</html>

Now when we click the link, our page uses a nice, smooth fade transition and our sidebar bar stays in place just like we’d expect in a nested layout. Not only that, we also scroll the active link to the middle of the page when a user doesn’t have a previously saved scroll position. This is useful for folks who might be viewing our site for the first time.

Great! Now our Astro app feels just as nice as a client-side SPA and yet we still get all the benefits of our statically generated site.


If you want to take a look at a full example, head to Stackblitz and please feel free with any questions or feedback!