Split Sidebar into Tabs
6 min read
This recipe provides a comprehensive walkthrough for implementing a tabbed sidebar switcher in Starlight, similar to the one used in the official Astro Documentation.
By default, Starlight provides a vertical sidebar. For larger documentation sites, a tabbed interface can help organize content into top-level categories, making navigation more intuitive.
Step-by-Step Guide
Section titled “Step-by-Step Guide”-
Configure Sidebar Labels
Create a dedicated configuration file for your sidebar. This file will export separate arrays for each tab, combined into a single configuration for Starlight. Define your labels within a constant in this file.
config/sidebar.ts import type { StarlightUserConfig } from "@astrojs/starlight/types";const enLabels = {"astro.recipes": "Astro Recipes","starlight.recipes": "Starlight Recipes",} as const;type NavKey = keyof typeof enLabels;/*** Helper to create a sidebar group with localized labels.*/export function group(key: NavKey, group: any): any {return {label: enLabels[key],...group,};}export const astroSidebar = [group("astro.recipes", {items: [{ autogenerate: { directory: "astro" } }],}),] satisfies StarlightUserConfig["sidebar"];export const starlightSidebar = [group("starlight.recipes", {items: [{ autogenerate: { directory: "starlight" } }],}),] satisfies StarlightUserConfig["sidebar"]; -
Create Custom Tab Components
Implement the necessary components for the tabbed interface. These components handle the layout, tab selection, and accessibility.
src/components/tabs/TabbedContent.astro ---export interface Props {class?: string;}---<tabbed-content class={Astro.props.class}><ul role="tablist" class="tab-list"><slot name="tab-list" /></ul><slot /></tabbed-content><script>class Tabs extends HTMLElement {TabStore: Set<HTMLElement>[] = [];PanelStore: Set<HTMLElement>[] = [];constructor() {super();const tabs = this.querySelectorAll<HTMLElement>('[role="tab"]');const panels = this.querySelectorAll<HTMLElement>('[role="tabpanel"]');Array.prototype.forEach.call(tabs, (tab: HTMLElement, i: number) => {tab.addEventListener('click', (e) => {e.preventDefault();this.switchTab(tab, i);});tab.addEventListener('keydown', (e) => {const index = Array.prototype.indexOf.call(tabs, e.currentTarget);const dir =e.key === 'ArrowLeft' ? index - 1 : e.key === 'ArrowRight' ? index + 1 : null;if (dir !== null) {e.preventDefault();if (tabs[dir]) this.switchTab(tabs[dir], dir);}});if (!this.TabStore[i]) this.TabStore.push(new Set());this.TabStore[i].add(tab);});// Initialization logic for panels and initial tab selectionlet initialTab = 0;Array.prototype.forEach.call(tabs, (tab: HTMLElement, i: number) => {if (tab.dataset.initial) initialTab = i;});// Re-initialize logic after Starlight page transitionswindow.addEventListener('astro:after-swap', () => {const tabs = this.querySelectorAll<HTMLElement>('[role="tab"]');Array.prototype.forEach.call(tabs, (tab: HTMLElement, i: number) => {const index = Array.prototype.indexOf.call(tabs, tab);const dir = index;if (dir !== null) {if (tabs[dir]) this.switchTab(tabs[dir], dir);}});});Array.prototype.forEach.call(panels, (panel: HTMLElement, i: number) => {panel.setAttribute('role', 'tabpanel');panel.setAttribute('tabindex', '-1');panel.setAttribute('aria-labelledby', tabs[i].id);panel.hidden = true;if (!this.PanelStore[i]) this.PanelStore.push(new Set());this.PanelStore[i].add(panel);});tabs[initialTab].removeAttribute('tabindex');tabs[initialTab].setAttribute('aria-selected', 'true');panels[initialTab].hidden = false;}switchTab(newTab: HTMLElement, index: number) {this.TabStore.forEach((store) =>store.forEach((oldTab) => {oldTab.removeAttribute('aria-selected');oldTab.setAttribute('tabindex', '-1');}));this.TabStore[index].forEach((newTab) => {newTab.removeAttribute('tabindex');newTab.setAttribute('aria-selected', 'true');});this.PanelStore.forEach((store) =>store.forEach((oldPanel) => {oldPanel.hidden = true;}));this.PanelStore[index].forEach((newPanel) => {newPanel.hidden = false;});newTab.focus();}}customElements.define('tabbed-content', Tabs);</script>src/components/tabs/TabListItem.astro ---import type { HTMLAttributes } from 'astro/types';export interface Props {id: string;initial?: boolean;class?: string;}const { id, initial } = Astro.props;const linkAttributes: HTMLAttributes<'a'> = initial ? { 'data-initial': 'true' } : {};---<li class:list={Astro.props.class}><a href={'#' + id} {...linkAttributes}><slot /></a></li>src/components/tabs/TabPanel.astro ---import type { HTMLAttributes } from 'astro/types';export interface Props {id: string;initial?: boolean;}const { id, initial } = Astro.props;const attributes: HTMLAttributes<'div'> = initial ? { 'data-initial': 'true' } : {};---<div {id} {...attributes}><slot /></div><style>div {animation: tab-panel-appear 10s steps(2, jump-none) 1;}div[data-initial],div[role='tabpanel'] {animation: none;}@keyframes tab-panel-appear {from {display: none;content-visibility: hidden;}}</style> -
Create the Custom Sidebar Component
This component overrides Starlight’s default Sidebar. It uses the components from the previous step and assigns icons like “astro” and “starlight”.
src/components/Sidebar.astro ---import { Icon } from "@astrojs/starlight/components";import SidebarPersister from "@astrojs/starlight/components/SidebarPersister.astro";import SidebarSublist from "@astrojs/starlight/components/SidebarSublist.astro";import type { Props } from "@astrojs/starlight/props";import TabbedContent from "./tabs/TabbedContent.astro";import TabListItem from "./tabs/TabListItem.astro";import TabPanel from "./tabs/TabPanel.astro";const { sidebar } = Astro.locals.starlightRoute;// Make sure all top-level items in the sidebar are groups.type SidebarEntry = (typeof sidebar)[number];type Group = Extract<SidebarEntry, { type: "group" }>;function assertGroups(sidebar: SidebarEntry[]): asserts sidebar is Group[] {for (const entry of sidebar) {if (entry.type !== "group") {throw new Error("Top-level links are not permitted in the docs sidebar.");}}}assertGroups(sidebar);/** Convert a group label to an `id` we can use to identify tab panels. */const makeId = (label: string) =>"__tab-" + label.toLowerCase().replaceAll(/\s+/g, "-");/** Get the icon for a group. */const getIcon = (label: string) => {if (label.toLowerCase().includes("astro")) return "astro";if (label.toLowerCase().includes("starlight")) return "starlight";return "open-book";};/** Determine if an array of sidebar items contains the current page. */const isCurrent = (sidebar: SidebarEntry[]): boolean =>sidebar.map((entry) =>entry.type === "link" ? entry.isCurrent : isCurrent(entry.entries),).some((entry) => entry === true);const anyTabIsCurrent = sidebar.some(({ entries }) => isCurrent(entries));const isInitial = (entries: SidebarEntry[], index: number) =>anyTabIsCurrent ? isCurrent(entries) : index === 0;---<SidebarPersister><TabbedContent class="tabbed-sidebar"><Fragment slot="tab-list">{sidebar.map(({ label, entries }, index) => (<TabListItemid={makeId(label)}initial={isInitial(entries, index)}class="tab-item"><Icon class="icon" name={getIcon(label)} /> {label}</TabListItem>))}</Fragment>{sidebar.map(({ label, entries }, index) => (<TabPanel id={makeId(label)} initial={isInitial(entries, index)}><SidebarSublist sublist={entries} /></TabPanel>))}</TabbedContent></SidebarPersister><style>/** Always show the scrollbar gutter. */:global(.sidebar-pane) {overflow-y: scroll;}/* Styles for the custom tab switcher. */.tabbed-sidebar {/* Layout variables */--tab-switcher-border-width: 1px;--tab-switcher-padding: calc(0.25rem - var(--tab-switcher-border-width));--tab-item-border-radius: 0.5rem;--tab-switcher-border-radius: calc(var(--tab-item-border-radius) + var(--tab-switcher-padding) +var(--tab-switcher-border-width));/* Color variables */--tab-switcher-border-color: var(--sl-color-hairline-light);--tab-switcher-background-color: var(--sl-color-gray-7,var(--sl-color-gray-6));--tab-switcher-text-color: var(--sl-color-gray-3);--tab-switcher-text-color--active: var(--sl-color-white);--tab-switcher-icon-color: var(--sl-color-gray-4);--tab-switcher-icon-color--active: var(--sl-color-text-accent);--tab-item-background-color--hover: var(--sl-color-gray-6);--tab-item-background-color--active: var(--sl-color-black);}/* Dark theme variations */:global([data-theme="dark"]) .tabbed-sidebar {--tab-switcher-text-color: var(--sl-color-gray-2);--tab-switcher-icon-color: var(--sl-color-gray-3);--tab-item-background-color--hover: var(--sl-color-gray-5);}@media (min-width: 50rem) {/* Dark theme variations with the desktop sidebar visible */:global([data-theme="dark"]) .tabbed-sidebar {--tab-switcher-background-color: var(--sl-color-black);--tab-item-background-color--hover: var(--sl-color-gray-6);--tab-item-background-color--active: var(--sl-color-gray-6);}}.tabbed-sidebar.tab-list {border: var(--tab-switcher-border-width) solidvar(--tab-switcher-border-color);border-radius: var(--tab-switcher-border-radius);display: flex;flex-direction: column;gap: 0.25rem;padding: var(--tab-switcher-padding);background-color: var(--tab-switcher-background-color);margin-bottom: 1.5rem;}.tab-item :global(a) {border: var(--tab-switcher-border-width) solid transparent;border-radius: var(--tab-item-border-radius);display: flex;align-items: center;gap: 0.5rem;padding: calc(0.5rem - var(--tab-switcher-border-width));background-clip: padding-box;line-height: var(--sl-line-height-headings);text-decoration: none;color: var(--tab-switcher-text-color);font-weight: 600;}.tab-item :global(a:hover) {color: var(--tab-switcher-text-color--active);background-color: var(--tab-item-background-color--hover);}.tab-item :global(a[aria-selected="true"]) {border-color: var(--tab-switcher-border-color);color: var(--tab-switcher-text-color--active);background-color: var(--tab-item-background-color--active);}.icon {margin: 0.25rem;color: var(--tab-switcher-icon-color);}.tab-item :global(a:hover) .icon {color: inherit;}.tab-item :global(a[aria-selected="true"]) .icon {color: var(--tab-switcher-icon-color--active);}</style> -
Configure Astro
Finally, register your custom component and import the modular sidebar configs.
astro.config.mjs import starlight from "@astrojs/starlight";import { defineConfig } from "astro/config";import { astroSidebar, starlightSidebar } from "./config/sidebar";export default defineConfig({integrations: [starlight({components: {Sidebar: "./src/components/Sidebar.astro",},sidebar: [...astroSidebar, ...starlightSidebar],}),],});