Sidebar
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add navigation/sidebarPlain-CSS theme — no build step
npx astrocraft-ui add navigation/sidebar --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add navigation/sidebar --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add navigation/sidebar --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add navigation/sidebar --theme tailwind --bridge lumosLive demo
Sidebar
A native <details>: [open] is the state and the <summary> is a real, announced button. The script adds only what the platform doesn't — the open state is remembered in localStorage (collapse it and reload), and below 48rem Escape or activating an item dismisses it.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Sidebar.astro` | `sidebar` | `data-side`: `left` · `right` `data-persist`: `true` when set | — | `[open]` |
| `SidebarContent.astro` | `sidebar-content` | — | — | — |
| `SidebarFooter.astro` | `sidebar-footer` | — | — | — |
| `SidebarGroup.astro` | `sidebar-group` | — | — | — |
| `SidebarGroupLabel.astro` | `sidebar-group-label` | — | — | — |
| `SidebarItem.astro` | `sidebar-item` | — | — | `[aria-current]` |
| `SidebarTrigger.astro` | `chevron` `sidebar-trigger` | — | — | — |
Source
What the command copies — 8 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/navigation/sidebar/Sidebar.astro — headless primitive (see ../../README.md).
// Collapsible application navigation, on native <details>.
//
// <details> is doing nearly all of the work, and that is the point: `[open]` is the state, <summary>
// is a real button with a real expanded state the browser announces itself, the keyboard works, and
// find-in-page can reveal a collapsed nav. A div with `aria-expanded` and a click handler would be
// more code and less correct.
//
// The script adds the two things the platform does not:
//
// 1. PERSISTENCE. A nav that re-collapses on every page load is worse than one that never
// collapsed. Keyed on this element's `id` in localStorage; set `persist={false}` to opt out, and
// an element with no `id` never persists because there would be nothing to key on.
// 2. SHEET BEHAVIOR BELOW `breakpoint`. Narrow viewports turn the panel into an overlay (a theme's
// job), and an overlay has to be dismissible: Escape closes it, and so does activating an item,
// because the page behind has just changed.
//
// ponytail: "sheet-LIKE" is the honest description of #2 — Escape and dismiss-on-activate, but no
// focus trap and no inert background, so a Tab from the open panel walks into the page under it. A
// real modal would mean a <dialog>, which would mean either two DOM trees or moving the nav between
// them at a media query. The upgrade path if someone needs it is `<dialog>` + the shared `_dialog`
// controller, the same way Sheet is built; the ceiling until then is "dismissible, not trapping".
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"details"> & {
side?: "left" | "right";
/** Remember the open state in localStorage, keyed on this element's `id`. */
persist?: boolean;
/** Width at or below which the panel behaves like a sheet. Any CSS length. */
breakpoint?: string;
};
const {
side = "left",
persist = true,
breakpoint = "48rem",
class: className,
...rest
} = Astro.props;
---
<details
open
class={className}
data-slot="sidebar"
data-side={side}
data-persist={persist ? "true" : undefined}
data-breakpoint={breakpoint}
{...rest}
>
<slot />
</details>
<script>
import { onReadyOnce } from "../../_once";
const KEY = (el: HTMLElement) => `sidebar:${el.id}`;
function wire(el: HTMLElement) {
const sidebar = el as HTMLDetailsElement;
const narrow = matchMedia(`(max-width: ${sidebar.dataset.breakpoint || "48rem"})`);
if (sidebar.dataset.persist === "true" && sidebar.id) {
try {
const saved = localStorage.getItem(KEY(sidebar));
if (saved !== null) sidebar.open = saved === "open";
} catch {
// storage blocked (sandboxed iframe, site data off) — the sidebar still opens and closes,
// it just starts from the markup's state each load. Nothing else here depends on it.
}
sidebar.addEventListener("toggle", () => {
try {
localStorage.setItem(KEY(sidebar), sidebar.open ? "open" : "closed");
} catch {
// same as above.
}
});
}
// Escape closes it, but only where it is an overlay: on a wide screen the panel is part of the
// layout, and collapsing the whole nav because someone pressed Escape in a search field would be
// a surprise. Focus returns to the <summary>, which is where Escape's user was conceptually.
sidebar.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || !narrow.matches || !sidebar.open) return;
event.preventDefault();
sidebar.open = false;
sidebar.querySelector<HTMLElement>('[data-slot="sidebar-trigger"]')?.focus();
});
// Activating an item on a narrow screen dismisses the panel: the destination it covers is the
// thing the user just asked to see.
sidebar.addEventListener("click", (event) => {
if (!narrow.matches || !(event.target instanceof Element)) return;
if (event.target.closest('[data-slot="sidebar-item"]')) sidebar.open = false;
});
}
onReadyOnce('[data-slot="sidebar"]', wire);
</script>
---
// src/components/ui/navigation/sidebar/SidebarContent.astro — Sidebar compound part (see ../../README.md).
// The nav landmark inside the sidebar, and the element a theme scrolls. Label it whenever the page
// has more than one <nav> — `<SidebarContent aria-label="Main">` — because "navigation, navigation"
// in a landmark list tells a screen reader user nothing about which is which.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"nav">;
const { class: className, ...rest } = Astro.props;
---
<nav class={className} data-slot="sidebar-content" {...rest}>
<slot />
</nav>
---
// src/components/ui/navigation/sidebar/SidebarFooter.astro — Sidebar compound part (see ../../README.md).
// The bottom shelf — account switcher, collapse hint, version string. Structure a selector depends
// on: it is what a theme pins to the bottom while SidebarContent scrolls above it.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div">;
const { class: className, ...rest } = Astro.props;
---
<div class={className} data-slot="sidebar-footer" {...rest}>
<slot />
</div>
---
// src/components/ui/navigation/sidebar/SidebarGroup.astro — Sidebar compound part (see ../../README.md).
// A labelled run of items. `for` is the id of its SidebarGroupLabel, and it becomes
// `aria-labelledby` — the same "the part points at an id the author wrote" convention FormField,
// Dialog and ToggleCount use, because Astro has no context API to hand a generated id downwards.
//
// The label association is the whole reason this is a file. A visual heading above a list of links
// is a heading to the eye and nothing at all to a screen reader; `role="group"` plus a name is what
// makes "Workspace: 4 items" come out of it.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
/** id of this group's SidebarGroupLabel. */
for?: string;
};
const { for: labelledBy, class: className, ...rest } = Astro.props;
---
<div
role="group"
aria-labelledby={labelledBy}
class={className}
data-slot="sidebar-group"
{...rest}
>
<slot />
</div>
---
// src/components/ui/navigation/sidebar/SidebarGroupLabel.astro — Sidebar compound part (see ../../README.md).
// The group's heading. Give it an `id` and point the SidebarGroup's `for` at it.
//
// A <div>, not an <h3>: the right heading LEVEL depends on the page this sidebar is dropped into,
// and a library that guesses it wrong breaks the document outline in a way nothing reports. The
// group's name comes from `aria-labelledby`, which needs no heading semantics at all — and a
// consumer who does want one passes `role="heading"` and `aria-level` through {...rest}.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div">;
const { class: className, ...rest } = Astro.props;
---
<div class={className} data-slot="sidebar-group-label" {...rest}>
<slot />
</div>
---
// src/components/ui/navigation/sidebar/SidebarItem.astro — Sidebar compound part (see ../../README.md).
// One nav entry: an <a> when `href` is set, a <button> otherwise — the same dynamic-tag shape
// DropdownItem uses, so an item that opens a panel is not a link that goes nowhere.
//
// `isActive` renders `aria-current="page"`, which is where the active state lives. There is no
// `data-active` to go with it: ARIA already carries this one, and a theme styles
// `[data-slot="sidebar-item"][aria-current="page"]` (contract rule 4, and the same call
// PaginationLink makes).
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & { href?: string; isActive?: boolean };
const { href, isActive = false, class: className, ...rest } = Astro.props;
const Tag = href ? "a" : "button";
---
<Tag
href={href}
type={href ? undefined : "button"}
aria-current={isActive ? "page" : undefined}
class={className}
data-slot="sidebar-item"
{...rest}
>
<slot />
</Tag>
---
// src/components/ui/navigation/sidebar/SidebarTrigger.astro — Sidebar compound part (see ../../README.md).
// The <summary> that collapses the nav. It is a real button to the platform: focusable, operable
// with Enter and Space, and announced with its own expanded state — none of which has to be written
// here. structure.css suppresses the UA's disclosure marker so the library's one chevron is the only
// one drawn; a theme rotates it from the parent's `[open]`.
//
// It must be the FIRST child of Sidebar. That is <details>'s rule, not ours, and a <summary> anywhere
// else stops being the control.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
import Chevron from "../../_Chevron.astro";
type Props = HTMLAttributes<"summary">;
const { class: className, ...rest } = Astro.props;
---
<summary class={className} data-slot="sidebar-trigger" {...rest}>
<slot />
<slot name="chevron"><Chevron /></slot>
</summary>
import Sidebar from "./Sidebar.astro";
import SidebarContent from "./SidebarContent.astro";
import SidebarFooter from "./SidebarFooter.astro";
import SidebarGroup from "./SidebarGroup.astro";
import SidebarGroupLabel from "./SidebarGroupLabel.astro";
import SidebarItem from "./SidebarItem.astro";
import SidebarTrigger from "./SidebarTrigger.astro";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarItem,
SidebarTrigger,
};
export default Sidebar;
What you get
The component source, copied into your project by npx astrocraft-ui add navigation/sidebar — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.