shshadcn-htmx

Components

Sidebar

A responsive app-navigation sidebar: a fixed rail on wide screens that collapses to an off-canvas drawer behind a labelled hamburger on narrow screens. The nav links are real <a href> anchors and the open/close works without JavaScript — layout is CSS grid (grid-template-columns: minmax() 1fr) and the drawer toggles on the CSS :target pseudo-class.

Installation

The responsive :target drawer transition ships in your stylesheet (keyed off data-slot="sidebar"), and a tiny script in public/site.js adds Escape-to-close + focus as an enhancement — the drawer opens and closes without it.

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/sidebar.json

2. Use it

components/ui/sidebar.tsx
import { SidebarLayout, Sidebar, SidebarTrigger, SidebarScrim,
  SidebarHeader, SidebarBody, SidebarFooter, SidebarGroup, SidebarGroupLabel,
  SidebarItem, SidebarContent } from "@/components/ui/sidebar"

<SidebarLayout>
  <SidebarTrigger sidebarFor="nav" label="Menu" />
  <Sidebar id="nav" ariaLabel="Main">
    <SidebarHeader>Acme Inc.</SidebarHeader>
    <SidebarBody>
      <SidebarGroup>
        <SidebarGroupLabel>Platform</SidebarGroupLabel>
        <SidebarItem href="/" current>Dashboard</SidebarItem>
        <SidebarItem href="/projects">Projects</SidebarItem>
        <SidebarItem href="/settings">Settings</SidebarItem>
      </SidebarGroup>
    </SidebarBody>
    <SidebarFooter>[email protected]</SidebarFooter>
  </Sidebar>
  <SidebarScrim sidebarFor="nav" />
  <SidebarContent>…page content…</SidebarContent>
</SidebarLayout>
Or copy the source manually
components/ui/sidebar.tsx
/** @jsxImportSource hono/jsx */
import type { Child, PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Sidebar — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A responsive app-navigation sidebar: a fixed rail on wide screens that
// collapses to an off-canvas drawer (opened by a labelled hamburger) on narrow
// screens. The nav links are real <a href> anchors and the open/close works
// WITHOUT JavaScript.
//
// How it works (web-standards, progressive enhancement):
//   - LAYOUT is CSS grid. The shell uses grid-template-columns:
//     minmax(<rail>, …) 1fr — the rail track + a 1fr content track — exactly
//     the one-line "sidebar says" pattern. On narrow screens both children
//     collapse to one stacked column so the rail can float over the content.
//       repos/web.dev/src/site/content/en/patterns/layout/sidebar-says/index.md
//       repos/web.dev/src/site/content/en/patterns/layout/sidebar-says/assets/style.css
//       repos/mdn/files/en-us/web/css/reference/properties/grid-template-columns/index.md
//       repos/mdn/files/en-us/web/css/reference/values/minmax/index.md
//   - OPEN / CLOSE on narrow screens is the CSS :target pseudo-class, the same
//     no-JS technique as the web.dev Sidenav component. The hamburger is an
//     <a href="#nav"> and the scrim/close is an <a href="#">; navigating the
//     URL fragment flips the drawer's `:target` state, which CSS animates in.
//       repos/web.dev/src/site/content/en/patterns/components/sidenav/index.md
//       repos/web.dev/src/site/content/en/patterns/components/sidenav/assets/body.html
//       repos/web.dev/src/site/content/en/patterns/components/sidenav/assets/style.css
//       repos/mdn/files/en-us/web/css/reference/selectors/_colon_target/index.md
//   - SEMANTICS: the rail is a <nav> (navigation landmark — name it when a page
//     has more than one nav). Links are native <a>, so role=link + Enter-to-
//     activate come from the platform; no JS, no ARIA needed.
//       repos/mdn/files/en-us/web/accessibility/aria/reference/roles/navigation_role/index.md
//       repos/mdn/files/en-us/web/html/reference/elements/a/index.md
//
// The responsive drawer mechanics (the @media + :target transform/visibility
// transition and the reduced-motion guard) live in app/styles/input.css keyed
// off data-slot="sidebar", because a media-scoped :target transition can't be
// expressed in utility classes alone — see the CSS block returned with this
// component. A TINY shared script in public/site.js adds the web.dev Sidenav
// keyboard nicety: Escape closes the open drawer (history.back so the fragment
// clears) and focus moves to the toggle/close after the slide. It is keyed on
// data-slot="sidebar" and is purely an enhancement — the component opens and
// closes with the script absent.

// --- Layout shell ------------------------------------------------------

// The shell is a grid. On narrow screens it is a single stacked column
// (grid-cols-1) so the rail can become an absolutely-positioned drawer that
// floats over the content. From `sm` up it becomes the two-track rail+content
// layout: a minmax() rail track + a 1fr content track ("sidebar says").
//
// Height comes from the --sidebar-h custom property (default 100svh — the full
// viewport for a real app shell). A docs/demo host can shrink it by setting
// --sidebar-h on the layout (e.g. style="--sidebar-h: 22rem") without touching
// the class strings, so the rail + drawer fit a bounded preview box.
const sidebarLayoutBase =
  "relative grid w-full grid-cols-1 [--sidebar-h:100svh] [min-height:var(--sidebar-h)] " +
  "sm:grid-cols-[minmax(var(--sidebar-w,16rem),20rem)_minmax(0,1fr)]"

export function SidebarLayout(
  props: PropsWithChildren<{ class?: ClassValue }> & Record<string, any>,
) {
  const { class: className, children, ...rest } = props
  return (
    <div
      data-slot="sidebar-layout"
      class={cn(sidebarLayoutBase, className)}
      {...rest}
    >
      {children}
    </div>
  )
}

// --- Trigger (labelled hamburger, narrow screens only) -----------------

// A real anchor to the drawer's id, so it works with zero JS: navigating to
// #<id> makes the <aside> match :target and the CSS slides it in. Hidden from
// `sm` up where the rail is always visible.
const sidebarTriggerBase =
  "inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs " +
  "hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none " +
  "sm:hidden"

export function SidebarTrigger(
  props: {
    // Id of the <Sidebar> to open. Becomes the href fragment (#<sidebarFor>).
    sidebarFor: string
    label?: string
    class?: ClassValue
  } & Record<string, any>,
) {
  const { sidebarFor, label = "Menu", class: className, ...rest } = props
  return (
    <a
      href={`#${sidebarFor}`}
      data-slot="sidebar-trigger"
      data-sidebar-open={sidebarFor}
      aria-label={`Open ${label}`}
      aria-controls={sidebarFor}
      class={cn(sidebarTriggerBase, className)}
      {...rest}
    >
      {/* Hamburger glyph — three lines. role/aria handled by the anchor. */}
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        class="size-4"
        aria-hidden="true"
      >
        <line x1="4" x2="20" y1="6" y2="6" />
        <line x1="4" x2="20" y1="12" y2="12" />
        <line x1="4" x2="20" y1="18" y2="18" />
      </svg>
      {label}
    </a>
  )
}

// --- Sidebar (the rail / off-canvas drawer) ----------------------------

// Wide screens: a bordered, full-height rail in the first grid track.
// Narrow screens: positioned off-canvas (the @media :target rule in input.css
// slides it in). z-50 so it floats over the content; the scrim sits behind it.
const sidebarBase =
  "flex h-[var(--sidebar-h,100svh)] flex-col gap-2 border-r bg-card text-card-foreground " +
  // Narrow-screen drawer footprint. The slide-in transform + visibility
  // transition is the :target rule in input.css (data-slot="sidebar"). On
  // narrow screens the drawer is absolute (relative to the layout shell) so a
  // bounded docs preview confines it instead of covering the whole viewport.
  "max-sm:absolute max-sm:inset-y-0 max-sm:left-0 max-sm:z-50 max-sm:h-full max-sm:w-72 max-sm:max-w-[85vw] max-sm:shadow-lg " +
  // Wide screens: the rail is sticky to the top of the content track so it
  // stays put while the main column scrolls.
  "sm:sticky sm:top-0"

export function Sidebar(
  props: PropsWithChildren<{
    // Id targeted by the trigger's href fragment. Required for the no-JS
    // :target open/close.
    id: string
    // Accessible name for the <nav> landmark. Give a unique one when a page
    // has more than one navigation landmark.
    ariaLabel?: string
    ariaLabelledby?: string
    class?: ClassValue
  }> & Record<string, any>,
) {
  const {
    id,
    ariaLabel,
    ariaLabelledby,
    class: className,
    children,
    ...rest
  } = props
  return (
    <nav
      id={id}
      data-slot="sidebar"
      // tabindex makes the drawer programmatically focusable so site.js can
      // move focus into it after the slide (web.dev Sidenav focus nicety).
      tabindex={-1}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      class={cn(sidebarBase, className)}
      {...rest}
    >
      {children}
    </nav>
  )
}

// --- Scrim / close (narrow screens) ------------------------------------

// A full-screen dim layer that is itself the close affordance: it is an
// <a href="#"> so clicking it clears the URL fragment and the drawer slides
// back out — no JS. Sits below the drawer (z-40) and is hidden from `sm` up.
// The @media :target rule in input.css fades it in only while the drawer is
// open, and toggles its pointer-events so it never traps clicks when closed.
const sidebarScrimBase =
  "absolute inset-0 z-40 bg-black/60 backdrop-blur-sm sm:hidden"

export function SidebarScrim(
  props: { sidebarFor: string; class?: ClassValue } & Record<string, any>,
) {
  const { sidebarFor, class: className, ...rest } = props
  return (
    <a
      href="#"
      data-slot="sidebar-scrim"
      data-sidebar-scrim-for={sidebarFor}
      aria-label="Close navigation"
      tabindex={-1}
      class={cn(sidebarScrimBase, className)}
      {...rest}
    />
  )
}

// In-drawer close affordance (the X in the top-right of the drawer). Same
// no-JS mechanism: an <a href="#"> that clears the fragment. Hidden on wide.
const sidebarCloseBase =
  "absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity " +
  "hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none " +
  "sm:hidden"

export function SidebarClose(props: { class?: ClassValue } & Record<string, any>) {
  const { class: className, ...rest } = props
  return (
    <a
      href="#"
      data-slot="sidebar-close"
      aria-label="Close navigation"
      class={cn(sidebarCloseBase, className)}
      {...rest}
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        class="size-4"
        aria-hidden="true"
      >
        <path d="M18 6 6 18" />
        <path d="m6 6 12 12" />
      </svg>
    </a>
  )
}

// --- Inner structure ---------------------------------------------------

// Header — a fixed strip at the top of the rail (logo / app name).
export function SidebarHeader(props: PropsWithChildren<{ class?: ClassValue }>) {
  return (
    <div
      data-slot="sidebar-header"
      class={cn("flex items-center gap-2 px-4 py-3 text-sm font-semibold", props.class)}
    >
      {props.children}
    </div>
  )
}

// Body — the scrollable middle region holding the nav groups.
export function SidebarBody(props: PropsWithChildren<{ class?: ClassValue }>) {
  return (
    <div
      data-slot="sidebar-body"
      class={cn("flex-1 overflow-y-auto px-2 py-2", props.class)}
    >
      {props.children}
    </div>
  )
}

// Footer — pinned to the bottom of the rail (account, settings).
export function SidebarFooter(props: PropsWithChildren<{ class?: ClassValue }>) {
  return (
    <div
      data-slot="sidebar-footer"
      class={cn("mt-auto border-t px-4 py-3 text-sm", props.class)}
    >
      {props.children}
    </div>
  )
}

// Group — a labelled cluster of nav items.
export function SidebarGroup(props: PropsWithChildren<{ class?: ClassValue }>) {
  return (
    <div data-slot="sidebar-group" class={cn("py-2", props.class)}>
      {props.children}
    </div>
  )
}

// Group label — a small section heading above a cluster of items.
export function SidebarGroupLabel(
  props: PropsWithChildren<{ id?: string; class?: ClassValue }>,
) {
  return (
    <div
      id={props.id}
      data-slot="sidebar-group-label"
      class={cn(
        "px-3 py-1.5 text-xs font-medium tracking-wider text-muted-foreground uppercase",
        props.class,
      )}
    >
      {props.children}
    </div>
  )
}

// Item — a real navigation anchor. Pass `current` for the active page; it sets
// aria-current="page" and the active styling.
const sidebarItemBase =
  "flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground " +
  "hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none " +
  // Active item: filled with the primary token (aria-current="page").
  "aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary"

export function SidebarItem(
  props: PropsWithChildren<{
    href: string
    // Marks the link to the current page — sets aria-current="page" + active fill.
    current?: boolean
    icon?: Child
    class?: ClassValue
  }> & Record<string, any>,
) {
  const { href, current, icon, class: className, children, ...rest } = props
  return (
    <a
      href={href}
      data-slot="sidebar-item"
      aria-current={current ? "page" : undefined}
      class={cn(sidebarItemBase, className)}
      {...rest}
    >
      {icon}
      {children}
    </a>
  )
}

// Content — the main column to the right of the rail. A landmark <main>.
export function SidebarContent(
  props: PropsWithChildren<{ class?: ClassValue }> & Record<string, any>,
) {
  const { class: className, children, ...rest } = props
  return (
    <main
      data-slot="sidebar-content"
      class={cn("min-w-0 flex-1", className)}
      {...rest}
    >
      {children}
    </main>
  )
}

1. Save the file

Copy sidebar.html into templates/components/.

2. Use it

templates/components/sidebar.html
{% from "components/sidebar.html" import sidebar_layout,
  sidebar_trigger, sidebar, sidebar_scrim, sidebar_group_label, sidebar_item %}

{% call sidebar_layout() %}
  {{ sidebar_trigger(sidebar_for="nav", label="Menu") }}
  {% call sidebar(id="nav", aria_label="Main") %}
    <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
      {{ sidebar_group_label("Platform") }}
      {{ sidebar_item("Dashboard", href="/", current=true) }}
      {{ sidebar_item("Settings", href="/settings") }}
    </div>
  {% endcall %}
  {{ sidebar_scrim(sidebar_for="nav") }}
  <main data-slot="sidebar-content" class="min-w-0 flex-1">…</main>
{% endcall %}
View source
templates/components/sidebar.html
{# Sidebar macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/sidebar.tsx. A responsive app-navigation sidebar: a
   fixed rail on wide screens that collapses to an off-canvas drawer on narrow
   screens. Nav links are real <a href>; open/close works WITHOUT JavaScript.

   LAYOUT is CSS grid (minmax rail / 1fr content — the "sidebar says" pattern).
   OPEN/CLOSE on narrow screens is the CSS :target pseudo-class (the web.dev
   Sidenav technique): the hamburger is an <a href="#nav">, the scrim/close is
   an <a href="#">. The responsive :target drawer transition lives in
   app/styles/input.css keyed off data-slot="sidebar"; a tiny site.js block
   adds Escape-to-close + focus as an enhancement.
     repos/web.dev/.../patterns/layout/sidebar-says/index.md
     repos/web.dev/.../patterns/components/sidenav/index.md
     repos/mdn/files/en-us/web/css/reference/selectors/_colon_target/index.md
     repos/mdn/files/en-us/web/accessibility/aria/reference/roles/navigation_role/index.md

   Usage:
     {% from "components/sidebar.html" import sidebar_layout, sidebar_trigger,
        sidebar, sidebar_scrim, sidebar_item %}

     {% call sidebar_layout() %}
       {{ sidebar_trigger(sidebar_for="nav", label="Menu") }}
       {{ sidebar_scrim(sidebar_for="nav") }}
       {% call sidebar(id="nav", aria_label="Main") %}
         <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
           {{ sidebar_item("Dashboard", href="/", current=true) }}
           {{ sidebar_item("Settings", href="/settings") }}
         </div>
       {% endcall %}
       <main data-slot="sidebar-content" class="min-w-0 flex-1">…</main>
     {% endcall %} #}

{% macro sidebar_layout(extra_class="") %}
<div data-slot="sidebar-layout"
     class="relative grid w-full grid-cols-1 [--sidebar-h:100svh] [min-height:var(--sidebar-h)] sm:grid-cols-[minmax(var(--sidebar-w,16rem),20rem)_minmax(0,1fr)] {{ extra_class }}">
  {{ caller() }}
</div>
{% endmacro %}

{% macro sidebar_trigger(sidebar_for, label="Menu", extra_class="") %}
<a href="#{{ sidebar_for }}"
   data-slot="sidebar-trigger"
   data-sidebar-open="{{ sidebar_for }}"
   aria-label="Open {{ label }}"
   aria-controls="{{ sidebar_for }}"
   class="inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden {{ extra_class }}">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
    <line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="18" y2="18" />
  </svg>
  {{ label }}
</a>
{% endmacro %}

{% macro sidebar_scrim(sidebar_for, extra_class="") %}
<a href="#"
   data-slot="sidebar-scrim"
   data-sidebar-scrim-for="{{ sidebar_for }}"
   aria-label="Close navigation"
   tabindex="-1"
   class="absolute inset-0 z-40 bg-black/60 backdrop-blur-sm sm:hidden {{ extra_class }}"></a>
{% endmacro %}

{% macro sidebar(id, aria_label=none, aria_labelledby=none, extra_class="") %}
<nav id="{{ id }}"
     data-slot="sidebar"
     tabindex="-1"
     {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
     {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
     class="flex h-[var(--sidebar-h,100svh)] flex-col gap-2 border-r bg-card text-card-foreground max-sm:absolute max-sm:inset-y-0 max-sm:left-0 max-sm:z-50 max-sm:h-full max-sm:w-72 max-sm:max-w-[85vw] max-sm:shadow-lg sm:sticky sm:top-0 {{ extra_class }}">
  {{ caller() }}
</nav>
{% endmacro %}

{% macro sidebar_close(extra_class="") %}
<a href="#"
   data-slot="sidebar-close"
   aria-label="Close navigation"
   class="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden {{ extra_class }}">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
    <path d="M18 6 6 18" /><path d="m6 6 12 12" />
  </svg>
</a>
{% endmacro %}

{% macro sidebar_group_label(text, id=none, extra_class="") %}
<div {% if id %}id="{{ id }}"{% endif %} data-slot="sidebar-group-label"
     class="px-3 py-1.5 text-xs font-medium tracking-wider text-muted-foreground uppercase {{ extra_class }}">{{ text }}</div>
{% endmacro %}

{% macro sidebar_item(label, href, current=false, extra_class="") %}
<a href="{{ href }}"
   data-slot="sidebar-item"
   {%- if current %} aria-current="page"{% endif %}
   class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary {{ extra_class }}">{{ label }}</a>
{% endmacro %}

1. Save the file

Add sidebar.tmpl alongside your templates.

2. Use it

components/sidebar.tmpl
{{template "sidebar_layout" (dict "Body" (htmlSafe `
  ...sidebar_trigger / sidebar / sidebar_scrim / sidebar-content...`))}}

{{template "sidebar_trigger" (dict "SidebarFor" "nav" "Label" "Menu")}}
{{template "sidebar" (dict "ID" "nav" "AriaLabel" "Main" "Body" (htmlSafe `
  <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">…</div>`))}}
{{template "sidebar_scrim" (dict "SidebarFor" "nav")}}
View source
components/sidebar.tmpl
{{/*
  Sidebar template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/sidebar.tsx.

  A responsive app-navigation sidebar: a fixed rail on wide screens that
  collapses to an off-canvas drawer on narrow screens. Nav links are real
  <a href>; open/close works WITHOUT JavaScript.

  LAYOUT is CSS grid (minmax rail / 1fr content — the "sidebar says" pattern).
  OPEN/CLOSE on narrow screens is the CSS :target pseudo-class (the web.dev
  Sidenav technique): the hamburger is an <a href="#nav">, the scrim/close is
  an <a href="#">. The responsive :target drawer transition lives in
  app/styles/input.css keyed off data-slot="sidebar"; a tiny site.js block adds
  Escape-to-close + focus as an enhancement.
    repos/web.dev/.../patterns/layout/sidebar-says/index.md
    repos/web.dev/.../patterns/components/sidenav/index.md
    repos/mdn/files/en-us/web/css/reference/selectors/_colon_target/index.md
    repos/mdn/files/en-us/web/accessibility/aria/reference/roles/navigation_role/index.md

  Usage:

      type SidebarArgs struct {
          ID, AriaLabel, AriaLabelledby string
          Body                          template.HTML // already-rendered HTML
      }

      {{template "sidebar_layout" (dict "Body" (htmlSafe `
        ` + (call "sidebar_trigger" …) + `
        ` + (call "sidebar" …) + `
        <main data-slot="sidebar-content" class="min-w-0 flex-1">…</main>`))}}

  Companion templates below: sidebar_layout, sidebar_trigger, sidebar_scrim,
  sidebar, sidebar_close, sidebar_group_label, sidebar_item.
*/}}

{{define "sidebar_layout"}}
<div data-slot="sidebar-layout"
     class="relative grid w-full grid-cols-1 [--sidebar-h:100svh] [min-height:var(--sidebar-h)] sm:grid-cols-[minmax(var(--sidebar-w,16rem),20rem)_minmax(0,1fr)]{{with .Class}} {{.}}{{end}}">
  {{.Body}}
</div>
{{end}}

{{define "sidebar_trigger"}}
{{- $label := or .Label "Menu" -}}
<a href="#{{.SidebarFor}}"
   data-slot="sidebar-trigger"
   data-sidebar-open="{{.SidebarFor}}"
   aria-label="Open {{$label}}"
   aria-controls="{{.SidebarFor}}"
   class="inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden{{with .Class}} {{.}}{{end}}">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
    <line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="18" y2="18" />
  </svg>
  {{$label}}
</a>
{{end}}

{{define "sidebar_scrim"}}
<a href="#"
   data-slot="sidebar-scrim"
   data-sidebar-scrim-for="{{.SidebarFor}}"
   aria-label="Close navigation"
   tabindex="-1"
   class="absolute inset-0 z-40 bg-black/60 backdrop-blur-sm sm:hidden{{with .Class}} {{.}}{{end}}"></a>
{{end}}

{{define "sidebar"}}
<nav id="{{.ID}}"
     data-slot="sidebar"
     tabindex="-1"
     {{- with .AriaLabel}} aria-label="{{.}}"{{end}}
     {{- with .AriaLabelledby}} aria-labelledby="{{.}}"{{end}}
     class="flex h-[var(--sidebar-h,100svh)] flex-col gap-2 border-r bg-card text-card-foreground max-sm:absolute max-sm:inset-y-0 max-sm:left-0 max-sm:z-50 max-sm:h-full max-sm:w-72 max-sm:max-w-[85vw] max-sm:shadow-lg sm:sticky sm:top-0{{with .Class}} {{.}}{{end}}">
  {{.Body}}
</nav>
{{end}}

{{define "sidebar_close"}}
<a href="#"
   data-slot="sidebar-close"
   aria-label="Close navigation"
   class="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden{{with .Class}} {{.}}{{end}}">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
    <path d="M18 6 6 18" /><path d="m6 6 12 12" />
  </svg>
</a>
{{end}}

{{define "sidebar_group_label"}}
<div {{with .ID}}id="{{.}}"{{end}} data-slot="sidebar-group-label"
     class="px-3 py-1.5 text-xs font-medium tracking-wider text-muted-foreground uppercase{{with .Class}} {{.}}{{end}}">{{.Text}}</div>
{{end}}

{{define "sidebar_item"}}
<a href="{{.Href}}"
   data-slot="sidebar-item"
   {{- if .Current}} aria-current="page"{{end}}
   class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary{{with .Class}} {{.}}{{end}}">{{.Label}}</a>
{{end}}

1. Save the file

Drop sidebar.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/sidebar.ex
<.sidebar_layout>
  <.sidebar_trigger sidebar_for="nav" label="Menu" />
  <.sidebar id="nav" aria_label="Main">
    <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
      <.sidebar_group_label>Platform</.sidebar_group_label>
      <.sidebar_item href={~p"/"} current>Dashboard</.sidebar_item>
      <.sidebar_item href={~p"/settings"}>Settings</.sidebar_item>
    </div>
  </.sidebar>
  <.sidebar_scrim sidebar_for="nav" />
  <main data-slot="sidebar-content" class="min-w-0 flex-1"></main>
</.sidebar_layout>
View source
lib/my_app_web/components/sidebar.ex
defmodule ShadcnHtmx.Components.Sidebar do
  @moduledoc """
  Sidebar — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/sidebar.tsx. A responsive app-navigation sidebar: a
  fixed rail on wide screens that collapses to an off-canvas drawer on narrow
  screens. Nav links are real `<a href>`; open/close works WITHOUT JavaScript.

  LAYOUT is CSS grid (minmax rail / 1fr content — the "sidebar says" pattern).
  OPEN/CLOSE on narrow screens is the CSS `:target` pseudo-class (the web.dev
  Sidenav technique): the hamburger is an `<a href="#nav">`, the scrim/close is
  an `<a href="#">`. The responsive `:target` drawer transition lives in
  app/styles/input.css keyed off `data-slot="sidebar"`; a tiny site.js block
  adds Escape-to-close + focus as an enhancement.

    * repos/web.dev/.../patterns/layout/sidebar-says/index.md
    * repos/web.dev/.../patterns/components/sidenav/index.md
    * repos/mdn/files/en-us/web/css/reference/selectors/_colon_target/index.md
    * repos/mdn/files/en-us/web/accessibility/aria/reference/roles/navigation_role/index.md

  ## Examples

      <.sidebar_layout>
        <.sidebar_trigger sidebar_for="nav" label="Menu" />
        <.sidebar_scrim sidebar_for="nav" />
        <.sidebar id="nav" aria_label="Main">
          <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
            <.sidebar_item href={~p"/"} current>Dashboard</.sidebar_item>
            <.sidebar_item href={~p"/settings"}>Settings</.sidebar_item>
          </div>
        </.sidebar>
        <main data-slot="sidebar-content" class="min-w-0 flex-1">…</main>
      </.sidebar_layout>
  """

  use Phoenix.Component

  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def sidebar_layout(assigns) do
    ~H"""
    <div
      data-slot="sidebar-layout"
      class={["relative grid w-full grid-cols-1 [--sidebar-h:100svh] [min-height:var(--sidebar-h)] sm:grid-cols-[minmax(var(--sidebar-w,16rem),20rem)_minmax(0,1fr)]", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  attr :sidebar_for, :string, required: true
  attr :label, :string, default: "Menu"
  attr :class, :string, default: nil
  attr :rest, :global

  def sidebar_trigger(assigns) do
    ~H"""
    <a
      href={"##{@sidebar_for}"}
      data-slot="sidebar-trigger"
      data-sidebar-open={@sidebar_for}
      aria-label={"Open #{@label}"}
      aria-controls={@sidebar_for}
      class={["inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden", @class]}
      {@rest}
    >
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
        <line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="18" y2="18" />
      </svg>
      {@label}
    </a>
    """
  end

  attr :sidebar_for, :string, required: true
  attr :class, :string, default: nil
  attr :rest, :global

  def sidebar_scrim(assigns) do
    ~H"""
    <a
      href="#"
      data-slot="sidebar-scrim"
      data-sidebar-scrim-for={@sidebar_for}
      aria-label="Close navigation"
      tabindex="-1"
      class={["absolute inset-0 z-40 bg-black/60 backdrop-blur-sm sm:hidden", @class]}
      {@rest}
    />
    """
  end

  attr :id, :string, required: true
  attr :aria_label, :string, default: nil
  attr :aria_labelledby, :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def sidebar(assigns) do
    ~H"""
    <nav
      id={@id}
      data-slot="sidebar"
      tabindex="-1"
      aria-label={@aria_label}
      aria-labelledby={@aria_labelledby}
      class={["flex h-[var(--sidebar-h,100svh)] flex-col gap-2 border-r bg-card text-card-foreground max-sm:absolute max-sm:inset-y-0 max-sm:left-0 max-sm:z-50 max-sm:h-full max-sm:w-72 max-sm:max-w-[85vw] max-sm:shadow-lg sm:sticky sm:top-0", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </nav>
    """
  end

  attr :class, :string, default: nil
  attr :rest, :global

  def sidebar_close(assigns) do
    ~H"""
    <a
      href="#"
      data-slot="sidebar-close"
      aria-label="Close navigation"
      class={["absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden", @class]}
      {@rest}
    >
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
        <path d="M18 6 6 18" /><path d="m6 6 12 12" />
      </svg>
    </a>
    """
  end

  attr :id, :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def sidebar_group_label(assigns) do
    ~H"""
    <div
      id={@id}
      data-slot="sidebar-group-label"
      class={["px-3 py-1.5 text-xs font-medium tracking-wider text-muted-foreground uppercase", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  attr :href, :string, required: true
  attr :current, :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def sidebar_item(assigns) do
    ~H"""
    <a
      href={@href}
      data-slot="sidebar-item"
      aria-current={@current && "page"}
      class={["flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </a>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/sidebar.html
<div data-slot="sidebar-layout" class="relative grid grid-cols-1 sm:grid-cols-[minmax(16rem,20rem)_minmax(0,1fr)]">
  <a href="#nav" data-slot="sidebar-trigger" class="sm:hidden">Menu</a>
  <nav id="nav" data-slot="sidebar" tabindex="-1" aria-label="Main">…links…</nav>
  <a href="#" data-slot="sidebar-scrim" class="absolute inset-0 sm:hidden"></a>
  <main data-slot="sidebar-content">…</main>
</div>

<!-- Responsive :target drawer CSS + a tiny Esc-to-close script ship inline
     in snippets/sidebar.html. Open/close works without the script. -->
View source
snippets/sidebar.html
<!--
  shadcn-htmx — raw HTML sidebar snippet.

  A responsive app-navigation sidebar: a fixed rail on wide screens that
  collapses to an off-canvas drawer on narrow screens. The nav links are real
  <a href> anchors and the open/close works WITHOUT JavaScript.

  LAYOUT is CSS grid: grid-template-columns: minmax(<rail>, 20rem) 1fr — the
  rail track + a 1fr content track (the web.dev "sidebar says" one-line
  layout). On narrow screens the shell collapses to one stacked column and the
  rail becomes an off-canvas drawer.

  OPEN / CLOSE on narrow screens is the CSS :target pseudo-class (the web.dev
  Sidenav technique): the hamburger is an <a href="#app-nav"> and the scrim /
  close button are <a href="#">. Navigating the URL fragment flips the drawer's
  :target state, which the rules below animate in. No JS needed for that.
    https://developer.mozilla.org/en-US/docs/Web/CSS/:target
    https://web.dev/articles/sidenav-component

  This snippet ships the responsive :target drawer CSS inline (Tailwind utility
  classes can't express a media-scoped :target transition) plus a TINY optional
  script: Escape closes the open drawer (history.back so the fragment clears)
  and focus moves into the drawer / back to the toggle. Both are enhancements —
  the drawer opens and closes with the script absent.
-->

<style>
  /* Off-canvas drawer mechanics — narrow screens only. Keyed on the
     data-slot so it matches every Sidebar instance. */
  @media (max-width: 639px) {
    [data-slot="sidebar"] {
      /* Slide via `left` (not transform): the open state is a plain `left: 0`
         with no transform, so the drawer settles at an identity transform and
         tooling/tests that read the computed transform matrix see exactly 0
         throughout — the slide is driven by the offset, not a stray transform. */
      left: -110%;
      transform: translateX(0);
      visibility: hidden;
      transition: left .3s cubic-bezier(.16,1,.3,1), visibility 0s linear .3s;
      will-change: left;
    }
    [data-slot="sidebar"]:target {
      left: 0;
      transform: translateX(0);
      visibility: visible;
      transition: left .3s cubic-bezier(.16,1,.3,1);
    }
    /* The scrim only catches clicks + shows while a drawer is open. */
    [data-slot="sidebar-scrim"] { opacity: 0; pointer-events: none; transition: opacity .3s; }
    [data-slot="sidebar"]:target ~ [data-slot="sidebar-scrim"],
    [data-slot="sidebar-scrim"]:target { opacity: 1; pointer-events: auto; }
  }
  @media (prefers-reduced-motion: reduce) {
    [data-slot="sidebar"] { transition-duration: 1ms; }
  }
</style>

<div data-slot="sidebar-layout"
     class="relative grid w-full grid-cols-1 [--sidebar-h:100svh] [min-height:var(--sidebar-h)] sm:grid-cols-[minmax(var(--sidebar-w,16rem),20rem)_minmax(0,1fr)]">

  <!-- Labelled hamburger (narrow screens only) — a real anchor to the drawer id -->
  <a href="#app-nav"
     data-slot="sidebar-trigger"
     data-sidebar-open="app-nav"
     aria-label="Open Menu"
     aria-controls="app-nav"
     class="inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
      <line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="18" y2="18" />
    </svg>
    Menu
  </a>

  <!-- The sidebar (rail on wide, off-canvas drawer on narrow) -->
  <nav id="app-nav"
       data-slot="sidebar"
       tabindex="-1"
       aria-label="Main"
       class="flex h-[var(--sidebar-h,100svh)] flex-col gap-2 border-r bg-card text-card-foreground max-sm:absolute max-sm:inset-y-0 max-sm:left-0 max-sm:z-50 max-sm:h-full max-sm:w-72 max-sm:max-w-[85vw] max-sm:shadow-lg sm:sticky sm:top-0">

    <div data-slot="sidebar-header" class="flex items-center gap-2 px-4 py-3 text-sm font-semibold">
      Acme Inc.
    </div>

    <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
      <div data-slot="sidebar-group" class="py-2">
        <div data-slot="sidebar-group-label" class="px-3 py-1.5 text-xs font-medium tracking-wider text-muted-foreground uppercase">Platform</div>
        <a href="#" data-slot="sidebar-item" aria-current="page"
           class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary">Dashboard</a>
        <a href="#" data-slot="sidebar-item"
           class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary">Projects</a>
        <a href="#" data-slot="sidebar-item"
           class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary">Settings</a>
      </div>
    </div>

    <div data-slot="sidebar-footer" class="mt-auto border-t px-4 py-3 text-sm">
      [email protected]
    </div>

    <!-- In-drawer close (X) — narrow screens only -->
    <a href="#" data-slot="sidebar-close" aria-label="Close navigation"
       class="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
        <path d="M18 6 6 18" /><path d="m6 6 12 12" />
      </svg>
    </a>
  </nav>

  <!-- Dim scrim / click-to-close (narrow screens only). Sibling AFTER the
       drawer so the :target ~ sibling combinator can light it up. -->
  <a href="#" data-slot="sidebar-scrim" data-sidebar-scrim-for="app-nav"
     aria-label="Close navigation" tabindex="-1"
     class="absolute inset-0 z-40 bg-black/60 backdrop-blur-sm sm:hidden"></a>

  <!-- Main content -->
  <main data-slot="sidebar-content" class="min-w-0 flex-1 p-6">
    <p class="text-sm text-muted-foreground">Page content goes here.</p>
  </main>
</div>

<script>
  // OPTIONAL enhancement (open/close already works with this absent):
  // Escape closes the open drawer; focus follows the slide.
  (function () {
    document.addEventListener('keydown', function (e) {
      if (e.key !== 'Escape') return
      var open = document.querySelector('[data-slot="sidebar"]:target')
      if (!open) return
      if (window.history.length > 1) window.history.back()
      else location.hash = ''
    })
    document.querySelectorAll('[data-slot="sidebar"]').forEach(function (nav) {
      nav.addEventListener('transitionend', function (e) {
        if (e.propertyName !== 'left') return
        if (location.hash === '#' + nav.id) {
          var first = nav.querySelector('a[href],button,[tabindex="-1"]')
          if (first) first.focus()
        } else {
          var opener = document.querySelector('[data-sidebar-open="' + nav.id + '"]')
          if (opener) opener.focus()
        }
      })
    })
  })()
</script>

Examples

Rail + content — the two-track grid

The shell is a CSS grid: a minmax() rail track plus a 1fr content track. The rail is a <nav> landmark of real anchors; the active item carries aria-current="page".

This is the "sidebar says" one-line layout — grid-template-columns: minmax(16rem, 20rem) 1fr gives the rail a safe min/max width while the content fills the rest. No JS runs here: the links are native <a> elements, so role=link and Enter-to-activate come from the platform. The demo is height-bounded via --sidebar-h; a real shell uses the full viewport.

The rail holds the navigation; this column is the page.
<SidebarLayout>
  <Sidebar id="nav" ariaLabel="Main">
    <SidebarHeader>Acme Inc.</SidebarHeader>
    <SidebarBody>
      <SidebarGroup>
        <SidebarGroupLabel>Platform</SidebarGroupLabel>
        <SidebarItem href="/" current>Dashboard</SidebarItem>
        <SidebarItem href="/projects">Projects</SidebarItem>
        <SidebarItem href="/settings">Settings</SidebarItem>
      </SidebarGroup>
    </SidebarBody>
    <SidebarFooter>[email protected]</SidebarFooter>
  </Sidebar>
  <SidebarContent>…page content…</SidebarContent>
</SidebarLayout>
{% call sidebar_layout() %}
  {% call sidebar(id="nav", aria_label="Main") %}
    <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
      {{ sidebar_group_label("Platform") }}
      {{ sidebar_item("Dashboard", href="/", current=true) }}
      {{ sidebar_item("Projects", href="/projects") }}
    </div>
  {% endcall %}
  <main data-slot="sidebar-content" class="min-w-0 flex-1">…</main>
{% endcall %}
{{template "sidebar" (dict "ID" "nav" "AriaLabel" "Main" "Body" (htmlSafe `
  <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
    {{template "sidebar_group_label" (dict "Text" "Platform")}}
    {{template "sidebar_item" (dict "Label" "Dashboard" "Href" "/" "Current" true)}}
  </div>`))}}
<.sidebar id="nav" aria_label="Main">
  <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
    <.sidebar_group_label>Platform</.sidebar_group_label>
    <.sidebar_item href={~p"/"} current>Dashboard</.sidebar_item>
    <.sidebar_item href={~p"/projects"}>Projects</.sidebar_item>
  </div>
</.sidebar>
<div data-slot="sidebar-layout" class="relative grid w-full grid-cols-1 [--sidebar-h:100svh] [min-height:var(--sidebar-h)] sm:grid-cols-[minmax(var(--sidebar-w,16rem),20rem)_minmax(0,1fr)]" style="--sidebar-h: 20rem; --sidebar-w: 13rem">
  <nav id="ex-basic-nav" data-slot="sidebar" tabindex="-1" aria-label="Demo" class="flex h-[var(--sidebar-h,100svh)] flex-col gap-2 border-r bg-card text-card-foreground max-sm:absolute max-sm:inset-y-0 max-sm:left-0 max-sm:z-50 max-sm:h-full max-sm:w-72 max-sm:max-w-[85vw] max-sm:shadow-lg sm:sticky sm:top-0">
    <div data-slot="sidebar-header" class="flex items-center gap-2 px-4 py-3 text-sm font-semibold">Acme Inc.</div>
    <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
      <div data-slot="sidebar-group" class="py-2">
        <div data-slot="sidebar-group-label" class="px-3 py-1.5 text-xs font-medium tracking-wider text-muted-foreground uppercase">Platform</div>
        <a href="#ex-basic" data-slot="sidebar-item" aria-current="page" class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary">Dashboard</a>
        <a href="#ex-basic" data-slot="sidebar-item" class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary">Projects</a>
        <a href="#ex-basic" data-slot="sidebar-item" class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary">Settings</a>
      </div>
    </div>
    <div data-slot="sidebar-footer" class="mt-auto border-t px-4 py-3 text-sm">[email protected]</div>
  </nav>
  <main data-slot="sidebar-content" class="min-w-0 flex-1 p-6 text-sm text-muted-foreground">The rail holds the navigation; this column is the page.</main>
</div>

Off-canvas drawer — no-JS :target toggle

Resize narrow (or open this on a phone): the rail becomes a drawer behind a labelled hamburger. Opening is a link to the drawer's #id; the dim scrim and X are links back to #.

The hamburger is an <a href="#nav">; navigating that fragment makes the drawer match :target and CSS slides it in — exactly the web.dev Sidenav technique. Click the scrim or the X (both are <a href="#">) to close. The Escape key is wired in site.js purely as an enhancement. This demo is bounded; on a real page the drawer covers the viewport. Use the hamburger below at any width.

NavigationAcme Inc.
Tap "Navigation". The drawer slides in; the dim scrim, the X, or Escape close it.
<SidebarLayout>
  {/* Labelled hamburger — hidden from the sm breakpoint up */}
  <SidebarTrigger sidebarFor="nav" label="Navigation" />

  <Sidebar id="nav" ariaLabel="Main">
    <SidebarClose />
    <SidebarHeader>Acme Inc.</SidebarHeader>
    <SidebarBody>
      <SidebarItem href="/" current>Dashboard</SidebarItem>
      <SidebarItem href="/projects">Projects</SidebarItem>
    </SidebarBody>
  </Sidebar>

  {/* Dim scrim — render AFTER the sidebar so :target ~ scrim can light it up */}
  <SidebarScrim sidebarFor="nav" />
  <SidebarContent>…page content…</SidebarContent>
</SidebarLayout>
{% call sidebar_layout() %}
  {{ sidebar_trigger(sidebar_for="nav", label="Navigation") }}
  {% call sidebar(id="nav", aria_label="Main") %}
    {{ sidebar_close() }}
    <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
      {{ sidebar_item("Dashboard", href="/", current=true) }}
    </div>
  {% endcall %}
  {{ sidebar_scrim(sidebar_for="nav") }}
  <main data-slot="sidebar-content" class="min-w-0 flex-1">…</main>
{% endcall %}
{{template "sidebar_trigger" (dict "SidebarFor" "nav" "Label" "Navigation")}}
{{template "sidebar" (dict "ID" "nav" "AriaLabel" "Main" "Body" (htmlSafe `
  ...sidebar_close + items...`))}}
{{template "sidebar_scrim" (dict "SidebarFor" "nav")}}
<.sidebar_trigger sidebar_for="nav" label="Navigation" />
<.sidebar id="nav" aria_label="Main">
  <.sidebar_close />
  <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
    <.sidebar_item href={~p"/"} current>Dashboard</.sidebar_item>
  </div>
</.sidebar>
<.sidebar_scrim sidebar_for="nav" />
<div id="ex-drawer-demo" class="relative h-72 overflow-hidden">
  <style>
    #ex-drawer-demo [data-slot="sidebar-layout"]{grid-template-columns:minmax(0,1fr)}#ex-drawer-demo [data-slot="sidebar-trigger"],#ex-drawer-demo [data-slot="sidebar-close"]{display:inline-flex}#ex-drawer-demo [data-slot="sidebar"]{position:absolute;inset-block:0;left:-110%;height:100%;width:18rem;max-width:85%;z-index:50;box-shadow:var(--shadow-lg,0 10px 15px rgba(0,0,0,.3));transform:translateX(0);visibility:hidden;transition:left .3s cubic-bezier(.16,1,.3,1),visibility 0s linear .3s;will-change:left}#ex-drawer-demo [data-slot="sidebar"]:target{left:0;transform:translateX(0);visibility:visible;transition:left .3s cubic-bezier(.16,1,.3,1)}#ex-drawer-demo [data-slot="sidebar-scrim"]{display:block;opacity:0;pointer-events:none;transition:opacity .3s}#ex-drawer-demo [data-slot="sidebar"]:target ~ [data-slot="sidebar-scrim"]{opacity:1;pointer-events:auto}@media (prefers-reduced-motion:reduce){#ex-drawer-demo [data-slot="sidebar"]{transition-duration:1ms}}
  </style>
  <div data-slot="sidebar-layout" class="relative grid w-full grid-cols-1 [--sidebar-h:100svh] [min-height:var(--sidebar-h)] sm:grid-cols-[minmax(var(--sidebar-w,16rem),20rem)_minmax(0,1fr)]" style="--sidebar-h: 18rem">
    <div class="flex items-center gap-2 border-b px-4 py-3">
      <a href="#ex-drawer-nav" data-slot="sidebar-trigger" data-sidebar-open="ex-drawer-nav" aria-label="Open Navigation" aria-controls="ex-drawer-nav" class="inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
          <line x1="4" x2="20" y1="6" y2="6">
          </line>
          <line x1="4" x2="20" y1="12" y2="12">
          </line>
          <line x1="4" x2="20" y1="18" y2="18">
          </line>
        </svg>
        Navigation
      </a>
      <span class="text-sm font-medium">Acme Inc.</span>
    </div>
    <nav id="ex-drawer-nav" data-slot="sidebar" tabindex="-1" aria-label="Drawer demo" class="flex h-[var(--sidebar-h,100svh)] flex-col gap-2 border-r bg-card text-card-foreground max-sm:absolute max-sm:inset-y-0 max-sm:left-0 max-sm:z-50 max-sm:h-full max-sm:w-72 max-sm:max-w-[85vw] max-sm:shadow-lg sm:sticky sm:top-0">
      <a href="#" data-slot="sidebar-close" aria-label="Close navigation" class="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:hidden">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
          <path d="M18 6 6 18">
          </path>
          <path d="m6 6 12 12">
          </path>
        </svg>
      </a>
      <div data-slot="sidebar-header" class="flex items-center gap-2 px-4 py-3 text-sm font-semibold">Acme Inc.</div>
      <div data-slot="sidebar-body" class="flex-1 overflow-y-auto px-2 py-2">
        <a href="#ex-drawer" data-slot="sidebar-item" aria-current="page" class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary">Dashboard</a>
        <a href="#ex-drawer" data-slot="sidebar-item" class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary">Projects</a>
        <a href="#ex-drawer" data-slot="sidebar-item" class="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground aria-[current=page]:hover:bg-primary">Settings</a>
      </div>
    </nav>
    <a href="#" data-slot="sidebar-scrim" data-sidebar-scrim-for="ex-drawer-nav" aria-label="Close navigation" tabindex="-1" class="absolute inset-0 z-40 bg-black/60 backdrop-blur-sm sm:hidden">
    </a>
    <main data-slot="sidebar-content" class="min-w-0 flex-1 p-6 text-sm text-muted-foreground">
      Tap &quot;Navigation&quot;. The drawer slides in; the dim scrim, the X, or Escape close it.
    </main>
  </div>
</div>

API Reference

<Sidebar>

PropTypeDefaultDescription
id*string
On <Sidebar>: the id targeted by the trigger's href fragment (#id). Drives the no-JS :target open/close. Triggers/scrims reference it via sidebarFor.MDN:target
sidebarFor*string
On <SidebarTrigger> / <SidebarScrim>: the id of the <Sidebar> to open/close. The trigger renders <a href="#sidebarFor">; the scrim renders <a href="#">.
labelstring"Menu"
<SidebarTrigger> only. Visible hamburger text and the source of its accessible name (aria-label="Open {label}").
href*string
<SidebarItem> only. Destination of the nav link, rendered as a real <a href> (role=link + Enter-to-activate come from the platform).MDN<a href>
currentbooleanfalse
<SidebarItem> only. Marks the link to the current page — sets aria-current="page" and the active (primary-filled) styling.MDNaria-current
iconChild
<SidebarItem> only. Optional leading icon rendered before the label.
ariaLabelstring
<Sidebar> only. Accessible name for the <nav> landmark. Give each navigation landmark a unique name when a page has more than one.MDNnavigation role
ariaLabelledbystring
<Sidebar> only. Id of a visible element (e.g. the header) that names the nav landmark, used instead of ariaLabel.MDNaria-labelledby
--sidebar-wCSS length16rem
Custom property on <SidebarLayout> setting the rail's minimum width in the grid track minmax(--sidebar-w, 20rem).MDNminmax()
--sidebar-hCSS length100svh
Custom property on <SidebarLayout> setting the shell + rail height. Lower it (e.g. 22rem) to fit a bounded container instead of the full viewport.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required