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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/navigation/sidebar/Sidebar.astro
---
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>
src/components/ui/navigation/sidebar/SidebarContent.astro
---
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
---
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
---
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>
src/components/ui/navigation/sidebar/SidebarGroupLabel.astro
---
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
---
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
---
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.