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.json2. Use it
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
/** @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
{% 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
{# 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
{{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
{{/*
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
<.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
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
<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
<!--
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.
<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.
<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 "Navigation". The drawer slides in; the dim scrim, the X, or Escape close it.
</main>
</div>
</div>API Reference
<Sidebar>
| Prop | Type | Default | Description |
|---|---|---|---|
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="#">. |
label | string | "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> |
current | boolean | false | <SidebarItem> only. Marks the link to the current page — sets aria-current="page" and the active (primary-filled) styling.MDNaria-current |
icon | Child | — | <SidebarItem> only. Optional leading icon rendered before the label. |
ariaLabel | string | — | <Sidebar> only. Accessible name for the <nav> landmark. Give each navigation landmark a unique name when a page has more than one.MDNnavigation role |
ariaLabelledby | string | — | <Sidebar> only. Id of a visible element (e.g. the header) that names the nav landmark, used instead of ariaLabel.MDNaria-labelledby |
--sidebar-w | CSS length | 16rem | Custom property on <SidebarLayout> setting the rail's minimum width in the grid track minmax(--sidebar-w, 20rem).MDNminmax() |
--sidebar-h | CSS length | 100svh | Custom property on <SidebarLayout> setting the shell + rail height. Lower it (e.g. 22rem) to fit a bounded container instead of the full viewport. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required