Skip to content

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-item renders an <a> when href is 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
ts
import {
  define,
  computed,
  createContext,
  html,
  inject,
  provide,
  signal,
  type ReadonlySignal,
  watch,
  onMounted,
} from '@vielzeug/craftit';
import { resizeObserver } from '@vielzeug/craftit/observers';

import '../../content/icon/icon';
import { coarsePointerMixin, reducedMotionMixin } from '../../styles';

// ─── Types ────────────────────────────────────────────────────────────────

type SidebarVariant = 'floating' | 'inset';
type SidebarCollapseSource = 'api' | 'responsive' | 'toggle';
type SidebarMobileSource = 'api' | 'responsive' | 'toggle';
type SidebarMode = 'bottom-nav' | 'collapsed' | 'default';

type BottomNavItem = {
  active: boolean;
  disabled: boolean;
  href?: string;
  iconName?: string;
  label: string;
  source: HTMLElement;
};

const parseMaxWidthPx = (query: string | undefined): number | undefined => {
  const value = String(query ?? '').trim();

  if (!value) return undefined;

  const match = /max-width\s*:\s*([0-9]+(?:\.[0-9]+)?)px/i.exec(value);

  if (!match) return undefined;

  const parsed = Number.parseFloat(match[1]);

  return Number.isFinite(parsed) ? parsed : undefined;
};

const resolveContainerElement = (el: HTMLElement): HTMLElement | null => {
  let container = el.parentElement;

  while (container?.tagName.toLowerCase() === 'bit-grid-item') {
    container = container.parentElement;
  }

  return container;
};

const readContainerWidth = (el: HTMLElement): number => {
  const parentWidth = resolveContainerElement(el)?.clientWidth ?? 0;

  if (parentWidth > 0) return parentWidth;

  return el.offsetWidth;
};

/** Context provided by `bit-sidebar` to its `bit-sidebar-group` and `bit-sidebar-item` children. */
export type SidebarContext = {
  collapsed: ReadonlySignal<boolean>;
  mobileOpen: ReadonlySignal<boolean>;
  mode: ReadonlySignal<SidebarMode>;
  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 & {
    /** Close the drawer in bottom-nav mode. */
    closeMobile(): void;
    /** Open the drawer in bottom-nav mode. */
    openMobile(): void;
    /** Set collapsed state imperatively. */
    setCollapsed(next: boolean): void;
    /** Toggle between collapsed and expanded. */
    toggle(): void;
    /** Toggle the drawer in bottom-nav mode. */
    toggleMobile(): void;
  };

/** Sidebar component properties */

export type BitSidebarEvents = {
  'collapsed-change': { collapsed: boolean; source: SidebarCollapseSource };
  'mobile-open-change': { open: boolean; source: SidebarMobileSource };
};

export type BitSidebarGroupEvents = {
  'open-change': { open: boolean };
};

export type BitSidebarProps = {
  /** CSS media query that switches the sidebar to bottom navigation mode */
  'bottom-nav-at'?: string;
  /** Controlled collapsed state */
  collapsed?: boolean;
  /** Whether the sidebar supports collapsing */
  collapsible?: boolean;
  /** Evaluate responsive and bottom-nav breakpoints against container width only. */
  'container-breakpoints'?: 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
 *
 * @fires collapsed-change - Fired when collapsed state changes
 *
 * @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.)
 *
 * @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
 *
 * @part mobile-backdrop - Backdrop shown for mobile overlays.
 * @part nav - Navigation container.
 * @part header - Header container.
 * @part toggle-btn - Shadow part for the `toggle-btn` element.
 * @part content - Content container.
 * @part footer - Footer container.
 * @part bottom-bar - Bottom bar container.
 * @part group - Group container.
 * @part group-header - Group header container.
 * @part group-icon - Group icon container.
 * @part group-label - Group label container.
 * @part group-items - Group items container.
 * @part item-icon - Leading item icon container.
 * @part item-label - Item label container.
 * @part item-end - Trailing item content container.
 * @part item - Item root element.
 * @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 = define<BitSidebarProps, BitSidebarEvents>('bit-sidebar', {
  props: {
    'bottom-nav-at': undefined,
    collapsed: {
      default: undefined as boolean | undefined,
      parse: (value: string | null) => (value == null ? undefined : value === '' || value === 'true'),
    },
    collapsible: false,
    'container-breakpoints': false,
    'default-collapsed': false,
    label: 'Sidebar navigation',
    responsive: undefined,
    variant: undefined,
  },
  setup(props, { emit, host, slots }) {
    const hasHeader = () => slots.has('header').value;
    const hasFooter = () => slots.has('footer').value;
    const hasLogo = () => slots.has('logo').value;

    const isControlled = signal(host.el.hasAttribute('collapsed'));
    const collapsedState = signal(
      isControlled.value ? host.el.hasAttribute('collapsed') : props['default-collapsed'].value,
    );
    const isBottomNav = signal(false);
    const isMobileOpen = signal(false);
    const bottomNavItems = signal<BottomNavItem[]>([]);
    const responsiveMediaMatches = signal(false);
    const responsiveSizeMatches = signal(false);
    const responsiveMaxWidthPx = signal<number | undefined>(parseMaxWidthPx(props.responsive.value));
    const hasResponsiveQuery = signal(Boolean(String(props.responsive.value ?? '').trim()));
    const bottomNavMediaMatches = signal(false);
    const bottomNavSizeMatches = signal(false);
    const bottomNavMaxWidthPx = signal<number | undefined>(parseMaxWidthPx(props['bottom-nav-at'].value));
    const isPreviewMode = signal(false);

    const isCollapsed = () => collapsedState.value;
    const mode = computed<SidebarMode>(() => {
      if (isBottomNav.value) return 'bottom-nav';

      return collapsedState.value ? 'collapsed' : 'default';
    });

    const applyResponsiveState = () => {
      const useContainerBreakpoints = props['container-breakpoints'].value;
      const responsiveMatched = useContainerBreakpoints
        ? responsiveSizeMatches.value
        : responsiveMediaMatches.value || responsiveSizeMatches.value;
      const bottomMatched = useContainerBreakpoints
        ? bottomNavSizeMatches.value
        : bottomNavMediaMatches.value || bottomNavSizeMatches.value;

      isBottomNav.value = bottomMatched;

      if (!bottomMatched) {
        setMobileOpen(false, 'responsive');
      }

      if (hasResponsiveQuery.value) {
        setCollapsed(responsiveMatched, 'responsive');
      }
    };

    const readBottomNavItems = () => {
      const next = slots
        .elements()
        .value.filter(
          (el): el is HTMLElement => el instanceof HTMLElement && el.tagName.toLowerCase() === 'bit-sidebar-item',
        )
        .map((el, index) => {
          const iconSlotEl =
            (el.querySelector(':scope > [slot="icon"]') as HTMLElement | null) ??
            (el.querySelector('[slot="icon"]') as HTMLElement | null);
          const directIconName =
            iconSlotEl?.tagName.toLowerCase() === 'bit-icon' ? iconSlotEl.getAttribute('name') : null;
          const nestedIconName = iconSlotEl?.querySelector('bit-icon')?.getAttribute('name') ?? null;
          const rawLabel = (el.textContent ?? '').trim();

          return {
            active: el.hasAttribute('active'),
            disabled: el.hasAttribute('disabled'),
            href: el.getAttribute('href') ?? undefined,
            iconName: directIconName ?? nestedIconName ?? undefined,
            label: rawLabel || `Item ${index + 1}`,
            source: el,
          } satisfies BottomNavItem;
        });

      bottomNavItems.value = next;
    };

    provide(SIDEBAR_CTX, {
      collapsed: computed(() => !isBottomNav.value && collapsedState.value) as ReadonlySignal<boolean>,
      mobileOpen: computed(() => isBottomNav.value && isMobileOpen.value) as ReadonlySignal<boolean>,
      mode: mode as ReadonlySignal<SidebarMode>,
      variant: props.variant,
    });

    const setCollapsed = (next: boolean, source: SidebarCollapseSource) => {
      if (isCollapsed() === next) return;

      if (!isControlled.value) {
        collapsedState.value = next;
      }

      emit('collapsed-change', { collapsed: next, source });
    };

    const setMobileOpen = (next: boolean, source: SidebarMobileSource) => {
      const open = Boolean(next);

      if (!isBottomNav.value) {
        if (isMobileOpen.value) {
          isMobileOpen.value = false;
        }

        return;
      }

      if (isMobileOpen.value === open) return;

      isMobileOpen.value = open;
      emit('mobile-open-change', { open, source });
    };

    const doToggle = () => {
      setCollapsed(!isCollapsed(), 'toggle');
    };

    host.bind({
      attr: {
        'data-bottom-nav': () => (isBottomNav.value ? true : undefined),
        'data-collapsed': () => (isCollapsed() && !isBottomNav.value ? true : undefined),
        'data-mobile-open': () => (isBottomNav.value && isMobileOpen.value ? true : undefined),
        'data-preview-mode': () => (isPreviewMode.value ? true : undefined),
      },
    });

    onMounted(() => {
      const el = host.el as SidebarElement;

      el.setCollapsed = (next) => setCollapsed(Boolean(next), 'api');
      el.toggle = doToggle;
      el.openMobile = () => setMobileOpen(true, 'api');
      el.closeMobile = () => setMobileOpen(false, 'api');
      el.toggleMobile = () => setMobileOpen(!isMobileOpen.value, 'toggle');

      // Suppress transitions during initial layout so the sidebar doesn't
      // animate from a collapsed/0 state before the first ResizeObserver fires.
      host.el.setAttribute('data-no-transition', '');

      let transitionUnlocked = false;
      const unlockTransition = () => {
        if (transitionUnlocked) return;

        transitionUnlocked = true;
        host.el.removeAttribute('data-no-transition');
      };

      // Fallback: unlock after two frames in case ResizeObserver doesn't fire.
      requestAnimationFrame(() => requestAnimationFrame(unlockTransition));

      let mediaCleanup: (() => void) | undefined;
      let bottomNavCleanup: (() => void) | undefined;
      const itemObservers = new Map<HTMLElement, MutationObserver>();
      const observer = new MutationObserver(() => {
        if (!host.el.hasAttribute('collapsed') && !isControlled.value) return;

        isControlled.value = true;
        collapsedState.value = host.el.hasAttribute('collapsed');
      });

      observer.observe(host.el, {
        attributeFilter: ['collapsed'],
        attributes: true,
      });

      watch(
        props.responsive,
        (query) => {
          mediaCleanup?.();
          mediaCleanup = undefined;

          const mediaQuery = String(query ?? '').trim();

          hasResponsiveQuery.value = Boolean(mediaQuery);
          responsiveMaxWidthPx.value = parseMaxWidthPx(mediaQuery);
          responsiveMediaMatches.value = false;

          const width = readContainerWidth(host.el);

          responsiveSizeMatches.value =
            width > 0 && responsiveMaxWidthPx.value != null ? width <= responsiveMaxWidthPx.value : false;
          applyResponsiveState();

          // For parseable max-width queries, keep behavior container-driven.
          // This avoids preview viewport controls being overridden by window width.
          if (props['container-breakpoints'].value && responsiveMaxWidthPx.value != null) {
            return;
          }

          if (!mediaQuery) {
            return;
          }

          const mql = window.matchMedia(mediaQuery);
          const onChange = (event: MediaQueryListEvent) => {
            responsiveMediaMatches.value = event.matches;
            applyResponsiveState();
          };

          responsiveMediaMatches.value = mql.matches;
          applyResponsiveState();
          mql.addEventListener('change', onChange);

          mediaCleanup = () => {
            mql.removeEventListener('change', onChange);
          };
        },
        { immediate: true },
      );

      watch(
        props['bottom-nav-at'],
        (query) => {
          bottomNavCleanup?.();
          bottomNavCleanup = undefined;

          const mediaQuery = String(query ?? '').trim();

          bottomNavMaxWidthPx.value = parseMaxWidthPx(mediaQuery);
          bottomNavMediaMatches.value = false;

          const width = readContainerWidth(host.el);

          bottomNavSizeMatches.value =
            width > 0 && bottomNavMaxWidthPx.value != null ? width <= bottomNavMaxWidthPx.value : false;
          applyResponsiveState();

          // For parseable max-width queries, keep behavior container-driven.
          // This avoids preview viewport controls being overridden by window width.
          if (props['container-breakpoints'].value && bottomNavMaxWidthPx.value != null) {
            return;
          }

          if (!mediaQuery) {
            return;
          }

          const mql = window.matchMedia(mediaQuery);
          const onChange = (event: MediaQueryListEvent) => {
            bottomNavMediaMatches.value = event.matches;
            applyResponsiveState();
          };

          bottomNavMediaMatches.value = mql.matches;
          applyResponsiveState();

          mql.addEventListener('change', onChange);
          bottomNavCleanup = () => {
            mql.removeEventListener('change', onChange);
          };
        },
        { immediate: true },
      );

      const stopResizeEffect =
        typeof ResizeObserver === 'function'
          ? (() => {
              const hostSize = resizeObserver(host.el);
              const wrapperEl = host.el.parentElement;
              const containerEl = resolveContainerElement(host.el);
              const wrapperSize = wrapperEl ? resizeObserver(wrapperEl) : undefined;
              const parentSize = containerEl && containerEl !== wrapperEl ? resizeObserver(containerEl) : undefined;
              let rafId: number | undefined;

              const onResize = () => {
                cancelAnimationFrame(rafId!);
                rafId = requestAnimationFrame(() => {
                  const resolvedContainer = resolveContainerElement(host.el);
                  const width = readContainerWidth(host.el);
                  const responsiveWasMatched = responsiveSizeMatches.value;
                  const bottomNavWasMatched = bottomNavSizeMatches.value;
                  const parentWidth = resolvedContainer?.clientWidth ?? 0;

                  isPreviewMode.value = parentWidth > 0 && parentWidth < window.innerWidth;

                  responsiveSizeMatches.value =
                    width > 0 && responsiveMaxWidthPx.value != null ? width <= responsiveMaxWidthPx.value : false;
                  bottomNavSizeMatches.value =
                    width > 0 && bottomNavMaxWidthPx.value != null ? width <= bottomNavMaxWidthPx.value : false;

                  if (
                    responsiveWasMatched !== responsiveSizeMatches.value ||
                    bottomNavWasMatched !== bottomNavSizeMatches.value
                  ) {
                    applyResponsiveState();
                  }

                  unlockTransition();
                });
              };

              return watch(
                computed(() => [
                  hostSize.value.width,
                  hostSize.value.height,
                  wrapperSize?.value.width,
                  wrapperSize?.value.height,
                  parentSize?.value.width,
                  parentSize?.value.height,
                ]),
                onResize,
              );
            })()
          : undefined;

      const bindItemObservers = (items: HTMLElement[]) => {
        const set = new Set(items);

        for (const [item, cleanup] of itemObservers) {
          if (set.has(item)) continue;

          cleanup.disconnect();
          itemObservers.delete(item);
        }

        for (const item of items) {
          if (itemObservers.has(item)) continue;

          const itemObserver = new MutationObserver(() => {
            // Only update if in bottom-nav mode to minimize re-renders
            if (isBottomNav.value) {
              readBottomNavItems();
            }
          });

          // Only watch attributes that affect bottom-nav rendering; skip childList/subtree/characterData
          itemObserver.observe(item, {
            attributeFilter: ['active', 'disabled', 'href'],
            attributes: true,
          });
          itemObservers.set(item, itemObserver);
        }
      };

      watch(
        slots.elements(),
        (elements) => {
          const directItems = elements.filter(
            (el): el is HTMLElement => el instanceof HTMLElement && el.tagName.toLowerCase() === 'bit-sidebar-item',
          );

          bindItemObservers(directItems);
          readBottomNavItems();
        },
        { immediate: true },
      );

      return () => {
        observer.disconnect();
        mediaCleanup?.();
        bottomNavCleanup?.();
        stopResizeEffect?.();

        for (const itemObserver of itemObservers.values()) {
          itemObserver.disconnect();
        }

        itemObservers.clear();
      };
    });

    return () => html`
      <button
        class="mobile-backdrop"
        part="mobile-backdrop"
        type="button"
        aria-label="Close sidebar"
        ?hidden=${() => !isBottomNav.value || !isMobileOpen.value}
        @click=${() => setMobileOpen(false, 'toggle')}></button>
      <nav aria-label="${props.label}" part="nav">
        <div class="sidebar-header" part="header" ?hidden=${() => !hasHeader() && !props.collapsible.value}>
          <span class="sidebar-logo" ?hidden=${() => !hasLogo()}>
            <slot name="logo"></slot>
          </span>
          <span class="sidebar-header-content">
            <slot name="header"></slot>
          </span>
          <button
            class="toggle-btn"
            part="toggle-btn"
            type="button"
            ?hidden=${() => !props.collapsible.value}
            aria-label="${() => (isCollapsed() ? 'Expand sidebar' : 'Collapse sidebar')}"
            aria-expanded="${() => !isCollapsed()}"
            @click="${doToggle}">
            <span class="toggle-icon" aria-hidden="true">
              <bit-icon name="chevron-left" size="16" stroke-width="2" aria-hidden="true"></bit-icon>
            </span>
          </button>
        </div>
        <div class="sidebar-content" part="content">
          <slot></slot>
        </div>
        <div class="sidebar-footer" part="footer" ?hidden=${() => !hasFooter()}>
          <slot name="footer"></slot>
        </div>
      </nav>

      <div class="bottom-bar" part="bottom-bar" ?hidden=${() => !isBottomNav.value}>
        ${() =>
          bottomNavItems.value.map((item) => {
            const className = `bottom-tab${item.active ? ' bottom-tab-active' : ''}`;

            if (item.href && !item.disabled) {
              return html`
                <a
                  class="${className}"
                  href="${item.href}"
                  aria-current="${item.active ? 'page' : null}"
                  data-active="${item.active ? 'true' : null}">
                  <span class="bottom-tab-icon" aria-hidden="true" ?hidden=${() => !item.iconName}>
                    <bit-icon :name="${item.iconName}" size="18" stroke-width="2"></bit-icon>
                  </span>
                  <span class="bottom-tab-label">${item.label}</span>
                </a>
              `;
            }

            return html`
              <button
                class="${className}"
                type="button"
                ?disabled=${item.disabled}
                aria-current="${item.active ? 'page' : null}"
                data-active="${item.active ? 'true' : null}"
                @click=${() => item.source.click()}>
                <span class="bottom-tab-icon" aria-hidden="true" ?hidden=${() => !item.iconName}>
                  <bit-icon :name="${item.iconName}" size="18" stroke-width="2"></bit-icon>
                </span>
                <span class="bottom-tab-label">${item.label}</span>
              </button>
            `;
          })}
      </div>
    `;
  },
  styles: [coarsePointerMixin, reducedMotionMixin, sidebarStyles],
});

// ─── 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
 *
 * @fires open-change - Fired when the group open state changes (collapsible groups only)
 *
 * @slot - Navigation items (`bit-sidebar-item`)
 * @slot icon - Icon displayed before the label
 *
 * @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 = define<BitSidebarGroupProps, BitSidebarGroupEvents>('bit-sidebar-group', {
  props: {
    collapsible: false,
    'default-open': true,
    label: '',
    open: {
      default: undefined as boolean | undefined,
      parse: (value: string | null) => (value == null ? undefined : value === '' || value === 'true'),
      reflect: false,
    },
  },
  setup(props, { host, slots }) {
    const hasIcon = () => slots.has('icon').value;
    const sidebarCtx = inject(SIDEBAR_CTX);

    host.bind({
      attr: {
        'sidebar-bottom-nav': () =>
          sidebarCtx?.mode.value === 'bottom-nav' && !sidebarCtx?.mobileOpen.value ? true : undefined,
        'sidebar-collapsed': () => (sidebarCtx?.collapsed.value ? true : undefined),
      },
    });

    const isControlled = () => props.open.value !== undefined;
    const openState = signal(props['default-open'].value);
    const isOpen = computed(() => {
      if (!props.collapsible.value) return true;

      if (isControlled()) return props.open.value ?? false;

      return openState.value;
    });

    watch(props.open, (value) => {
      if (value === undefined) return;

      openState.value = value;
    });

    host.bind({
      attr: {
        open: () => (isOpen.value ? true : undefined),
      },
    });

    return () => html`
      <details class="group" part="group" ?open=${isOpen}>
        <summary
          class="group-header"
          part="group-header"
          aria-expanded="${() => (props.collapsible.value ? String(props.open.value) : null)}"
          @click=${(e: MouseEvent) => {
            if (!props.collapsible.value) {
              e.preventDefault();
            }
          }}>
          <span class="group-icon" part="group-icon" ?hidden=${() => !hasIcon()} aria-hidden="true">
            <slot name="icon"></slot>
          </span>
          <span class="group-label" part="group-label">${props.label}</span>
          <span class="chevron" ?hidden=${() => !props.collapsible.value} aria-hidden="true">
            <bit-icon name="chevron-right" size="12" stroke-width="2" aria-hidden="true"></bit-icon>
          </span>
        </summary>
        <div class="group-items" part="group-items" role="list">
          <slot></slot>
        </div>
      </details>
    `;
  },
  styles: [reducedMotionMixin, groupStyles],
});

// ─── 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.)
 *
 * @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
 *
 * @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
 *
 * @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 = define<BitSidebarItemProps>('bit-sidebar-item', {
  props: {
    active: false,
    disabled: false,
    href: undefined,
    rel: undefined,
    target: undefined,
  },
  setup(props, { host, slots }) {
    const hasIcon = () => slots.has('icon').value;
    const hasEnd = () => slots.has('end').value;
    const sidebarCtx = inject(SIDEBAR_CTX);

    host.bind({
      attr: {
        'sidebar-bottom-nav': () =>
          sidebarCtx?.mode.value === 'bottom-nav' && !sidebarCtx?.mobileOpen.value ? true : undefined,
        'sidebar-collapsed': () => (sidebarCtx?.collapsed.value ? true : undefined),
      },
    });

    const isLink = () => !!props.href.value && !props.disabled.value;

    const renderItemContent = () => html`
      <span class="item-icon" part="item-icon" ?hidden=${() => !hasIcon()} 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()}>
        <slot name="end"></slot>
      </span>
    `;

    return () => html`
      ${() => {
        if (isLink()) {
          return html`
            <a
              class="item"
              part="item"
              href="${props.href}"
              :rel="${props.rel}"
              :target="${props.target}"
              aria-current="${() => (props.active.value ? 'page' : null)}">
              ${renderItemContent()}
            </a>
          `;
        }

        if (props.disabled.value) {
          return html`
            <div
              class="item"
              part="item"
              aria-disabled="true"
              tabindex="-1"
              aria-current="${() => (props.active.value ? 'page' : null)}">
              ${renderItemContent()}
            </div>
          `;
        }

        return html`
          <button
            class="item"
            part="item"
            type="button"
            ?disabled="${props.disabled}"
            aria-current="${() => (props.active.value ? 'page' : null)}">
            ${renderItemContent()}
          </button>
        `;
      }}
    `;
  },
  styles: [coarsePointerMixin, itemStyles],
});

Basic Usage

Wrap groups and items inside bit-sidebar. Mark the current page with active.

html
<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.

PreviewCode
RTL

Responsive App Shell (Desktop / Tablet / Mobile)

Use two breakpoints to get the full three-state behavior:

  • Desktop: full sidebar
  • Tablet: compact/collapsed sidebar (responsive)
  • Mobile: bottom navigation + drawer (bottom-nav-at), usually opened from a navbar hamburger
PreviewCode
RTL

Integration Notes

  • Bottom navigation tabs are derived from direct bit-sidebar-item children only.
  • bit-sidebar-group content remains available in the drawer opened by openMobile() or a linked bit-navbar mobile-sidebar trigger.
  • Use responsive for tablet compact mode and bottom-nav-at for mobile bottom-nav mode.

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.

PreviewCode
RTL

Items with Badges

Use the end slot on bit-sidebar-item for trailing content such as notification counts or keyboard shortcuts.

PreviewCode
RTL

Variants

Floating

Uses the same drawer-inspired panel surface with a stronger floating presentation, rounded corners, and more separation from the page background.

PreviewCode
RTL

Inset

A subtle variant with a slightly tinted background and no visible border or elevated panel shadow — blends naturally into page content areas.

PreviewCode
RTL

Use slot="logo" for the logo/icon and slot="header" for the app name or branding text. Use slot="footer" for user profile or secondary actions.

PreviewCode
RTL

Note: The sidebar header now supports a dedicated logo slot for the logo/icon, and a header slot for the app name or branding text. When collapsed, only the logo/icon is shown above the toggle button.

Disabled Items

Set disabled on a bit-sidebar-item to prevent interaction.

PreviewCode
RTL

When linking to external resources, use target and rel attributes on bit-sidebar-item.

html
<bit-sidebar-item href="https://example.com" target="_blank" rel="noopener noreferrer">
  <bit-icon slot="icon" name="globe" size="18"></bit-icon>
  External Docs
</bit-sidebar-item>

Imperative API

Collapse methods are exposed on the element instance.

js
const sidebar = document.querySelector('bit-sidebar');

sidebar.setCollapsed(true); // collapse to icon-only
sidebar.setCollapsed(false); // expand to full width
sidebar.toggle(); // toggle between states

// bottom-nav mode drawer controls
sidebar.openMobile();
sidebar.closeMobile();
sidebar.toggleMobile();

Events

js
const sidebar = document.querySelector('bit-sidebar');

sidebar.addEventListener('collapsed-change', (e) => {
  console.log('Collapsed:', e.detail.collapsed, 'source:', e.detail.source);
});

sidebar.addEventListener('mobile-open-change', (e) => {
  console.log('Mobile drawer open:', e.detail.open, '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:

css
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

AttributeTypeDefaultDescription
collapsedbooleanControlled collapsed state
default-collapsedbooleanfalseInitial collapsed state in uncontrolled mode
collapsiblebooleanfalseShows the collapse/expand toggle button in the header
responsivestringMedia query that enables compact (collapsed) sidebar mode
bottom-nav-atstringMedia query that switches to mobile bottom-nav + drawer mode
variantstringVisual variant: 'floating' | 'inset'
labelstring'Sidebar navigation'aria-label for the <nav> landmark

bit-sidebar Slots

SlotDescription
logoLogo or icon, always visible at the top
headerApp name or branding text, hidden when collapsed
(default)bit-sidebar-group or bit-sidebar-item elements
footerUser info, theme toggles, or secondary actions

bit-sidebar Events

EventDetailDescription
collapsed-change{ collapsed: boolean; source: 'toggle' | 'responsive' | 'api' }Fired when a collapse state change is requested
mobile-open-change{ open: boolean; source: 'toggle' | 'responsive' | 'api' }Fired when the bottom-nav drawer open state changes

bit-sidebar Methods

MethodDescription
setCollapsed(next)Set collapsed state
toggle()Toggle between collapsed / expanded
openMobile()Open the bottom-nav drawer
closeMobile()Close the bottom-nav drawer
toggleMobile()Toggle the bottom-nav drawer

bit-sidebar CSS Custom Properties

PropertyDescriptionDefault
--sidebar-widthExpanded width16rem
--sidebar-collapsed-widthCollapsed (icon-only) width3.5rem
--sidebar-bgSidebar background colorcanvas
--sidebar-border-colorBorder / divider colorcontrast

bit-sidebar-group Attributes

AttributeTypeDefaultDescription
labelstring''Visible group label text
collapsiblebooleanfalseAdds a toggle button to collapse/expand the group's items
default-openbooleantrueInitial open state for uncontrolled collapsible groups
openbooleanControlled group open state

bit-sidebar-group Slots

SlotDescription
iconOptional icon displayed in the group header
(default)bit-sidebar-item elements

bit-sidebar-group Events

EventDetailDescription
toggle{ open: boolean }Fired when a collapsible group is toggled

bit-sidebar-item Attributes

AttributeTypeDefaultDescription
hrefstringURL — renders an <a> when set, otherwise a <button>
activebooleanfalseMarks the item as the current page (aria-current="page")
disabledbooleanfalseDisables the item and forces button rendering
relstringrel attribute on the inner <a> (link items only)
targetstringtarget attribute on the inner <a> (link items only)

bit-sidebar-item Slots

SlotDescription
iconLeading icon (hidden from assistive tech)
(default)Item label text
endTrailing content: badge, shortcut key, chevron, etc.

bit-sidebar-item Parts

PartDescription
itemThe inner <a> or <button> element
item-iconThe icon slot wrapper
item-labelThe label text wrapper
item-endThe trailing content wrapper

bit-sidebar-item CSS Custom Properties

PropertyDescription
--sidebar-item-colorDefault text color
--sidebar-item-hover-bgBackground on hover
--sidebar-item-hover-colorText color on hover
--sidebar-item-active-bgBackground when active
--sidebar-item-active-colorText color when active
--sidebar-item-indicatorActive state indicator bar color

Accessibility

The sidebar follows WAI-ARIA navigation patterns and WCAG 2.2 guidelines.

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:

html
<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-label and aria-expanded to 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

KeyBehavior
TabMoves focus to the next focusable item in DOM order
EnterActivates a focused item or toggles a collapsible header
SpaceSame 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 label on bit-sidebar when the page has other <nav> elements.
  • Set active on the item matching the current URL on every page load.
  • Use bit-sidebar-group to group semantically related items — it adds a visible label and an implicit role="list" on the items container.
  • Add tooltips to icon-only items when the sidebar can collapse.

Don't:

  • Nest bit-sidebar inside another bit-sidebar.
  • Set active on more than one item simultaneously — it breaks aria-current semantics.
  • Use disabled as a teaching mechanism. If an item is permanently unavailable, remove it from the sidebar.