# Sidebar

> Vertical navigation rail with section eyebrows, icon + label items, and active-state highlighting. Compound component - Sidebar + SidebarSection + SidebarItem.

- Category: navigation
- Status: stable (since 0.3.0)
- A11y pattern: https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/
- Tokens: --background-primary, --background-tertiary, --cta-background, --cta-foreground, --foreground-primary, --foreground-secondary, --font-sans, --font-mono
- Playground: https://design.freecodecamp.org/playground#sidebar
- npm dependencies: `react@>=18 <20`
- Registry dependencies: [theme](https://design.freecodecamp.org/registry/theme.md)
- Files:
  - `Sidebar.tsx` → `src/ui/sidebar/Sidebar.tsx` (raw: https://design.freecodecamp.org/registry/sidebar/Sidebar.tsx)
  - `sidebar.css` → `src/ui/sidebar/sidebar.css` (raw: https://design.freecodecamp.org/registry/sidebar/sidebar.css)

## Install (copy source)

1. Ensure the theme is installed once per project - tokens.css + base.css imported globally, fonts available. See https://design.freecodecamp.org/registry/theme.md and https://design.freecodecamp.org/registry/starter.md.
2. Copy the files below into `src/ui/sidebar/` (adjust to your project layout) and import the CSS once from your global stylesheet, e.g. `@import './ui/sidebar/sidebar.css';`.
3. Colors, spacing and type come from tokens - tailor the component by editing the copied source; recolour by editing tokens.css, not the component CSS.

## Usage

Sidebar is the vertical navigation rail. The compound API keeps
consumer code declarative: `<Sidebar>` wraps one or more
`<SidebarSection>`s, each with optional eyebrow label, and each
section contains `<SidebarItem>` entries. Items render as `<a>` when
`href` is supplied and as `<button type="button">` otherwise.

## Accessibility

Sidebar renders `<aside role="navigation">` - always pair with
`aria-label` so screen readers can distinguish multiple navigation
landmarks on the page. The active item carries `aria-current="page"`
so users of assistive tech know where they are in the hierarchy.

## Example

```tsx
import { Sidebar } from './ui/sidebar/Sidebar';

<Sidebar
  sections={[
    { id: 'primitives', label: 'Primitives', items: [
      { id: 'text', label: 'Text', href: '#text' },
      { id: 'button', label: 'Button', href: '#button', current: true }
    ]}
  ]}
/>
```

## Props

No component-specific props - accepts standard HTML attributes. See the TypeScript source below.

## Source: Sidebar.tsx

```tsx
import React, { forwardRef } from 'react';

export interface SidebarProps extends React.HTMLAttributes<HTMLElement> {}

export const Sidebar = forwardRef<HTMLElement, SidebarProps>(
  ({ className = '', children, ...rest }, ref) => {
    const classes = ['sidebar', className].filter(Boolean).join(' ');
    return (
      <aside ref={ref} role='navigation' className={classes} {...rest}>
        {children}
      </aside>
    );
  }
);
Sidebar.displayName = 'Sidebar';

export interface SidebarSectionProps extends React.HTMLAttributes<HTMLElement> {
  label?: React.ReactNode;
  /**
   * When true, renders the section as a `<details><summary>` pair so users can
   * fold/unfold. Default: false (emits the original `<section>`).
   */
  collapsible?: boolean;
  defaultOpen?: boolean;
}

export const SidebarSection = forwardRef<HTMLElement, SidebarSectionProps>(
  (
    {
      label,
      className = '',
      children,
      collapsible = false,
      defaultOpen = true,
      ...rest
    },
    ref
  ) => {
    if (collapsible) {
      const classes = [
        'sidebar__section',
        'sidebar__section--collapsible',
        className
      ]
        .filter(Boolean)
        .join(' ');
      return (
        <details
          ref={ref as unknown as React.Ref<HTMLDetailsElement>}
          className={classes}
          open={defaultOpen}
          {...(rest as React.HTMLAttributes<HTMLDetailsElement>)}
        >
          <summary className='sidebar__section__summary'>
            {label !== undefined && (
              <span className='sidebar__eyebrow'>{label}</span>
            )}
            <span className='sidebar__section__caret' aria-hidden='true'>
              ▸
            </span>
          </summary>
          {children}
        </details>
      );
    }
    const classes = ['sidebar__section', className].filter(Boolean).join(' ');
    return (
      <section ref={ref} className={classes} {...rest}>
        {label !== undefined && <div className='sidebar__eyebrow'>{label}</div>}
        {children}
      </section>
    );
  }
);
SidebarSection.displayName = 'SidebarSection';

type AnchorProps = React.AnchorHTMLAttributes<HTMLAnchorElement>;
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;

export interface SidebarItemCommonProps {
  active?: boolean;
  icon?: React.ReactNode;
}

export type SidebarItemProps = SidebarItemCommonProps &
  Omit<AnchorProps & ButtonProps, 'type'>;

export const SidebarItem = forwardRef<
  HTMLAnchorElement | HTMLButtonElement,
  SidebarItemProps
>((props, ref) => {
  const { active, icon, className = '', children, ...rest } = props;
  const classes = ['sidebar__item', className].filter(Boolean).join(' ');
  const extras = {
    'aria-current': active ? 'page' : undefined,
    'data-active': active ? 'true' : undefined
  } as const;
  const inner = (
    <>
      {icon !== undefined && <span className='sidebar__icon'>{icon}</span>}
      <span className='sidebar__label'>{children}</span>
    </>
  );
  const href = (rest as AnchorProps).href;
  if (href !== undefined) {
    return (
      <a
        ref={ref as React.Ref<HTMLAnchorElement>}
        className={classes}
        {...extras}
        {...(rest as AnchorProps)}
      >
        {inner}
      </a>
    );
  }
  return (
    <button
      ref={ref as React.Ref<HTMLButtonElement>}
      type='button'
      className={classes}
      {...extras}
      {...(rest as ButtonProps)}
    >
      {inner}
    </button>
  );
});
SidebarItem.displayName = 'SidebarItem';

/**
 * Normalise a path by stripping trailing slashes. Root (`/`) is preserved.
 */
function normalisePath(value: string): string {
  if (value === '/') return '/';
  return value.replace(/\/+$/, '') || '/';
}

export interface IsActiveHrefOptions {
  /** When true (default), require exact match. When false, descendant hrefs match. */
  exact?: boolean;
}

export function isActiveHref(
  path: string,
  href: string,
  options?: IsActiveHrefOptions
): boolean {
  const exact = options?.exact ?? true;
  const p = normalisePath(path);
  const h = normalisePath(href);
  if (p === h) return true;
  if (exact) return false;
  if (h === '/') return true;
  return p.startsWith(h + '/');
}

export function isActiveHrefWithHash(
  currentPath: string,
  currentHash: string,
  href: string
): boolean {
  const hashIdx = href.indexOf('#');
  if (hashIdx === -1) return isActiveHref(currentPath, href);

  const targetPath = hashIdx === 0 ? currentPath : href.slice(0, hashIdx);
  const targetHash = '#' + href.slice(hashIdx + 1);

  // A bare `#frag` href is treated as same-route fragment.
  const path = normalisePath(targetPath);
  const cur = normalisePath(currentPath);
  if (path !== cur) return false;
  return currentHash === targetHash;
}
```

## Source: sidebar.css

```css
.sidebar {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 16px 8px;
  background: var(--background-primary);
  border-right: var(--border-width-thin) solid var(--foreground-secondary);
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  color: var(--foreground-primary);
  min-width: 220px;
  position: sticky;
  top: var(--sidebar-top, 0);
  align-self: start;
  max-height: calc(100vh - var(--sidebar-top, 0px));
  overflow-y: auto;
}
.sidebar__intro {
  padding: 0 12px 12px;
  border-bottom: var(--border-width-thin) solid var(--background-tertiary);
  margin-bottom: 12px;
}
.sidebar__intro-kicker {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--foreground-secondary);
  margin: 0 0 4px;
}
.sidebar__intro-title {
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  font-weight: var(--fw-bold);
  color: var(--foreground-primary);
  margin: 0;
}
.sidebar__hint {
  padding: 12px 16px;
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--foreground-secondary);
  line-height: 1.5;
}
.sidebar__section {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 0;
}
.sidebar__section + .sidebar__section {
  border-top: var(--border-width-thin) dashed var(--background-tertiary);
}
.sidebar__eyebrow {
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  font-weight: var(--fw-bold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--foreground-secondary);
  padding: 4px 10px;
}
.sidebar__item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  color: var(--foreground-secondary);
  background: transparent;
  border: none;
  font: inherit;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition:
    background-color 120ms,
    color 120ms;
}
.sidebar__item:hover {
  background: var(--background-secondary);
  color: var(--foreground-primary);
}
.sidebar__item {
  border-left: 3px solid transparent;
}
.sidebar__item[data-active='true'] {
  color: var(--foreground-primary);
  background: var(--background-secondary);
  border-left-color: var(--cta-background);
  font-weight: var(--fw-bold);
}
.sidebar__item:focus-visible {
  outline: var(--border-width-thin) solid var(--foreground-primary);
  outline-offset: -2px;
}
.sidebar__icon {
  display: inline-flex;
  align-items: center;
  width: 1em;
  height: 1em;
}
.sidebar__label {
  flex: 1 1 auto;
}

.sidebar__section--collapsible {
  padding: 0;
}
.sidebar__section--collapsible > .sidebar__section__summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 4px 10px;
  cursor: pointer;
  list-style: none;
  user-select: none;
}
.sidebar__section--collapsible
  > .sidebar__section__summary::-webkit-details-marker {
  display: none;
}
.sidebar__section--collapsible
  > .sidebar__section__summary
  > .sidebar__eyebrow {
  padding: 0;
}
.sidebar__section__caret {
  display: inline-block;
  font-size: var(--fs-xs);
  color: var(--foreground-secondary);
  transition: transform 160ms ease;
}
.sidebar__section--collapsible[open]
  > .sidebar__section__summary
  > .sidebar__section__caret {
  transform: rotate(90deg);
}
```

## HTML / vanilla variant

```html
<aside class="sidebar">
  <div class="sidebar__section">
    <p class="sidebar__eyebrow">Primitives</p>
    <a class="sidebar__item" href="#button" aria-current="true">Button</a>
  </div>
</aside>
```

Interactive behaviours for plain HTML come from the vanilla runtime (data-uikit-* attributes): https://design.freecodecamp.org/registry/vanilla.md - or download https://design.freecodecamp.org/cdn/uikit.global.js once and self-host it (do not hotlink).

## For coding agents

This library is distributed as copyable source, not an npm package. Start at https://design.freecodecamp.org/registry/starter.md, discover components via https://design.freecodecamp.org/llms.txt, and copy files into the consuming project. Keep token names intact; recolour by editing the copied tokens.css.
