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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"details"> & {
side?: "left" | "right";
persist?: boolean;
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 {
}
sidebar.addEventListener("toggle", () => {
try {
localStorage.setItem(KEY(sidebar), sidebar.open ? "open" : "closed");
} catch {
}
});
}
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();
});
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>
---
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>
---
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>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
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>
---
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>
---
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>
---
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.