Sidebar
A collapsible navigation sidebar with labelled groups and individual items. It uses the same frosted panel surface treatment as the drawer, while still supporting icon-only collapse mode, keyboard navigation, and full ARIA compliance.
Features
- 🗂️ 3 Sub-components:
bit-sidebar,bit-sidebar-group,bit-sidebar-item - 🔄 Collapsible: smooth icon-only mode with animated width transition
- 🎨 3 Variants: default (drawer-style panel), floating (rounded elevated panel), inset (subtle background)
- 🔗 Link or button:
bit-sidebar-itemrenders an<a>whenhrefis set, otherwise a<button> - 📌 Active indicator: visual pill indicator for the current page item
- 🔘 Collapsible groups: native
<details>/<summary>interaction with optional toggle event - ♿ Accessible:
role="navigation",aria-current="page",aria-expanded, keyboard navigation - ⌨️ Imperative API:
setCollapsed(next),toggle()methods on the element
Source Code
View Source Code
import {
computed,
createContext,
defineComponent,
effect,
html,
inject,
onMount,
provide,
signal,
type ReadonlySignal,
watch,
} from '@vielzeug/craftit';
import { chevronLeftIcon, chevronRightIcon } from '../../icons';
import { coarsePointerMixin, reducedMotionMixin } from '../../styles';
// ─── Types ────────────────────────────────────────────────────────────────
type SidebarVariant = 'floating' | 'inset';
type SidebarCollapseSource = 'api' | 'responsive' | 'toggle';
/** Context provided by `bit-sidebar` to its `bit-sidebar-group` and `bit-sidebar-item` children. */
export type SidebarContext = {
collapsed: ReadonlySignal<boolean>;
variant: ReadonlySignal<SidebarVariant | undefined>;
};
/** Injection key for the sidebar context. */
export const SIDEBAR_CTX = createContext<SidebarContext>('SidebarContext');
// ─── bit-sidebar styles ──────────────────────────────────────────────────────
import sidebarStyles from './sidebar.css?inline';
/** bit-sidebar element interface */
export type SidebarElement = HTMLElement &
BitSidebarProps & {
/** Set collapsed state imperatively. */
setCollapsed(next: boolean): void;
/** Toggle between collapsed and expanded. */
toggle(): void;
};
/** Sidebar component properties */
export type BitSidebarEvents = {
'collapsed-change': { collapsed: boolean; source: SidebarCollapseSource };
};
export type BitSidebarGroupEvents = {
'open-change': { open: boolean };
};
export type BitSidebarProps = {
/** Controlled collapsed state */
collapsed?: boolean;
/** Whether the sidebar supports collapsing */
collapsible?: boolean;
/** Initial collapsed state in uncontrolled mode */
'default-collapsed'?: boolean;
/**
* Accessible label for the navigation landmark.
* Use to distinguish multiple navigation regions on a page.
* @default 'Sidebar navigation'
*/
label?: string;
/**
* CSS media query that, when it matches, automatically collapses the sidebar.
* Unset by default — no automatic collapse.
* @example 'responsive="(max-width: 768px)"'
*/
responsive?: string;
/** Visual style variant */
variant?: SidebarVariant;
};
/**
* `bit-sidebar` — A collapsible navigation sidebar with group and item support.
*
* @element bit-sidebar
*
* @attr {boolean} collapsed - Controlled collapsed state
* @attr {boolean} default-collapsed - Initial collapsed state for uncontrolled sidebars
* @attr {boolean} collapsible - Show the collapse toggle button
* @attr {string} variant - Visual variant: 'floating' | 'inset'
* @attr {string} label - Accessible aria-label for the nav landmark
*
* @slot header - Branding or logo content above the nav
* @slot - Navigation content (bit-sidebar-group / bit-sidebar-item)
* @slot footer - Footer content below the nav (user info, settings, etc.)
*
* @fires collapsed-change - Fired when collapsed state changes
*
* @cssprop --sidebar-width - Expanded sidebar width (default: 16rem)
* @cssprop --sidebar-collapsed-width - Collapsed sidebar width (default: 3.5rem)
* @cssprop --sidebar-bg - Sidebar background color
* @cssprop --sidebar-border-color - Border color
*
* @attr {string} responsive - CSS media query that auto-collapses the sidebar when it matches (e.g. '(max-width: 768px)')
*
* @example
* ```html
* <bit-sidebar collapsible label="App navigation">
* <span slot="header">My App</span>
* <bit-sidebar-group label="Main">
* <bit-sidebar-item href="/dashboard" active>Dashboard</bit-sidebar-item>
* <bit-sidebar-item href="/settings">Settings</bit-sidebar-item>
* </bit-sidebar-group>
* </bit-sidebar>
*
* <!-- Auto-collapse on mobile -->
* <bit-sidebar collapsible responsive="(max-width: 768px)">...</bit-sidebar>
* ```
*/
export const SIDEBAR_TAG = defineComponent<BitSidebarProps, BitSidebarEvents>({
props: {
collapsed: { default: undefined, type: Boolean },
collapsible: { default: false, type: Boolean },
'default-collapsed': { default: false, type: Boolean },
label: { default: 'Sidebar navigation' },
responsive: { default: undefined },
variant: { default: undefined },
},
setup({ emit, host, props, slots }) {
const hasHeader = computed(() => slots.has('header').value);
const hasFooter = computed(() => slots.has('footer').value);
const isControlled = signal(host.hasAttribute('collapsed'));
const collapsedState = signal(
isControlled.value ? host.hasAttribute('collapsed') : props['default-collapsed'].value,
);
const isCollapsed = computed(() => collapsedState.value);
provide(SIDEBAR_CTX, {
collapsed: isCollapsed as ReadonlySignal<boolean>,
variant: props.variant as ReadonlySignal<SidebarVariant | undefined>,
});
const setCollapsed = (next: boolean, source: SidebarCollapseSource) => {
if (isCollapsed.value === next) return;
if (!isControlled.value) {
collapsedState.value = next;
}
emit('collapsed-change', { collapsed: next, source });
};
const doToggle = () => {
setCollapsed(!isCollapsed.value, 'toggle');
};
effect(() => {
host.toggleAttribute('data-collapsed', isCollapsed.value);
});
onMount(() => {
const el = host as SidebarElement;
el.setCollapsed = (next) => setCollapsed(Boolean(next), 'api');
el.toggle = doToggle;
let mediaCleanup: (() => void) | undefined;
const observer = new MutationObserver(() => {
if (!host.hasAttribute('collapsed') && !isControlled.value) return;
isControlled.value = true;
collapsedState.value = host.hasAttribute('collapsed');
});
observer.observe(host, {
attributeFilter: ['collapsed'],
attributes: true,
});
watch(
props.responsive,
(query) => {
mediaCleanup?.();
mediaCleanup = undefined;
const mediaQuery = String(query ?? '').trim();
if (!mediaQuery) return;
const mql = window.matchMedia(mediaQuery);
const onChange = (event: MediaQueryListEvent) => {
setCollapsed(event.matches, 'responsive');
};
setCollapsed(mql.matches, 'responsive');
mql.addEventListener('change', onChange);
mediaCleanup = () => {
mql.removeEventListener('change', onChange);
};
},
{ immediate: true },
);
return () => {
observer.disconnect();
mediaCleanup?.();
};
});
return html`
<nav aria-label="${() => props.label.value}" part="nav">
<div class="sidebar-header" part="header" ?hidden=${() => !hasHeader.value && !props.collapsible.value}>
<slot name="header"></slot>
<button
class="toggle-btn"
part="toggle-btn"
type="button"
?hidden=${() => !props.collapsible.value}
aria-label="${() => (isCollapsed.value ? 'Expand sidebar' : 'Collapse sidebar')}"
aria-expanded="${() => String(!isCollapsed.value)}"
@click="${doToggle}">
<span class="toggle-icon" aria-hidden="true">${chevronLeftIcon}</span>
</button>
</div>
<div class="sidebar-content" part="content">
<slot></slot>
</div>
<div class="sidebar-footer" part="footer" ?hidden=${() => !hasFooter.value}>
<slot name="footer"></slot>
</div>
</nav>
`;
},
styles: [coarsePointerMixin, reducedMotionMixin, sidebarStyles],
tag: 'bit-sidebar',
});
// ─── bit-sidebar-group styles ────────────────────────────────────────────────
import groupStyles from './sidebar-group.css?inline';
/** Sidebar group properties */
export type BitSidebarGroupProps = {
/** Whether this group can be collapsed */
collapsible?: boolean;
/** Initial open state in uncontrolled mode */
'default-open'?: boolean;
/** Accessible label for the group */
label?: string;
/** Controlled open state */
open?: boolean;
};
/**
* `bit-sidebar-group` — A labelled section within `bit-sidebar`.
*
* @element bit-sidebar-group
*
* @attr {string} label - Group label text
* @attr {boolean} collapsible - Whether this group can be toggled open/closed
* @attr {boolean} open - Controlled expanded state
* @attr {boolean} default-open - Initial expanded state in uncontrolled mode
*
* @slot - Navigation items (`bit-sidebar-item`)
* @slot icon - Icon displayed before the label
*
* @fires open-change - Fired when the group open state changes (collapsible groups only)
*
* @example
* ```html
* <bit-sidebar-group label="Main" collapsible open>
* <bit-sidebar-item href="/home">Home</bit-sidebar-item>
* </bit-sidebar-group>
* ```
*/
export const SIDEBAR_GROUP_TAG = defineComponent<BitSidebarGroupProps, BitSidebarGroupEvents>({
props: {
collapsible: { default: false, type: Boolean },
'default-open': { default: true, type: Boolean },
label: { default: '' },
open: { default: undefined, type: Boolean },
},
setup({ emit, host, props, slots }) {
const hasIcon = computed(() => slots.has('icon').value);
const sidebarCtx = inject(SIDEBAR_CTX, undefined);
effect(() => {
host.toggleAttribute('sidebar-collapsed', sidebarCtx?.collapsed.value ?? false);
});
const isControlled = computed(() => props.open.value !== undefined);
const openState = signal(props['default-open'].value);
const isOpen = computed(() => {
if (!props.collapsible.value) return true;
if (isControlled.value) return props.open.value ?? false;
return openState.value;
});
watch(props.open, (value) => {
if (value === undefined) return;
openState.value = value;
});
effect(() => {
host.toggleAttribute('open', isOpen.value);
});
const handleGroupClick = (e: MouseEvent) => {
if (!(e.target instanceof HTMLElement) || !e.target.closest('.group-header')) return;
e.stopPropagation();
e.preventDefault();
if (!props.collapsible.value) {
return;
}
const next = !isOpen.value;
if (props.open.value === next) return;
if (!isControlled.value) {
openState.value = next;
}
emit('open-change', { open: next });
};
return html`
<details class="group" part="group" ?open=${() => isOpen.value} @click="${handleGroupClick}">
<summary
class="group-header"
part="group-header"
:aria-expanded="${() => (props.collapsible.value ? String(props.open.value) : null)}">
<span class="group-icon" part="group-icon" ?hidden=${() => !hasIcon.value} aria-hidden="true">
<slot name="icon"></slot>
</span>
<span class="group-label" part="group-label">${() => props.label.value}</span>
<span class="chevron" ?hidden=${() => !props.collapsible.value} aria-hidden="true">${chevronRightIcon}</span>
</summary>
<div class="group-items" part="group-items" role="list">
<slot></slot>
</div>
</details>
`;
},
styles: [reducedMotionMixin, groupStyles],
tag: 'bit-sidebar-group',
});
// ─── bit-sidebar-item styles ─────────────────────────────────────────────────
import itemStyles from './sidebar-item.css?inline';
/** Sidebar item properties */
export type BitSidebarItemProps = {
/** Whether this item represents the current page/section */
active?: boolean;
/** Whether this item is disabled */
disabled?: boolean;
/** Navigation href — renders an `<a>` when set, otherwise a `<button>` */
href?: string;
/**
* Relationship of the linked URL (`rel` attribute on the inner `<a>`).
* Only applies when `href` is set.
*/
rel?: string;
/**
* Browsing context for the link (`target` attribute on the inner `<a>`).
* Only applies when `href` is set.
*/
target?: string;
};
/**
* `bit-sidebar-item` — An individual navigation item in a `bit-sidebar`.
*
* Renders as an `<a>` when `href` is provided, otherwise as a `<button>`.
* Marks the active page via `aria-current="page"` when the `active` attribute is set.
*
* @element bit-sidebar-item
*
* @attr {string} href - Link URL; renders an anchor when set
* @attr {boolean} active - Marks the item as the current page
* @attr {boolean} disabled - Disables the item
* @attr {string} rel - Anchor `rel` attribute (links only)
* @attr {string} target - Anchor `target` attribute (links only)
*
* @slot - Label text
* @slot icon - Leading icon
* @slot end - Trailing content (badge, shortcut, arrow, etc.)
*
* @part item - The inner anchor or button element
* @part item-icon - The icon wrapper
* @part item-label - The label wrapper
* @part item-end - The trailing content wrapper
*
* @cssprop --sidebar-item-color - Default text color
* @cssprop --sidebar-item-hover-bg - Hover background
* @cssprop --sidebar-item-hover-color - Hover text color
* @cssprop --sidebar-item-active-bg - Active background
* @cssprop --sidebar-item-active-color - Active text color
* @cssprop --sidebar-item-indicator - Active indicator bar color
*
* @example
* ```html
* <bit-sidebar-item href="/dashboard" active>
* <span slot="icon">🏠</span>
* Dashboard
* </bit-sidebar-item>
*
* <bit-sidebar-item href="/users">
* <span slot="icon">👤</span>
* Users
* <bit-badge slot="end" color="primary">3</bit-badge>
* </bit-sidebar-item>
* ```
*/
export const SIDEBAR_ITEM_TAG = defineComponent<BitSidebarItemProps>({
props: {
active: { default: false, type: Boolean },
disabled: { default: false, type: Boolean },
href: { default: undefined },
rel: { default: undefined },
target: { default: undefined },
},
setup({ host, props, slots }) {
const hasIcon = computed(() => slots.has('icon').value);
const hasEnd = computed(() => slots.has('end').value);
const sidebarCtx = inject(SIDEBAR_CTX, undefined);
effect(() => {
host.toggleAttribute('sidebar-collapsed', sidebarCtx?.collapsed.value ?? false);
});
const isLink = computed(() => !!props.href.value && !props.disabled.value);
const renderItemContent = () => html`
<span class="item-icon" part="item-icon" ?hidden=${() => !hasIcon.value} aria-hidden="true">
<slot name="icon"></slot>
</span>
<span class="item-label" part="item-label"><slot></slot></span>
<span class="item-end" part="item-end" ?hidden=${() => !hasEnd.value}>
<slot name="end"></slot>
</span>
`;
return html`
${() =>
isLink.value
? html`
<a
class="item"
part="item"
href="${() => props.href.value}"
:rel="${() => props.rel.value ?? null}"
:target="${() => props.target.value ?? null}"
:aria-current="${() => (props.active.value ? 'page' : null)}">
${renderItemContent()}
</a>
`
: html`
<button
class="item"
part="item"
type="button"
:aria-current="${() => (props.active.value ? 'page' : null)}"
:disabled="${() => props.disabled.value || null}">
${renderItemContent()}
</button>
`}
`;
},
styles: [coarsePointerMixin, itemStyles],
tag: 'bit-sidebar-item',
});Basic Usage
Wrap groups and items inside bit-sidebar. Mark the current page with active.
<bit-sidebar label="App navigation">
<bit-sidebar-group label="Main">
<bit-sidebar-item href="/dashboard" active>Dashboard</bit-sidebar-item>
<bit-sidebar-item href="/projects">Projects</bit-sidebar-item>
<bit-sidebar-item href="/settings">Settings</bit-sidebar-item>
</bit-sidebar-group>
</bit-sidebar>
<script type="module">
import '@vielzeug/buildit/sidebar';
</script>Collapsible Sidebar
Add the collapsible attribute to show the collapse toggle button. Items will animate to icon-only mode when collapsed.
Groups
Use bit-sidebar-group to organize items into labelled sections. Add the collapsible attribute to allow toggling the group open/closed.
bit-sidebar-group now uses native details/summary semantics internally for simpler keyboard and accessibility behavior.
Set default-open="false" for uncontrolled groups that start collapsed, or pass open to control the group state externally.
Items with Badges
Use the end slot on bit-sidebar-item for trailing content such as notification counts or keyboard shortcuts.
Variants
Floating
Uses the same drawer-inspired panel surface with a stronger floating presentation, rounded corners, and more separation from the page background.
Inset
A subtle variant with a slightly tinted background and no visible border or elevated panel shadow — blends naturally into page content areas.
Header and Footer Slots
Use slot="header" for branding (logo, app name) and slot="footer" for user profile or secondary actions.
Disabled Items
Set disabled on a bit-sidebar-item to prevent interaction.
External Links
When linking to external resources, use target and rel attributes on bit-sidebar-item.
<bit-sidebar-item href="https://example.com" target="_blank" rel="noopener noreferrer">
<span slot="icon">🌐</span>
External Docs
</bit-sidebar-item>Imperative API
Collapse methods are exposed on the element instance.
const sidebar = document.querySelector('bit-sidebar');
sidebar.setCollapsed(true); // collapse to icon-only
sidebar.setCollapsed(false); // expand to full width
sidebar.toggle(); // toggle between statesEvents
const sidebar = document.querySelector('bit-sidebar');
sidebar.addEventListener('collapsed-change', (e) => {
console.log('Collapsed:', e.detail.collapsed, 'source:', e.detail.source);
});
const group = document.querySelector('bit-sidebar-group[collapsible]');
group.addEventListener('toggle', (e) => {
console.log('Group open:', e.detail.open);
});CSS Customization
Global Variables
Override these CSS custom properties in your stylesheet to restyle the sidebar globally:
bit-sidebar {
--sidebar-width: 18rem; /* expanded width */
--sidebar-collapsed-width: 4rem; /* collapsed width */
--sidebar-bg: var(--color-canvas); /* sidebar background */
--sidebar-border-color: var(--color-contrast-300);
}
bit-sidebar-item {
--sidebar-item-color: var(--color-contrast-700);
--sidebar-item-hover-bg: var(--color-contrast-100);
--sidebar-item-hover-color: var(--color-contrast-900);
--sidebar-item-active-bg: color-mix(in srgb, var(--color-primary) 12%, transparent);
--sidebar-item-active-color: var(--color-primary);
--sidebar-item-indicator: var(--color-primary);
}API Reference
bit-sidebar Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
collapsed | boolean | — | Controlled collapsed state |
default-collapsed | boolean | false | Initial collapsed state in uncontrolled mode |
collapsible | boolean | false | Shows the collapse/expand toggle button in the header |
variant | string | — | Visual variant: 'floating' | 'inset' |
label | string | 'Sidebar navigation' | aria-label for the <nav> landmark |
bit-sidebar Slots
| Slot | Description |
|---|---|
header | Branding, logo, or app name — displayed at the top |
| (default) | bit-sidebar-group or bit-sidebar-item elements |
footer | User info, theme toggles, or secondary actions |
bit-sidebar Events
| Event | Detail | Description |
|---|---|---|
collapsed-change | { collapsed: boolean; source: 'toggle' | 'responsive' | 'api' } | Fired when a collapse state change is requested |
bit-sidebar Methods
| Method | Description |
|---|---|
setCollapsed(next) | Set collapsed state |
toggle() | Toggle between collapsed / expanded |
bit-sidebar CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--sidebar-width | Expanded width | 16rem |
--sidebar-collapsed-width | Collapsed (icon-only) width | 3.5rem |
--sidebar-bg | Sidebar background color | canvas |
--sidebar-border-color | Border / divider color | contrast |
bit-sidebar-group Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
label | string | '' | Visible group label text |
collapsible | boolean | false | Adds a toggle button to collapse/expand the group's items |
default-open | boolean | true | Initial open state for uncontrolled collapsible groups |
open | boolean | — | Controlled group open state |
bit-sidebar-group Slots
| Slot | Description |
|---|---|
icon | Optional icon displayed in the group header |
| (default) | bit-sidebar-item elements |
bit-sidebar-group Events
| Event | Detail | Description |
|---|---|---|
toggle | { open: boolean } | Fired when a collapsible group is toggled |
bit-sidebar-item Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
href | string | — | URL — renders an <a> when set, otherwise a <button> |
active | boolean | false | Marks the item as the current page (aria-current="page") |
disabled | boolean | false | Disables the item and forces button rendering |
rel | string | — | rel attribute on the inner <a> (link items only) |
target | string | — | target attribute on the inner <a> (link items only) |
bit-sidebar-item Slots
| Slot | Description |
|---|---|
icon | Leading icon (hidden from assistive tech) |
| (default) | Item label text |
end | Trailing content: badge, shortcut key, chevron, etc. |
bit-sidebar-item Parts
| Part | Description |
|---|---|
item | The inner <a> or <button> element |
item-icon | The icon slot wrapper |
item-label | The label text wrapper |
item-end | The trailing content wrapper |
bit-sidebar-item CSS Custom Properties
| Property | Description |
|---|---|
--sidebar-item-color | Default text color |
--sidebar-item-hover-bg | Background on hover |
--sidebar-item-hover-color | Text color on hover |
--sidebar-item-active-bg | Background when active |
--sidebar-item-active-color | Text color when active |
--sidebar-item-indicator | Active state indicator bar color |
Accessibility
The sidebar follows WAI-ARIA navigation patterns and WCAG 2.2 guidelines.
Navigation Landmark
The bit-sidebar element renders a <nav> landmark with an aria-label. When a page has multiple navigation regions, ensure each has a unique descriptive label:
<bit-sidebar label="Main navigation">…</bit-sidebar> <bit-sidebar label="Documentation sidebar">…</bit-sidebar>Current Page
bit-sidebar-item sets aria-current="page" on the inner <a> or <button> when active is applied. Screen readers announce the item as the current location.
Collapsed State
When the sidebar is collapsed to icon-only mode:
- Text labels are visually hidden (opacity 0, width 0) but the structural DOM remains accessible.
- The toggle button updates
aria-labelandaria-expandedto reflect the current state. - Items remain keyboard reachable; only the visual label is hidden.
Best practice: pair icon-only collapsed items with tooltips using bit-tooltip to surface the label for sighted keyboard and pointer users.
Collapsible Groups
Collapsible group headers receive role="button", tabindex="0", and aria-expanded so they can be activated via keyboard (Enter or Space). The item list is hidden with the hidden attribute when closed.
Keyboard Navigation
| Key | Behavior |
|---|---|
Tab | Moves focus to the next focusable item in DOM order |
Enter | Activates a focused item or toggles a collapsible header |
Space | Same as Enter on collapsible group headers |
Navigation within the sidebar uses native DOM focus order — no roving tabindex. This keeps behaviour predictable and compatible with all screen readers.
Best Practices
Do:
- Provide a descriptive
labelonbit-sidebarwhen the page has other<nav>elements. - Set
activeon the item matching the current URL on every page load. - Use
bit-sidebar-groupto group semantically related items — it adds a visible label and an implicitrole="list"on the items container. - Add tooltips to icon-only items when the sidebar can collapse.
Don't:
- Nest
bit-sidebarinside anotherbit-sidebar. - Set
activeon more than one item simultaneously — it breaksaria-currentsemantics. - Use
disabledas a teaching mechanism. If an item is permanently unavailable, remove it from the sidebar.