Skip to main content
astrocraft-ui/ components · 101

Sidebar

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add navigation/sidebar

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add navigation/sidebar --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add navigation/sidebar --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/sidebar --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/sidebar --theme tailwind --bridge lumos

Live 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.

Workspace
v0.4.0

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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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
---
// 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
---
// 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
---
// 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
---
// 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
---
// 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>
src/components/ui/navigation/sidebar/index.ts
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.