Basic Sidebar
A static navigation column - brand, icon-and-label links, and one clearly marked current page.
6 frameworksBeginner
Expandable sections of sub-links built on the disclosure pattern - real triggers, real panels.
<nav class="nsidebar" aria-label="Sidebar">
<ul class="nsidebar__list">
<li>
<a class="nsidebar__link" href="/dashboard" aria-current="page">Dashboard</a>
</li>
<li class="nsidebar__group">
<!--
The disclosure pattern: aria-expanded on the trigger, aria-controls
pointing at the panel it owns. The chevron is decoration; those two
attributes are what communicate state.
-->
<button
class="nsidebar__trigger"
type="button"
aria-expanded="true"
aria-controls="nsidebar-panel-projects"
>
<span>Projects</span>
<svg class="nsidebar__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<ul class="nsidebar__sub" id="nsidebar-panel-projects">
<li><a class="nsidebar__sublink" href="/projects/active">Active</a></li>
<li><a class="nsidebar__sublink" href="/projects/archived">Archived</a></li>
<li><a class="nsidebar__sublink" href="/projects/templates">Templates</a></li>
</ul>
</li>
<li class="nsidebar__group">
<button
class="nsidebar__trigger"
type="button"
aria-expanded="false"
aria-controls="nsidebar-panel-settings"
>
<span>Settings</span>
<svg class="nsidebar__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<ul class="nsidebar__sub" id="nsidebar-panel-settings" hidden>
<li><a class="nsidebar__sublink" href="/settings/general">General</a></li>
<li><a class="nsidebar__sublink" href="/settings/billing">Billing</a></li>
</ul>
</li>
</ul>
</nav>
<script>
document.querySelectorAll('.nsidebar__trigger').forEach(function (trigger) {
trigger.addEventListener('click', function () {
var panel = document.getElementById(trigger.getAttribute('aria-controls'));
var expanded = trigger.getAttribute('aria-expanded') === 'true';
trigger.setAttribute('aria-expanded', String(!expanded));
/*
* hidden, not just height:0. A collapsed panel that is merely invisible
* still hands its links to Tab and to a screen reader - the classic
* half-built disclosure.
*/
panel.hidden = expanded;
});
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | SidebarGroup[] | - | The array of items to render. |
pathnamerequired | string | - | The current path. The item matching it is marked as the active page. |
defaultOpenIds | string[] | [] | Ids of the items expanded on mount. |
allowMultiple | boolean | true | Allows more than one item open at once. |
ariaLabel | string | 'Sidebar' | The button's accessible name. Required for icon-only buttons. |
Each group is a disclosure: `aria-expanded` on the trigger button, `aria-controls` pointing at the `id` of the `<ul>` it owns. The collapsed panel uses the `hidden` attribute rather than `height: 0` or `opacity-0` - an invisible panel that still hands its links to Tab is the classic half-built tree, where a keyboard user falls into sub-links they cannot see. `hidden` removes them from both the tab order and the accessibility tree in one attribute. These are `<ul>`s of links, deliberately **not** `role="tree"`: a tree implies arrow-key traversal and roving tabindex, and if you claim the role without building the behaviour you have made things worse than plain nav. `allowMultiple={false}` turns the groups into an accordion. The indent guide is the sub-list border-left doing double duty - one border, no spacer divs.