---
title: SidebarLayout
description: A composable application layout with a responsive, collapsible sidebar and slot-based header areas
---

```tsx
'use client';

import { Avatar } from '@/ui/Avatar';
import { Dropdown } from '@/ui/Dropdown';
import { SidebarLayout } from '@/ui/SidebarLayout';
import { Button } from '@/ui/buttons/Button';

const items = [
  { label: 'Home', icon: 'Home' as const, href: '#' },
  {
    label: 'Projects',
    icon: 'HeartOutlined' as const,
    items: [
      { label: 'Project A', href: '#' },
      { label: 'Project B', href: '#' },
      { label: 'Project C', href: '#' },
    ],
  },
  { label: 'Team', icon: 'PeopleDouble' as const, href: '#' },
];

export function SidebarLayoutDemo() {
  const user = { firstName: 'John', lastName: 'Doe' };

  return (
    <SidebarLayout items={items}>
      <div data-slot="header-logo" className="text-white text-b3 font-semibold">
        CM
      </div>
      <div data-slot="header" className="text-white text-h6">
        Project Dashboard
      </div>
      <Dropdown data-slot="header-actions" aria-label="Open user actions">
        <span data-slot="label" className="hidden md:inline text-body">
          {user.firstName} {user.lastName}
        </span>
        <Avatar
          data-slot="label"
          alt={`${user.firstName} ${user.lastName}`}
          className="w-40 h-40"
          style={{ width: '40px', height: '40px' }}
        />
        <Button size="small">Logout</Button>
      </Dropdown>

      <div>
        <h1 className="text-h3 mb-16">Welcome to your dashboard</h1>
        <p className="text-body">This example shows SidebarLayout with composable header slots.</p>
      </div>
    </SidebarLayout>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/sidebar-layout.json
```

Or manually, copy the following code into `/ui/SidebarLayout.tsx`:

```tsx
import { Icon, type IconicNames } from '@clubmed/trident-icons';
import clsx from 'clsx';
import {
  type ComponentPropsWithoutRef,
  type CSSProperties,
  type Dispatch,
  type PropsWithChildren,
  type ReactNode,
  type SetStateAction,
  type MouseEvent,
  useCallback,
  useRef,
  useState,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { HamburgerIcon } from './HamburgerIcon';
import { useSlots } from '@/ui/hooks/useSlots';

type SidebarSubItem = {
  label: string;
  href: string;
} & ComponentPropsWithoutRef<'a'>;

type SidebarItem = {
  label: string;
  icon: IconicNames;
  items?: SidebarSubItem[];
} & ((ComponentPropsWithoutRef<'a'> & { href: string }) | ComponentPropsWithoutRef<'button'>);

interface SidebarItemProps {
  item: SidebarItem;
  isActive?: boolean;
  isCollapsed?: boolean;
  anchorName: string;
  onItemClick?: () => void;
}

function Cta(item: PropsWithChildren<SidebarItem>) {
  if ('href' in item) {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { children, icon, label, ...attrs } = item;

    return (
      <a
        {...attrs}
        className="flex whitespace-nowrap items-center text-white hover:text-saffron transition-colors outline-none focus-visible:bg-white/20 rounded-pill relative group/sidebar-item"
      >
        {children}
      </a>
    );
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const { children, icon, label, ...attrs } = item;

  return (
    <button
      {...attrs}
      className="flex whitespace-nowrap items-center text-white outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2 rounded-pill relative group/sidebar-item"
    >
      {children}
    </button>
  );
}

const SidebarItem = ({
  item,
  isActive = false,
  isCollapsed = false,
  anchorName,
  onItemClick,
}: SidebarItemProps) => {
  const hasSubItems = item.items && item.items.length > 0;

  return (
    <div className="col-start-1 col-end-3">
      <Cta
        {...item}
        onClick={(e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
          if (hasSubItems && onItemClick) {
            e.preventDefault();
            onItemClick();
          }
        }}
        style={{ anchorName: `--${anchorName}` } as CSSProperties}
      >
        <div className="relative">
          <div
            className={twMerge('aspect-square w-48 rounded-full flex items-center justify-center')}
          >
            <Icon name={item.icon} className="w-24 text-white" />
          </div>
          <div className="absolute inset-0 pointer-events-none" aria-hidden="true" inert>
            <div
              className={twMerge(
                'max-md:hidden aspect-square w-48 rounded-full flex items-center justify-center bg-lightSand text-black',
                '[clip-path:circle(var(--sidebarItem_clipCircle))] transition-all duration-200 delay-100 ease-in-out group-focus-visible/sidebar-item:ring-4 group-focus-visible/sidebar-item:ring-black group-focus-visible/sidebar-item:ring-offset-2',
                isActive ? '[--sidebarItem_clipCircle:100%]' : '[--sidebarItem_clipCircle:0%]',
                'group-hover/sidebar-item:[--sidebarItem_clipCircle:100%] group-focus-within/sidebar-item:[--sidebarItem_clipCircle:100%]',
                !isActive &&
                  'group-hover/sidebar-item:bg-lightSand/80 group-focus-within/sidebar-item:bg-lightSand/80',
              )}
            >
              <Icon name={item.icon} className="w-24" />
            </div>
          </div>
        </div>
        <span
          className={twMerge(
            'transition-all duration-300 ease-in-out whitespace-nowrap ps-8 pe-12 truncate md:max-w-240',
            isCollapsed ? 'opacity-0' : 'opacity-100',
          )}
        >
          {item.label}
        </span>
      </Cta>
      {hasSubItems && (
        <div
          className={twMerge(
            'grid transition-[grid-template-rows,opacity] duration-300 ease-in-out overflow-hidden max-md:grid-rows-[1fr] max-md:opacity-100',
            isCollapsed ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100 pb-12',
          )}
          inert={isCollapsed}
        >
          <div className="outline-none relative overflow-hidden flex flex-col gap-8 ps-48 font-normal">
            {item.items!.map((subItem, subIndex) => (
              <a
                key={subIndex}
                href={subItem.href}
                className="text-white hover:text-saffron transition-colors text-b4 whitespace-nowrap outline-none focus-visible:bg-white/20 ps-8 pe-16 rounded-pill"
              >
                {subItem.label}
              </a>
            ))}
          </div>
        </div>
      )}
    </div>
  );
};

export const CollapseIndicatorSVG = ({ isCollapsed }: { isCollapsed: boolean }) => (
  <svg xmlns="http://www.w3.org/2000/svg" width="33" height="48">
    <g fill="none" fillRule="nonzero">
      <path
        fill="#FFF"
        d="M10.008 48v-1.829c0-4.116-1.722-8.045-4.75-10.835-6.331-5.837-6.331-15.835 0-21.672a14.736 14.736 0 0 0 4.75-10.835V0h27.417v48H10.008Z"
      />
      <g
        className={twMerge(
          'transition-transform duration-500 ease-in-out origin-center [transform-box:fill-box]',
          isCollapsed ? 'rotate-180' : 'rotate-0',
        )}
      >
        <path
          fill="#1E2643"
          d="M19.686 12.547c6.617 0 12 5.383 12 12s-5.383 12-12 12-12-5.383-12-12 5.383-12 12-12Zm0 1.986c-5.522 0-10.015 4.492-10.015 10.014 0 5.523 4.493 10.015 10.016 10.015 5.52 0 10.012-4.492 10.012-10.015 0-5.522-4.492-10.014-10.012-10.014Zm-2.097 4.382c.376-.366 1.032-.366 1.409 0l5.037 4.895a.948.948 0 0 1 0 1.37l-5.037 4.894a1.002 1.002 0 0 1-.705.285 1 1 0 0 1-.704-.285.947.947 0 0 1 0-1.369l4.332-4.21-4.332-4.21a.95.95 0 0 1 0-1.37Z"
        />
      </g>
    </g>
  </svg>
);

export interface SidebarLayoutProps extends ComponentPropsWithoutRef<'div'> {
  items: SidebarItem[];
  children: ReactNode;
  activeIndex?: number;
  isCollapsed?: boolean;
  onCollapsedChange?: (isCollapsed: boolean) => void;
}

function useSidebarLayout({
  isCollapsed: initialCollapsedValue,
  onCollapsedChange,
}: {
  isCollapsed?: boolean;
  onCollapsedChange?: (isCollapsed: boolean) => void;
}) {
  const [isCollapsed, setIsCollapsedState] = useState(() => initialCollapsedValue ?? true);
  const overlayRef = useRef<HTMLDivElement>(null);

  const setIsCollapsed = useCallback<Dispatch<SetStateAction<boolean>>>(
    (value) => {
      setIsCollapsedState((previousState) => {
        const nextState = typeof value === 'function' ? value(previousState) : value;
        if (nextState !== previousState) {
          onCollapsedChange?.(nextState);
        }
        return nextState;
      });
    },
    [onCollapsedChange],
  );

  const handleToggleCollapse = useCallback(() => {
    setIsCollapsed((prev) => !prev);
  }, [setIsCollapsed]);

  return {
    isCollapsed,
    setIsCollapsed,
    overlayRef,
    handleToggleCollapse,
  };
}

export const SidebarLayout = ({
  items,
  children: initialChildren,
  className,
  activeIndex = 0,
  isCollapsed: initialCollapsedValue,
  onCollapsedChange,
  ...attrs
}: SidebarLayoutProps) => {
  const { isCollapsed, setIsCollapsed, handleToggleCollapse } = useSidebarLayout({
    isCollapsed: initialCollapsedValue,
    onCollapsedChange,
  });
  const {
    'header-logo': headerLogo,
    header,
    ['header-actions']: headerActions,
    children,
  } = useSlots(initialChildren, ['header-logo', 'header', 'header-actions']);

  return (
    <div
      data-name="SidebarLayout"
      className={clsx('h-screen overflow-clip flex flex-col bg-ultramarine', className)}
      {...attrs}
    >
      {/* Header */}
      <header className="bg-ultramarine h-64 flex items-center justify-between gap-8 p-8 ps-20 md:px-24 text-white shrink-0">
        {headerLogo}
        {header}
        {headerActions}
        <button
          className="md:hidden w-48 h-48 focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2 rounded-full flex items-center justify-center text-white transition-opacity outline-none"
          aria-label={isCollapsed ? 'Open the navigation menu' : 'Close the navigation menu'}
          onClick={handleToggleCollapse}
        >
          <HamburgerIcon isActive={!isCollapsed} />
        </button>
      </header>

      {/* Main container with sidebar and content */}
      <div className="flex h-full overflow-x-clip overflow-y-hidden">
        {/* Sidebar */}
        <aside
          className={twMerge(
            'z-10 max-md:absolute max-md:top-64 max-md:h-[calc(100dvh-64px)] max-md:w-screen flex bg-ultramarine px-8 md:py-16 md:ps-28 md:pe-16 text-white flex-col gap-24 transition-all duration-300 ease-in-out relative h-full shrink-0',
            isCollapsed ? 'max-md:-translate-x-full' : 'md:pe-24',
          )}
        >
          {/* Indicator */}
          <button
            className="hidden md:block absolute transition-top duration-300 ease-in-out rounded-pill outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2 [--indicatorYPos:anchor(top)] top-[max(var(--indicatorYPos,24px),24px)] left-[max(calc(100%_-_10px),86px)]"
            onClick={handleToggleCollapse}
            aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
            style={
              {
                positionAnchor: `--sidebar-item-${activeIndex}`,
              } as CSSProperties
            }
          >
            <CollapseIndicatorSVG isCollapsed={isCollapsed} />
          </button>

          <nav
            className={twMerge(
              'max-md:divide-y-1 p-8 max-md:h-full max-md:overflow-auto md:flex-1 md:min-h-0 md:overflow-y-auto md:overflow-x-hidden md:gap-y-20 grid overflow-hidden text-b3 font-semibold relative transition-grid-template-columns duration-300 ease-in-out',
              isCollapsed ? 'grid-cols-[48px_0fr]' : 'grid-cols-[48px_1fr]',
            )}
          >
            {items.map((item, index) => (
              <SidebarItem
                key={index}
                item={item}
                isActive={index === activeIndex}
                isCollapsed={isCollapsed}
                anchorName={`sidebar-item-${index}`}
                onItemClick={() => {
                  if (item.items && item.items.length > 0) {
                    setIsCollapsed((prev) => (prev ? false : prev));
                  }
                }}
              />
            ))}
          </nav>
        </aside>

        <main className="w-full md:min-w-0 md:w-[calc(100%-108px)] bg-white p-24 overflow-auto md:rounded-ss-16 h-full shrink-0">
          {children}
        </main>
      </div>
    </div>
  );
};
```

## Usage

```tsx showLineNumbers
import { SidebarLayout } from '@/ui/SidebarLayout';
import { Dropdown } from '@/ui/Dropdown';
import { Avatar } from '@/ui/Avatar';
import { Button } from '@/ui/buttons/Button';

export function Component() {
  const user = { firstName: 'John', lastName: 'Doe' };

  return (
    <SidebarLayout
      items={[
        { label: 'Home', icon: 'Home', href: '#' },
        {
          label: 'Settings',
          icon: 'Compare',
          // Items with nested `items` render as buttons
          items: [
            { label: 'Profile', href: '#profile' },
            { label: 'Account', href: '#account' },
          ],
        },
        { label: 'Team', icon: 'PeopleDouble', href: '#team' },
      ]}
      activeIndex={0}
      isCollapsed={false}
      onCollapsedChange={(collapsed) => {
        console.log('Sidebar collapsed:', collapsed);
      }}
    >
      <img data-slot="header-logo" alt="Brand" src="/brand.svg" />
      <div data-slot="header">My Dashboard</div>
      <Dropdown data-slot="header-actions">
        <span data-slot="label" className="hidden md:inline text-body">
          {user.firstName} {user.lastName}
        </span>
        <Avatar
          data-slot="label"
          alt={`${user.firstName} ${user.lastName}`}
          className="w-40 h-40 cursor-pointer hover:opacity-80 transition-opacity"
          style={{ width: '40px', height: '40px' }}
        />
        <Button>Logout</Button>
      </Dropdown>

      <div>
        <h1>Welcome to your Dashboard</h1>
        <p>Your main content goes here.</p>
      </div>
    </SidebarLayout>
  );
}
```

## API Reference

### SidebarLayout Props

| Prop          | Type           | Default | Description                                                            |
| ------------- | -------------- | ------- | ---------------------------------------------------------------------- |
| `items`       | `SidebarItem[]`| -       | Array of navigation items displayed in the sidebar (required)          |
| `children`    | `ReactNode`    | -       | Main content and optional slotted header content (required)            |
| `activeIndex` | `number`       | `0`     | Index of the active sidebar item used by the indicator                 |
| `isCollapsed` | `boolean`      | `true`  | Initial collapsed state at mount only (`false` starts expanded)        |
| `onCollapsedChange` | `(isCollapsed: boolean) => void` | - | Called whenever the collapse state changes                     |
| `className`   | `string`       | -       | Additional CSS classes applied to the root container                   |

### SidebarItem Structure

`SidebarItem` is a typed union:
- **Link item**: anchor props + required `href`
- **Button item**: button props (no `href`)

| Property | Type          | Required for | Description                                                               |
| -------- | ------------- | ------------ | ------------------------------------------------------------------------- |
| `label`  | `string`      | Both         | Display text for the navigation item                                      |
| `icon`   | `IconicNames` | Both         | Icon name from the Trident Icons library                                  |
| `href`   | `string`      | Link items   | Standard anchor URL for direct navigation                                 |
| `items`  | `SubItem[]`   | Optional     | Nested links (when present, parent item renders as button)                |

### SubItem Structure

`SubItem` supports anchor attributes plus:

| Property | Type     | Description                      |
| -------- | -------- | -------------------------------- |
| `label`  | `string` | Display text for the sub-item    |
| `href`   | `string` | URL for the sub-item navigation  |

### Slots

Use `data-slot` on children passed to `SidebarLayout`:

| Slot              | Description                                             |
| ----------------- | ------------------------------------------------------- |
| `header-logo`     | Content rendered at the start of the header             |
| `header`          | Main header content                                     |
| `header-actions`  | Content rendered on the right side of the header        |

Children without `data-slot` are rendered in the main content area.

## Examples

### With Custom Header

```tsx
'use client';

import { SidebarLayout } from '@/ui/SidebarLayout';

const items = [
  { label: 'Dashboard', icon: 'Home' as const, href: '#' },
  { label: 'Analytics', icon: 'CheckOutlined' as const, href: '#' },
];

export function SidebarLayoutWithHeaderDemo() {
  return (
    <SidebarLayout items={items}>
      <div data-slot="header-logo" className="text-white text-b3 font-semibold">
        CM
      </div>
      <div data-slot="header" className="text-white text-h6">
        Project Dashboard
      </div>

      <div>
        <h1 className="text-h3 mb-16">Custom header example</h1>
        <p className="text-body">
          This layout demonstrates custom `header-logo` and `header` slots.
        </p>
      </div>
    </SidebarLayout>
  );
}
```

### Minimal Navigation

```tsx
'use client';

import { SidebarLayout } from '@/ui/SidebarLayout';

const items = [
  { label: 'Home', icon: 'Home' as const, href: '#' },
  { label: 'Settings', icon: 'Compare' as const, href: '#' },
];

export function SidebarLayoutMinimalDemo() {
  return (
    <SidebarLayout items={items} activeIndex={1}>
      <div data-slot="header" className="text-white text-h6">
        Minimal Layout
      </div>

      <div>
        <h1 className="text-h3 mb-16">Minimal navigation</h1>
        <p className="text-body">This layout demonstrates a compact SidebarLayout setup.</p>
      </div>
    </SidebarLayout>
  );
}
```

## Notes

### Responsive Behavior

- **Desktop**: Features a collapse button with animated indicator that follows the active navigation item using CSS anchor positioning
- **Mobile**: Replaces collapse button with a hamburger menu that slides the sidebar in from the left
- The sidebar automatically collapses on mobile and can be toggled open with the hamburger icon
- Animations use CSS transitions for smooth state changes

### State Management

- `isCollapsed` is only an initial value read on mount (uncontrolled component)
- Updating `isCollapsed` later does not force a new collapsed state
- Use `onCollapsedChange` to persist user preference (for example in `localStorage`) and pass it back through `isCollapsed` on next page refresh

### Navigation Patterns

- Navigation items with a `href` property render as anchor links for direct navigation
- Items with nested `items` render as buttons and display a collapsible list of sub-navigation links
- The active navigation indicator uses CSS anchor positioning to smoothly track the currently active item
- Sub-items are automatically hidden when the sidebar is collapsed and marked as `inert` for accessibility

### Header Composition

- Header content is fully composable through slots (`header-logo`, `header`, `header-actions`)
- A common pattern is to render `Dropdown` with slotted labels and action buttons inside `header-actions`
- This keeps identity/logout logic outside `SidebarLayout` and makes the layout reusable

### Accessibility

- Full keyboard navigation support with visible focus indicators
- ARIA labels for all interactive elements (expand/collapse, open/close menu)
- Uses `inert` attribute to prevent interaction with collapsed sub-items
- Semantic HTML with proper landmark regions (navigation, complementary, main)
- Screen reader friendly with descriptive labels that update based on state

### Styling Customization

- Use the `className` prop to add custom Tailwind classes to the root container
- The component uses Tailwind utility classes extensively for responsive design
- For deeper customization, copy the component source and modify the internal classes

### Performance

- Uses `useState` for local state management (collapse state)
- Callbacks are memoized with `useCallback` to prevent unnecessary re-renders
- CSS transitions handle all animations without JavaScript animation frames
