# Shell and navigation

Nav provides access to an application’s primary destinations through hierarchical links and categories.

## Best practices

### Do

- Keep navigation labels brief, plain, and easy to scan.
- Limit each nav node to one secondary action, using an overflow menu for additional actions.
- Use icons with nav category labels when possible.

### Don't

- Move the nav category chevron to another location.
- Use the footer slot when it could obscure other navigation content.
- Use Nav for an icon-only layout.

## Behavior and states

- Nav can contain links, category groups, and nested subitems.
- Expanded categories reveal their subitems and use a chevron to communicate state.
- Secondary actions can appear on nav nodes.

## Shell and Navigation

Source: CoreAI.

Full-page app shell: transparent header, collapsible sidebar nav, and a
main content area.

[Download source](/coreai-design-system/source/examples/templates-shell-and-navigation--default.tsx)

```tsx
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import * as React from 'react';
import { MemoryRouter } from "react-router-dom";
import { Tooltip } from "@coreai-ds/react-components";
import { Button } from "@coreai-ds/react-components";
import { NavigationRegular } from "@fluentui/react-icons";
import { AppsRegular } from "@fluentui/react-icons";
import { AlertRegular } from "@fluentui/react-icons";
import { Avatar } from "@coreai-ds/react-components";
import { mergeClasses } from "@coreai-ds/react-components";
import { PanelLeftContractRegular } from "@fluentui/react-icons";
import { HomeRegular } from "@fluentui/react-icons";
import { HomeFilled } from "@fluentui/react-icons";
import { GridRegular } from "@fluentui/react-icons";
import { GridFilled } from "@fluentui/react-icons";
import { DocumentRegular } from "@fluentui/react-icons";
import { DocumentFilled } from "@fluentui/react-icons";
import { BotRegular } from "@fluentui/react-icons";
import { BotFilled } from "@fluentui/react-icons";
import { SettingsRegular } from "@fluentui/react-icons";
import { SettingsFilled } from "@fluentui/react-icons";
import { NavLink } from "react-router-dom";
import { PanelLeftExpandRegular } from "@fluentui/react-icons";
import { Text } from "@coreai-ds/react-components";

// ─── Styles ──────────────────────────────────────────────────────────────────
const useStyles = makeStyles({
    // ── Shell root ──────────────────────────────────────────────────────────────
    // Dark canvas. Header is a child; shellBody sits below it.
    shell: {
        display: 'flex',
        flexDirection: 'column',
        height: '100vh',
        overflow: 'hidden',
        backgroundColor: tokens.colorNeutralBackground4, // #0F0F0F
    },
    // ── Header ──────────────────────────────────────────────────────────────────
    // Transparent — shell root color shows through. No border-bottom.
    header: {
        height: '48px',
        minHeight: '48px',
        backgroundColor: 'transparent',
        display: 'flex',
        alignItems: 'center',
        gap: tokens.spacingHorizontalL, // 16px
        padding: `0 ${tokens.spacingHorizontalXL}`, // 0 20px
        zIndex: 10,
    },
    headerBrand: {
        display: 'flex',
        alignItems: 'center',
        gap: tokens.spacingHorizontalS,
        flex: 1,
    },
    headerBrandName: {
        fontSize: tokens.fontSizeBase400,
        fontWeight: tokens.fontWeightSemibold,
        color: tokens.colorNeutralForeground1,
    },
    headerActions: {
        display: 'flex',
        alignItems: 'center',
        gap: tokens.spacingHorizontalXS,
    },
    // Brand icon in the header — Griffel class keeps font-size + color tokenised.
    brandIcon: {
        fontSize: tokens.fontSizeBase500, // 20px
        color: tokens.colorBrandForeground1,
        flexShrink: 0,
    },
    // ── Shell body ──────────────────────────────────────────────────────────────
    // flex row; background bleeds through the 2px gap to create the visual divider.
    shellBody: {
        display: 'flex',
        flexDirection: 'row',
        flex: '1 1 0%',
        overflow: 'hidden',
        backgroundColor: tokens.colorNeutralBackground4, // must match shell root
        gap: tokens.spacingHorizontalXXS, // 2px — the divider
    },
    // ── Sidebar ──────────────────────────────────────────────────────────────────
    sidebar: {
        width: '218px',
        minWidth: '218px',
        maxWidth: '218px',
        boxSizing: 'border-box',
        display: 'flex',
        flexDirection: 'column',
        overflowY: 'auto',
        backgroundColor: tokens.colorNeutralBackground1, // #1F1F1F
        boxShadow: tokens.shadow2,
        borderTopRightRadius: tokens.borderRadius2XLarge, // 12px — top-right only
        borderTopLeftRadius: '0',
        padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS} ${tokens.spacingVerticalM}`,
        gap: tokens.spacingVerticalS, // 8px between mobile top bar, nav sections, and footer
        // No transition here — applied separately via sidebarTransitionable only
        // on desktop, after the viewport has settled. This prevents any animated
        // width change when switching between viewport breakpoints.
    },
    // Opt-in width transition — only active on desktop when viewport is settled.
    sidebarTransitionable: {
        transitionProperty: 'width, min-width, max-width, padding',
        transitionDuration: `${tokens.durationNormal}, ${tokens.durationNormal}, ${tokens.durationNormal}, ${tokens.durationNormal}`,
        transitionTimingFunction: tokens.curveEasyEase,
    },
    sidebarCollapsed: {
        width: '56px',
        minWidth: '56px',
        maxWidth: '56px',
        // padding unchanged from expanded — spacingHorizontalS (8px) each side
        // leaves exactly 40px of content width for nav items to fill.
    },
    // Sidebar — mobile overlay drawer. Snaps to hidden when mobile mode activates.
    // Transition is only on sidebarMobileDrawerOpen so the mode switch is instant.
    sidebarMobileDrawer: {
        position: 'fixed',
        left: '0',
        top: '0',
        height: '100%',
        zIndex: 200,
        transform: 'translateX(-100%)',
        borderRadius: '0',
    },
    sidebarMobileDrawerOpen: {
        transform: 'translateX(0)',
    },
    // Transition class enabled one frame after entering mobile mode.
    // Kept separate so the initial snap-to-hidden is instant, while
    // subsequent open/close interactions both animate.
    sidebarMobileDrawerTransition: {
        transitionProperty: 'transform',
        transitionDuration: tokens.durationNormal,
        transitionTimingFunction: tokens.curveEasyEase,
    },
    // Scrim shown behind the mobile drawer.
    drawerBackdrop: {
        position: 'fixed',
        inset: '0',
        backgroundColor: 'rgba(0, 0, 0, 0.4)',
        zIndex: 199,
    },
    // ── Main content area ────────────────────────────────────────────────────────
    main: {
        flex: '1 1 0%',
        overflowY: 'auto',
        scrollbarWidth: 'thin',
        backgroundColor: tokens.colorNeutralBackground1, // #1F1F1F
        boxShadow: tokens.shadow2,
        borderTopLeftRadius: tokens.borderRadius2XLarge, // 12px — top-left only
        borderTopRightRadius: '0',
        padding: tokens.spacingHorizontalXL,
    },
    // ── Section label ─────────────────────────────────────────────────────────────
    // Rendered as <span aria-hidden="true"> so it does not appear in the heading
    // outline. The matching <ul> carries aria-label={section.label} instead.
    sectionLabel: {
        padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalSNudge}`, // 4px 6px
        fontSize: tokens.fontSizeBase200,
        fontWeight: tokens.fontWeightSemibold,
        lineHeight: tokens.lineHeightBase200,
        color: tokens.colorNeutralForeground3,
        whiteSpace: 'nowrap',
    },
    sectionLabelCollapsed: {
        // Padding is intentionally unchanged — identical height keeps all nav items
        // in the same vertical position whether or not the label text is visible.
        // `visibility: hidden` (not `color: transparent`) so there's no ghost-text
        // artefact — the space is preserved but nothing is rendered.
        visibility: 'hidden',
    },
    // ── Nav list (ul) ─────────────────────────────────────────────────────────────
    navGroup: {
        display: 'flex',
        flexDirection: 'column',
        gap: tokens.spacingVerticalS, // 8px
        listStyle: 'none',
        margin: 0,
        padding: 0,
    },
    // ── Nav item (the <NavLink>) ──────────────────────────────────────────────────
    // Renders as a flex row: icon span + label span.
    navItem: {
        display: 'flex',
        alignItems: 'center',
        gap: tokens.spacingHorizontalM, // 12px
        paddingTop: tokens.spacingVerticalSNudge, // 6px
        paddingBottom: tokens.spacingVerticalSNudge, // 6px
        paddingLeft: tokens.spacingHorizontalMNudge, // 10px
        paddingRight: tokens.spacingHorizontalMNudge, // 10px
        borderRadius: tokens.borderRadiusSmall, // 4px
        color: tokens.colorNeutralForeground2,
        fontSize: tokens.fontSizeBase300, // 14px
        fontWeight: tokens.fontWeightRegular,
        lineHeight: tokens.lineHeightBase300, // 20px
        textDecoration: 'none',
        overflow: 'hidden',
        whiteSpace: 'nowrap',
        textOverflow: 'ellipsis',
        cursor: 'pointer',
        transitionProperty: 'background, color',
        transitionDuration: `${tokens.durationNormal}, ${tokens.durationNormal}`,
        transitionTimingFunction: tokens.curveEasyEase,
        ':hover': {
            backgroundColor: tokens.colorNeutralBackground3Hover,
            color: tokens.colorNeutralForeground1,
        },
        // On hover, tint the icon span (first child) via Griffel nested selector.
        ':hover > span:first-child': {
            opacity: 1,
            color: tokens.colorBrandForeground1,
        },
    },
    navItemCollapsed: {
        // The label collapses to maxWidth:0 but stays a flex item — zero out the
        // gap so no residual space appears between icon and collapsed label.
        gap: '0',
    },
    navItemActive: {
        color: tokens.colorNeutralForeground1,
        fontWeight: tokens.fontWeightSemibold,
        backgroundColor: tokens.colorNeutralBackground3,
    },
    // ── Icon span ────────────────────────────────────────────────────────────────
    navIcon: {
        fontSize: tokens.fontSizeBase500, // 20px
        width: '20px',
        height: '20px',
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        flexShrink: 0,
        opacity: 0.7, // dimmed at rest
        transitionProperty: 'opacity, color',
        transitionDuration: tokens.durationNormal,
        transitionTimingFunction: tokens.curveEasyEase,
    },
    navIconActive: {
        opacity: 1,
        color: tokens.colorBrandForeground1,
    },
    // ── Nav item label ────────────────────────────────────────────────────────────────
    // Always rendered. Fades out and shrinks width-to-0 on collapse so the
    // icon never moves — only the empty space animates away.
    navLabel: {
        overflow: 'hidden',
        whiteSpace: 'nowrap',
        opacity: 1,
        maxWidth: '200px',
        transitionProperty: 'max-width, opacity',
        transitionDuration: `${tokens.durationFast}, ${tokens.durationFast}`,
        transitionTimingFunction: tokens.curveEasyEase,
    },
    navLabelCollapsed: {
        maxWidth: '0px',
        opacity: 0,
    },
    // ── Nav section wrapper ──────────────────────────────────────────────────
    // Groups one section label + its <ul>. The sidebar gap between multiple
    // navSection divs is spacingVerticalS (8px). The gap here (between the
    // aria-hidden label and the list) is spacingVerticalXXS (2px).
    navSection: {
        display: 'flex',
        flexDirection: 'column',
        gap: tokens.spacingVerticalXXS,
    },
    // ── Catalog variant overrides ────────────────────────────────────────────
    // Panels match the outer canvas color for a flat, full-dark catalog layout.
    sidebarCatalog: {
        backgroundColor: tokens.colorNeutralBackground4,
        boxShadow: 'none',
    },
    mainCatalog: {
        backgroundColor: tokens.colorNeutralBackground4,
        boxShadow: 'none',
    },
    // ── Collapse toggle (bottom of sidebar) ──────────────────────────────────────
    // Icon-only Button (appearance="subtle"), aligned to the right edge.
    sidebarFooter: {
        marginTop: 'auto',
        paddingTop: tokens.spacingVerticalS,
        display: 'flex',
        justifyContent: 'flex-end',
    },
    // Mobile drawer top bar — holds the close button at the top-right of the drawer.
    sidebarMobileTopBar: {
        display: 'flex',
        justifyContent: 'flex-end',
        // No paddingBottom — sidebar gap (spacingVerticalS) provides the spacing below.
    },
});

// ─── Nav data ─────────────────────────────────────────────────────────────────
interface NavItemDef {
    path: string;
    label: string;
    icon: React.ReactNode; // outline — shown at rest and on hover
    activeIcon: React.ReactNode; // filled  — shown only when active
}

interface NavSectionDef {
    label: string;
    items: NavItemDef[];
}

const NAV_SECTIONS: NavSectionDef[] = [
    {
        label: 'Main',
        items: [
            { path: '/', label: 'Home', icon: <HomeRegular />, activeIcon: <HomeFilled /> },
            { path: '/catalog', label: 'Catalog', icon: <GridRegular />, activeIcon: <GridFilled /> },
            {
                path: '/docs',
                label: 'Documents',
                icon: <DocumentRegular />,
                activeIcon: <DocumentFilled />,
            },
            { path: '/ai', label: 'AI Chat', icon: <BotRegular />, activeIcon: <BotFilled /> },
        ],
    },
    {
        label: 'Settings',
        items: [
            {
                path: '/settings',
                label: 'Settings',
                icon: <SettingsRegular />,
                activeIcon: <SettingsFilled />,
            },
        ],
    },
];

// ─── Shell component ──────────────────────────────────────────────────────────
function ShellAndNavigation({ variant = 'standard' }: {
    variant?: 'standard' | 'catalog';
}) {
    const styles = useStyles();
    const [collapsed, setCollapsed] = React.useState(false);
    const [mobileOpen, setMobileOpen] = React.useState(false);
    const [viewport, setViewport] = React.useState<'desktop' | 'tablet' | 'mobile'>('desktop');
    const [drawerReady, setDrawerReady] = React.useState(false);
    const [, forceRender] = React.useReducer((n) => n + 1, 0);
    // Set to true synchronously before any viewport state update so the
    // sidebar's transition class is suppressed in the exact same render.
    const viewportChangingRef = React.useRef(false);
    const shellRef = React.useRef<HTMLDivElement>(null);
    // Respond to container width:
    //   < 768px  → mobile: sidebar becomes a fixed overlay drawer
    //   < 1024px → tablet: sidebar auto-collapses to icon-only
    //   ≥ 1024px → desktop: user controls collapse
    React.useEffect(() => {
        const el = shellRef.current;
        if (!el)
            return;
        const observer = new ResizeObserver(([entry]) => {
            const w = entry.contentRect.width;
            // Mark viewport as changing synchronously so the transition class is
            // absent in this render, then restore it one frame later.
            viewportChangingRef.current = true;
            if (w < 768) {
                setViewport('mobile');
                setMobileOpen(false);
            }
            else if (w < 1024) {
                setViewport('tablet');
                setCollapsed(true);
                setMobileOpen(false);
            }
            else {
                setViewport('desktop');
            }
            requestAnimationFrame(() => {
                viewportChangingRef.current = false;
                forceRender();
            });
        });
        observer.observe(el);
        return () => observer.disconnect();
    }, []);
    const isMobile = viewport === 'mobile';
    // Enable drawer transition one frame after entering mobile mode so the
    // initial snap-to-hidden is instant, while open/close both animate.
    React.useEffect(() => {
        if (isMobile) {
            const id = requestAnimationFrame(() => setDrawerReady(true));
            return () => cancelAnimationFrame(id);
        }
        else {
            setDrawerReady(false);
        }
    }, [isMobile]);
    // Mobile: full-width inside drawer (effectiveCollapsed drives navLabel visibility).
    // Tablet/desktop: user-controlled via the toggle button (ResizeObserver auto-sets
    // collapsed=true on tablet entry, user can expand from there).
    const effectiveCollapsed = isMobile ? false : collapsed;
    return (<MemoryRouter initialEntries={['/']}>
      <div ref={shellRef} className={styles.shell}>
        {/* ── Header ── */}
        <header className={styles.header}>
          <div className={styles.headerBrand}>
            {isMobile && (<Tooltip content="Open navigation" relationship="label">
                <Button appearance="transparent" icon={<NavigationRegular />} aria-label="Open navigation" onClick={() => setMobileOpen(true)}/>
              </Tooltip>)}
            <AppsRegular className={styles.brandIcon}/>
            <span className={styles.headerBrandName}>CoreAI Portal</span>
          </div>
          <div className={styles.headerActions}>
            <Tooltip content="Notifications" relationship="label">
              <Button appearance="transparent" icon={<AlertRegular />} aria-label="Notifications"/>
            </Tooltip>
            <Avatar name="Alex Britez" size={28}/>
          </div>
        </header>

        {/* ── Mobile drawer backdrop ── */}
        {isMobile && mobileOpen && (<div className={styles.drawerBackdrop} onClick={() => setMobileOpen(false)} aria-hidden="true"/>)}

        {/* ── Shell body: sidebar + main, with 2px gap ── */}
        <div className={styles.shellBody}>
          {/* ── Sidebar (desktop/tablet: flex item; mobile: fixed overlay) ── */}
          <nav aria-label="Primary navigation" className={mergeClasses(styles.sidebar, !isMobile && !viewportChangingRef.current && styles.sidebarTransitionable, !isMobile && effectiveCollapsed && styles.sidebarCollapsed, isMobile && styles.sidebarMobileDrawer, isMobile && drawerReady && styles.sidebarMobileDrawerTransition, isMobile && mobileOpen && styles.sidebarMobileDrawerOpen, variant === 'catalog' && styles.sidebarCatalog)}>
            {/* ── Mobile close button (top-right) ── */}
            {isMobile && (<div className={styles.sidebarMobileTopBar}>
                <Tooltip relationship="label" content="Close navigation" positioning="after">
                  <Button appearance="subtle" icon={<PanelLeftContractRegular />} aria-label="Close navigation" onClick={() => setMobileOpen(false)}/>
                </Tooltip>
              </div>)}

            {NAV_SECTIONS.map((section) => (<div key={section.label} className={styles.navSection}>
                <span aria-hidden="true" className={mergeClasses(styles.sectionLabel, effectiveCollapsed && styles.sectionLabelCollapsed)}>
                  {section.label}
                </span>

                <ul aria-label={section.label} className={styles.navGroup}>
                  {section.items.map((item) => {
                const link = (<NavLink to={item.path} end={item.path === '/'} aria-label={item.label} className={({ isActive }) => mergeClasses(styles.navItem, effectiveCollapsed && styles.navItemCollapsed, isActive && styles.navItemActive)}>
                        {({ isActive }) => (<>
                            <span className={mergeClasses(styles.navIcon, isActive && styles.navIconActive)}>
                              {isActive ? item.activeIcon : item.icon}
                            </span>
                            <span className={mergeClasses(styles.navLabel, effectiveCollapsed && styles.navLabelCollapsed)} aria-hidden={effectiveCollapsed || undefined}>
                              {item.label}
                            </span>
                          </>)}
                      </NavLink>);
                return (<li key={item.path}>
                        {effectiveCollapsed ? (<Tooltip relationship="label" content={item.label} positioning="after">
                            {link}
                          </Tooltip>) : (link)}
                      </li>);
            })}
                </ul>
              </div>))}

            {/* ── Collapse toggle (desktop + tablet) ── */}
            {!isMobile && (<div className={styles.sidebarFooter}>
                <Tooltip relationship="label" content={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} positioning="after">
                  <Button appearance="subtle" icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />} aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} aria-expanded={!collapsed} onClick={() => setCollapsed((c) => !c)}/>
                </Tooltip>
              </div>)}
          </nav>

          {/* ── Main content ── */}
          <main className={mergeClasses(styles.main, variant === 'catalog' && styles.mainCatalog)}>
            <Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
              Main content area. Use the sidebar to switch views. Toggle Theme / Mode in the
              Storybook toolbar to preview light and dark.
            </Text>
          </main>
        </div>
      </div>
    </MemoryRouter>);
}

export const Default = () => <ShellAndNavigation />;

export default Default;
```

## Implementation

CoreAI 0.12.0; Fluent 9.74.9.

[Setup and dependencies](/coreai-design-system/guides/Package.md).

Copy this template into your app. It is not an npm export. Adapt actions and navigation to your product.

## Template source: ShellAndNavigation.tsx

[Download ShellAndNavigation.tsx](/coreai-design-system/source/templates/vertical-nav-tabs/ShellAndNavigation.tsx)

```tsx
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
 * Templates / Shell and Navigation
 *
 * Full-page app shell: transparent header + collapsible sidebar nav +
 * main content area. Implements the AppShell and SidebarNav patterns
 * documented in .github/skills/theme-migration/patterns/.
 *
 * Copy-paste starting point — not an importable component.
 *
 * Nav items use semantic <ul>/<li>/<NavLink> markup.
 * Wrap the app in <BrowserRouter> (or <RouterProvider>) instead of
 * the <MemoryRouter> used here for Storybook isolation.
 */

import * as React from 'react';
import { MemoryRouter, NavLink } from 'react-router-dom';
import {
  makeStyles,
  mergeClasses,
  tokens,
  Avatar,
  Button,
  Tooltip,
  Text,
} from '@coreai-ds/react-components';
import {
  HomeFilled,
  HomeRegular,
  GridFilled,
  GridRegular,
  SettingsFilled,
  SettingsRegular,
  DocumentFilled,
  DocumentRegular,
  BotFilled,
  BotRegular,
  PanelLeftContractRegular,
  PanelLeftExpandRegular,
  AlertRegular,
  AppsRegular,
  NavigationRegular,
} from '@fluentui/react-icons';

// ─── Styles ──────────────────────────────────────────────────────────────────

const useStyles = makeStyles({
  // ── Shell root ──────────────────────────────────────────────────────────────
  // Dark canvas. Header is a child; shellBody sits below it.
  shell: {
    display: 'flex',
    flexDirection: 'column',
    height: '100vh',
    overflow: 'hidden',
    backgroundColor: tokens.colorNeutralBackground4, // #0F0F0F
  },

  // ── Header ──────────────────────────────────────────────────────────────────
  // Transparent — shell root color shows through. No border-bottom.
  header: {
    height: '48px',
    minHeight: '48px',
    backgroundColor: 'transparent',
    display: 'flex',
    alignItems: 'center',
    gap: tokens.spacingHorizontalL, // 16px
    padding: `0 ${tokens.spacingHorizontalXL}`, // 0 20px
    zIndex: 10,
  },
  headerBrand: {
    display: 'flex',
    alignItems: 'center',
    gap: tokens.spacingHorizontalS,
    flex: 1,
  },
  headerBrandName: {
    fontSize: tokens.fontSizeBase400,
    fontWeight: tokens.fontWeightSemibold,
    color: tokens.colorNeutralForeground1,
  },
  headerActions: {
    display: 'flex',
    alignItems: 'center',
    gap: tokens.spacingHorizontalXS,
  },
  // Brand icon in the header — Griffel class keeps font-size + color tokenised.
  brandIcon: {
    fontSize: tokens.fontSizeBase500, // 20px
    color: tokens.colorBrandForeground1,
    flexShrink: 0,
  },

  // ── Shell body ──────────────────────────────────────────────────────────────
  // flex row; background bleeds through the 2px gap to create the visual divider.
  shellBody: {
    display: 'flex',
    flexDirection: 'row',
    flex: '1 1 0%',
    overflow: 'hidden',
    backgroundColor: tokens.colorNeutralBackground4, // must match shell root
    gap: tokens.spacingHorizontalXXS, // 2px — the divider
  },

  // ── Sidebar ──────────────────────────────────────────────────────────────────
  sidebar: {
    width: '218px',
    minWidth: '218px',
    maxWidth: '218px',
    boxSizing: 'border-box',
    display: 'flex',
    flexDirection: 'column',
    overflowY: 'auto',
    backgroundColor: tokens.colorNeutralBackground1, // #1F1F1F
    boxShadow: tokens.shadow2,
    borderTopRightRadius: tokens.borderRadius2XLarge, // 12px — top-right only
    borderTopLeftRadius: '0',
    padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS} ${tokens.spacingVerticalM}`,
    gap: tokens.spacingVerticalS, // 8px between mobile top bar, nav sections, and footer
    // No transition here — applied separately via sidebarTransitionable only
    // on desktop, after the viewport has settled. This prevents any animated
    // width change when switching between viewport breakpoints.
  },
  // Opt-in width transition — only active on desktop when viewport is settled.
  sidebarTransitionable: {
    transitionProperty: 'width, min-width, max-width, padding',
    transitionDuration: `${tokens.durationNormal}, ${tokens.durationNormal}, ${tokens.durationNormal}, ${tokens.durationNormal}`,
    transitionTimingFunction: tokens.curveEasyEase,
  },
  sidebarCollapsed: {
    width: '56px',
    minWidth: '56px',
    maxWidth: '56px',
    // padding unchanged from expanded — spacingHorizontalS (8px) each side
    // leaves exactly 40px of content width for nav items to fill.
  },

  // Sidebar — mobile overlay drawer. Snaps to hidden when mobile mode activates.
  // Transition is only on sidebarMobileDrawerOpen so the mode switch is instant.
  sidebarMobileDrawer: {
    position: 'fixed',
    left: '0',
    top: '0',
    height: '100%',
    zIndex: 200,
    transform: 'translateX(-100%)',
    borderRadius: '0',
  },
  sidebarMobileDrawerOpen: {
    transform: 'translateX(0)',
  },
  // Transition class enabled one frame after entering mobile mode.
  // Kept separate so the initial snap-to-hidden is instant, while
  // subsequent open/close interactions both animate.
  sidebarMobileDrawerTransition: {
    transitionProperty: 'transform',
    transitionDuration: tokens.durationNormal,
    transitionTimingFunction: tokens.curveEasyEase,
  },
  // Scrim shown behind the mobile drawer.
  drawerBackdrop: {
    position: 'fixed',
    inset: '0',
    backgroundColor: 'rgba(0, 0, 0, 0.4)',
    zIndex: 199,
  },

  // ── Main content area ────────────────────────────────────────────────────────
  main: {
    flex: '1 1 0%',
    overflowY: 'auto',
    scrollbarWidth: 'thin',
    backgroundColor: tokens.colorNeutralBackground1, // #1F1F1F
    boxShadow: tokens.shadow2,
    borderTopLeftRadius: tokens.borderRadius2XLarge, // 12px — top-left only
    borderTopRightRadius: '0',
    padding: tokens.spacingHorizontalXL,
  },

  // ── Section label ─────────────────────────────────────────────────────────────
  // Rendered as <span aria-hidden="true"> so it does not appear in the heading
  // outline. The matching <ul> carries aria-label={section.label} instead.
  sectionLabel: {
    padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalSNudge}`, // 4px 6px
    fontSize: tokens.fontSizeBase200,
    fontWeight: tokens.fontWeightSemibold,
    lineHeight: tokens.lineHeightBase200,
    color: tokens.colorNeutralForeground3,
    whiteSpace: 'nowrap',
  },
  sectionLabelCollapsed: {
    // Padding is intentionally unchanged — identical height keeps all nav items
    // in the same vertical position whether or not the label text is visible.
    // `visibility: hidden` (not `color: transparent`) so there's no ghost-text
    // artefact — the space is preserved but nothing is rendered.
    visibility: 'hidden',
  },

  // ── Nav list (ul) ─────────────────────────────────────────────────────────────
  navGroup: {
    display: 'flex',
    flexDirection: 'column',
    gap: tokens.spacingVerticalS, // 8px
    listStyle: 'none',
    margin: 0,
    padding: 0,
  },
  // ── Nav item (the <NavLink>) ──────────────────────────────────────────────────
  // Renders as a flex row: icon span + label span.
  navItem: {
    display: 'flex',
    alignItems: 'center',
    gap: tokens.spacingHorizontalM, // 12px
    paddingTop: tokens.spacingVerticalSNudge, // 6px
    paddingBottom: tokens.spacingVerticalSNudge, // 6px
    paddingLeft: tokens.spacingHorizontalMNudge, // 10px
    paddingRight: tokens.spacingHorizontalMNudge, // 10px
    borderRadius: tokens.borderRadiusSmall, // 4px
    color: tokens.colorNeutralForeground2,
    fontSize: tokens.fontSizeBase300, // 14px
    fontWeight: tokens.fontWeightRegular,
    lineHeight: tokens.lineHeightBase300, // 20px
    textDecoration: 'none',
    overflow: 'hidden',
    whiteSpace: 'nowrap',
    textOverflow: 'ellipsis',
    cursor: 'pointer',
    transitionProperty: 'background, color',
    transitionDuration: `${tokens.durationNormal}, ${tokens.durationNormal}`,
    transitionTimingFunction: tokens.curveEasyEase,
    ':hover': {
      backgroundColor: tokens.colorNeutralBackground3Hover,
      color: tokens.colorNeutralForeground1,
    },
    // On hover, tint the icon span (first child) via Griffel nested selector.
    ':hover > span:first-child': {
      opacity: 1,
      color: tokens.colorBrandForeground1,
    },
  },
  navItemCollapsed: {
    // The label collapses to maxWidth:0 but stays a flex item — zero out the
    // gap so no residual space appears between icon and collapsed label.
    gap: '0',
  },
  navItemActive: {
    color: tokens.colorNeutralForeground1,
    fontWeight: tokens.fontWeightSemibold,
    backgroundColor: tokens.colorNeutralBackground3,
  },

  // ── Icon span ────────────────────────────────────────────────────────────────
  navIcon: {
    fontSize: tokens.fontSizeBase500, // 20px
    width: '20px',
    height: '20px',
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    flexShrink: 0,
    opacity: 0.7, // dimmed at rest
    transitionProperty: 'opacity, color',
    transitionDuration: tokens.durationNormal,
    transitionTimingFunction: tokens.curveEasyEase,
  },
  navIconActive: {
    opacity: 1,
    color: tokens.colorBrandForeground1,
  },

  // ── Nav item label ────────────────────────────────────────────────────────────────
  // Always rendered. Fades out and shrinks width-to-0 on collapse so the
  // icon never moves — only the empty space animates away.
  navLabel: {
    overflow: 'hidden',
    whiteSpace: 'nowrap',
    opacity: 1,
    maxWidth: '200px',
    transitionProperty: 'max-width, opacity',
    transitionDuration: `${tokens.durationFast}, ${tokens.durationFast}`,
    transitionTimingFunction: tokens.curveEasyEase,
  },
  navLabelCollapsed: {
    maxWidth: '0px',
    opacity: 0,
  },

  // ── Nav section wrapper ──────────────────────────────────────────────────
  // Groups one section label + its <ul>. The sidebar gap between multiple
  // navSection divs is spacingVerticalS (8px). The gap here (between the
  // aria-hidden label and the list) is spacingVerticalXXS (2px).
  navSection: {
    display: 'flex',
    flexDirection: 'column',
    gap: tokens.spacingVerticalXXS,
  },

  // ── Catalog variant overrides ────────────────────────────────────────────
  // Panels match the outer canvas color for a flat, full-dark catalog layout.
  sidebarCatalog: {
    backgroundColor: tokens.colorNeutralBackground4,
    boxShadow: 'none',
  },
  mainCatalog: {
    backgroundColor: tokens.colorNeutralBackground4,
    boxShadow: 'none',
  },

  // ── Collapse toggle (bottom of sidebar) ──────────────────────────────────────
  // Icon-only Button (appearance="subtle"), aligned to the right edge.
  sidebarFooter: {
    marginTop: 'auto',
    paddingTop: tokens.spacingVerticalS,
    display: 'flex',
    justifyContent: 'flex-end',
  },

  // Mobile drawer top bar — holds the close button at the top-right of the drawer.
  sidebarMobileTopBar: {
    display: 'flex',
    justifyContent: 'flex-end',
    // No paddingBottom — sidebar gap (spacingVerticalS) provides the spacing below.
  },
});

// ─── Nav data ─────────────────────────────────────────────────────────────────

interface NavItemDef {
  path: string;
  label: string;
  icon: React.ReactNode; // outline — shown at rest and on hover
  activeIcon: React.ReactNode; // filled  — shown only when active
}

interface NavSectionDef {
  label: string;
  items: NavItemDef[];
}

const NAV_SECTIONS: NavSectionDef[] = [
  {
    label: 'Main',
    items: [
      { path: '/', label: 'Home', icon: <HomeRegular />, activeIcon: <HomeFilled /> },
      { path: '/catalog', label: 'Catalog', icon: <GridRegular />, activeIcon: <GridFilled /> },
      {
        path: '/docs',
        label: 'Documents',
        icon: <DocumentRegular />,
        activeIcon: <DocumentFilled />,
      },
      { path: '/ai', label: 'AI Chat', icon: <BotRegular />, activeIcon: <BotFilled /> },
    ],
  },
  {
    label: 'Settings',
    items: [
      {
        path: '/settings',
        label: 'Settings',
        icon: <SettingsRegular />,
        activeIcon: <SettingsFilled />,
      },
    ],
  },
];

// ─── Shell component ──────────────────────────────────────────────────────────

export function ShellAndNavigation({ variant = 'standard' }: { variant?: 'standard' | 'catalog' }) {
  const styles = useStyles();
  const [collapsed, setCollapsed] = React.useState(false);
  const [mobileOpen, setMobileOpen] = React.useState(false);
  const [viewport, setViewport] = React.useState<'desktop' | 'tablet' | 'mobile'>('desktop');
  const [drawerReady, setDrawerReady] = React.useState(false);
  const [, forceRender] = React.useReducer((n) => n + 1, 0);
  // Set to true synchronously before any viewport state update so the
  // sidebar's transition class is suppressed in the exact same render.
  const viewportChangingRef = React.useRef(false);
  const shellRef = React.useRef<HTMLDivElement>(null);

  // Respond to container width:
  //   < 768px  → mobile: sidebar becomes a fixed overlay drawer
  //   < 1024px → tablet: sidebar auto-collapses to icon-only
  //   ≥ 1024px → desktop: user controls collapse
  React.useEffect(() => {
    const el = shellRef.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) => {
      const w = entry.contentRect.width;
      // Mark viewport as changing synchronously so the transition class is
      // absent in this render, then restore it one frame later.
      viewportChangingRef.current = true;
      if (w < 768) {
        setViewport('mobile');
        setMobileOpen(false);
      } else if (w < 1024) {
        setViewport('tablet');
        setCollapsed(true);
        setMobileOpen(false);
      } else {
        setViewport('desktop');
      }
      requestAnimationFrame(() => {
        viewportChangingRef.current = false;
        forceRender();
      });
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const isMobile = viewport === 'mobile';

  // Enable drawer transition one frame after entering mobile mode so the
  // initial snap-to-hidden is instant, while open/close both animate.
  React.useEffect(() => {
    if (isMobile) {
      const id = requestAnimationFrame(() => setDrawerReady(true));
      return () => cancelAnimationFrame(id);
    } else {
      setDrawerReady(false);
    }
  }, [isMobile]);

  // Mobile: full-width inside drawer (effectiveCollapsed drives navLabel visibility).
  // Tablet/desktop: user-controlled via the toggle button (ResizeObserver auto-sets
  // collapsed=true on tablet entry, user can expand from there).
  const effectiveCollapsed = isMobile ? false : collapsed;

  return (
    <MemoryRouter initialEntries={['/']}>
      <div ref={shellRef} className={styles.shell}>
        {/* ── Header ── */}
        <header className={styles.header}>
          <div className={styles.headerBrand}>
            {isMobile && (
              <Tooltip content="Open navigation" relationship="label">
                <Button
                  appearance="transparent"
                  icon={<NavigationRegular />}
                  aria-label="Open navigation"
                  onClick={() => setMobileOpen(true)}
                />
              </Tooltip>
            )}
            <AppsRegular className={styles.brandIcon} />
            <span className={styles.headerBrandName}>CoreAI Portal</span>
          </div>
          <div className={styles.headerActions}>
            <Tooltip content="Notifications" relationship="label">
              <Button appearance="transparent" icon={<AlertRegular />} aria-label="Notifications" />
            </Tooltip>
            <Avatar name="Alex Britez" size={28} />
          </div>
        </header>

        {/* ── Mobile drawer backdrop ── */}
        {isMobile && mobileOpen && (
          <div
            className={styles.drawerBackdrop}
            onClick={() => setMobileOpen(false)}
            aria-hidden="true"
          />
        )}

        {/* ── Shell body: sidebar + main, with 2px gap ── */}
        <div className={styles.shellBody}>
          {/* ── Sidebar (desktop/tablet: flex item; mobile: fixed overlay) ── */}
          <nav
            aria-label="Primary navigation"
            className={mergeClasses(
              styles.sidebar,
              !isMobile && !viewportChangingRef.current && styles.sidebarTransitionable,
              !isMobile && effectiveCollapsed && styles.sidebarCollapsed,
              isMobile && styles.sidebarMobileDrawer,
              isMobile && drawerReady && styles.sidebarMobileDrawerTransition,
              isMobile && mobileOpen && styles.sidebarMobileDrawerOpen,
              variant === 'catalog' && styles.sidebarCatalog,
            )}
          >
            {/* ── Mobile close button (top-right) ── */}
            {isMobile && (
              <div className={styles.sidebarMobileTopBar}>
                <Tooltip relationship="label" content="Close navigation" positioning="after">
                  <Button
                    appearance="subtle"
                    icon={<PanelLeftContractRegular />}
                    aria-label="Close navigation"
                    onClick={() => setMobileOpen(false)}
                  />
                </Tooltip>
              </div>
            )}

            {NAV_SECTIONS.map((section) => (
              <div key={section.label} className={styles.navSection}>
                <span
                  aria-hidden="true"
                  className={mergeClasses(
                    styles.sectionLabel,
                    effectiveCollapsed && styles.sectionLabelCollapsed,
                  )}
                >
                  {section.label}
                </span>

                <ul aria-label={section.label} className={styles.navGroup}>
                  {section.items.map((item) => {
                    const link = (
                      <NavLink
                        to={item.path}
                        end={item.path === '/'}
                        aria-label={item.label}
                        className={({ isActive }) =>
                          mergeClasses(
                            styles.navItem,
                            effectiveCollapsed && styles.navItemCollapsed,
                            isActive && styles.navItemActive,
                          )
                        }
                      >
                        {({ isActive }) => (
                          <>
                            <span
                              className={mergeClasses(
                                styles.navIcon,
                                isActive && styles.navIconActive,
                              )}
                            >
                              {isActive ? item.activeIcon : item.icon}
                            </span>
                            <span
                              className={mergeClasses(
                                styles.navLabel,
                                effectiveCollapsed && styles.navLabelCollapsed,
                              )}
                              aria-hidden={effectiveCollapsed || undefined}
                            >
                              {item.label}
                            </span>
                          </>
                        )}
                      </NavLink>
                    );

                    return (
                      <li key={item.path}>
                        {effectiveCollapsed ? (
                          <Tooltip relationship="label" content={item.label} positioning="after">
                            {link}
                          </Tooltip>
                        ) : (
                          link
                        )}
                      </li>
                    );
                  })}
                </ul>
              </div>
            ))}

            {/* ── Collapse toggle (desktop + tablet) ── */}
            {!isMobile && (
              <div className={styles.sidebarFooter}>
                <Tooltip
                  relationship="label"
                  content={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
                  positioning="after"
                >
                  <Button
                    appearance="subtle"
                    icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
                    aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
                    aria-expanded={!collapsed}
                    onClick={() => setCollapsed((c) => !c)}
                  />
                </Tooltip>
              </div>
            )}
          </nav>

          {/* ── Main content ── */}
          <main className={mergeClasses(styles.main, variant === 'catalog' && styles.mainCatalog)}>
            <Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
              Main content area. Use the sidebar to switch views. Toggle Theme / Mode in the
              Storybook toolbar to preview light and dark.
            </Text>
          </main>
        </div>
      </div>
    </MemoryRouter>
  );
}
```

## Resources

- [Figma component](https://www.figma.com/design/eXfSQrRg3poHRgmr0ivN5V/CoreAI-Figma-Library?node-id=433143-30585)
- [Fluent guidance](https://fluent2.microsoft.design/components/web/react/core/nav/usage)