# AppShell

A responsive application shell with an animated icon rail, contextual navigation, and a mobile drawer.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/app-shell.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install cn@^0.4.0 lucide-react motion@^12.38.0
```

## Usage [#usage]

```tsx
"use client";

import { useState } from "react";
import { AppShell } from "@/components/ui/app-shell";

export default function WorkspaceLayout() {
  const [active, setActive] = useState("overview");

  return (
    <AppShell
      brand={{ name: "Acme" }}
      navigation={[
        {
          id: "overview",
          label: "Overview",
          active: active === "overview",
          onClick: () => setActive("overview"),
        },
        {
          id: "projects",
          label: "Projects",
          active: active === "projects",
          onClick: () => setActive("projects"),
        },
      ]}
    >
      <h1>{active === "overview" ? "Overview" : "Projects"}</h1>
    </AppShell>
  );
}
```

AppShell owns the layout and navigation presentation. Your application owns routes, active destinations, resource data, and search UI. These previews demonstrate local state, not real routing or authentication. AppShell uses states and runnable examples rather than a props playground.

`showHeader` defaults to `true`. On desktop, the sidebar toggle sits beside the brand or custom workspace header, not in the content header. The collapsed rail keeps an expand control in its identity area. Set `showHeader` to `false` for a headerless shell; the main surface remains edge-to-edge so the consumer can choose page spacing. `PageContainer` now defaults to `p-4 sm:p-6 lg:p-12` (16/24/48px), so add it only where that inset is wanted. This is a breaking spacing change from the previous shell padding and `lg:p-8` page container.

An optional `user` config renders the built-in account menu. It is presentation only: the consumer supplies the identity and `DropdownEntry[]` actions. A provided `sidebarFooter` takes precedence, including `null`. Async action and cancellation behavior follows Dropdown's `closeOnSelect` contract. Use `labels` to localize shell navigation controls.

```tsx
import { AppShell } from "@/components/ui/app-shell";
import { PageContainer } from "@/components/ui/page-container";

export function HeaderlessLayout() {
  return (
    <AppShell brand={{ name: "Acme" }} navigation={[]} showHeader={false}>
      <PageContainer>Page content</PageContainer>
    </AppShell>
  );
}
```

Install PageContainer separately when using this composition. Omit it for full-bleed content. Headerless mode hides `header`, `actions`, and the search button without relocating them; the search shortcut still runs when `onSearch` is supplied. The mobile brand tab and floating menu honor safe-area insets, with initial content clearance. When embedding a headerless shell in a preview, give the shell `relative isolate [contain:layout]` to contain these fixed controls without changing the full-page layout. Its drawer opens from the right; header mode opens from the left. The sidebar uses `sidebar`/`sidebar-foreground` tokens.

### Built-in identity and localized controls [#built-in-identity-and-localized-controls]

<ComponentPreview name="app-shell-user-demo" title="app-shell-user-demo.tsx">
  <AppShellUserDemo />
</ComponentPreview>

`user` requires `name` and `items: readonly DropdownEntry[]`; optional `description`, `avatar`, `pending`, and `menuLabel` configure identity. Missing avatar uses initials. `pending` disables the trigger and announces busy state. The collapsed trigger has an identity tooltip. Groups, submenus, real links, cancellation, overlays and error callbacks use Dropdown's existing contract. No authentication is implemented.

Accepted synchronous actions close mobile navigation. Async actions keep the drawer owner mounted until fulfillment, even when Dropdown closes its menu immediately by default. Rejected/thrown actions, canceled events, `closeOnSelect: false`, overlay activation and menu-only dismissal do not close navigation. `closeOnSelect: "success"` also keeps the menu open while pending. A late fulfillment from a dismissed/reopened menu does not close the newer navigation session.

## States [#states]

### Expanded [#expanded]

<ComponentPreview name="app-shell-demo" title="app-shell-demo.tsx">
  <AppShellDemo />
</ComponentPreview>

Desktop navigation is 15rem wide. Its middle section scrolls independently while the sidebar header and footer stay in place. The top header is sticky in a real application.

### Collapsed icon rail [#collapsed-icon-rail]

<ComponentPreview name="app-shell-demo" title="app-shell-demo.tsx">
  <AppShellCollapsedDemo />
</ComponentPreview>

The desktop toggle animates the sidebar to 4rem and fades the labels. Standard items retain accessible names and show tooltips in the icon rail. An item without an icon uses the first character of its label. Custom controls must provide their own compact presentation and accessible names.

### Mobile drawer [#mobile-drawer]

Below the default Tailwind `md` breakpoint (768px), the desktop sidebar is hidden and the header's menu button opens a left-side Drawer. Try either preview at a mobile viewport. Navigation is always expanded inside the drawer, even when the desktop icon rail is collapsed. Standard destination selection closes the drawer; Escape and the close button also dismiss it. Changing to a desktop viewport closes the mobile drawer.

The preview height overrides are only for embedding examples. Breakpoints follow the viewport, not the preview width; a narrow preview on a desktop viewport does not automatically activate mobile navigation.

## Examples [#examples]

### Full-bleed content [#full-bleed-content]

<ComponentPreview name="app-shell-full-bleed-demo" title="app-shell-full-bleed-demo.tsx">
  <AppShellFullBleedDemo />
</ComponentPreview>

The canvas omits PageContainer and owns its own internal spacing. Other page-style examples use PageContainer exactly once.

### Workspace and account controls [#workspace-and-account-controls]

<ComponentPreview name="app-shell-workspace-demo" title="app-shell-workspace-demo.tsx">
  <AppShellWorkspaceDemo />
</ComponentPreview>

Dropdown is included in AppShell's dependency closure. `sidebarHeader` replaces the default brand row; both it and `sidebarFooter` receive `{ collapsed, closeNavigation }`. Both receive `collapsed: false` on mobile. Slots are rendered separately on desktop and mobile: keep shared selection state in the consuming application, not inside a slot-local component.

### Hierarchy and controlled collapse [#hierarchy-and-controlled-collapse]

<ComponentPreview name="app-shell-hierarchy-demo" title="app-shell-hierarchy-demo.tsx">
  <AppShellHierarchyDemo />
</ComponentPreview>

Open an agent to replace the middle navigation with a return action and detail destinations. The agent switcher uses the sidebar header slot, outside the transitioning panel, rather than returning a composite Dropdown from `renderControl`. Return with **All agents**. Dropdown is included in the shell closure; the example also installs PageContainer and controls `collapsed` through `onCollapsedChange`.

`navigationContext={{ key, depth }}` is presentation metadata, not a router or navigation stack. Keep a stable key for each panel and a consistent depth for that key. Changing the key triggers a 220ms slide and fade; increasing depth moves forward and decreasing depth moves back. A different key at the same depth moves forward. Active-item changes and switching resources within the same panel keep the key unchanged and do not replay the hierarchy transition. Without `navigationContext`, navigation updates without panel transitions.

The hierarchy can have more than two levels. Your application supplies each panel's items, return actions, and state. AppShell does not fetch resources, remember history, or manage browser back navigation.

### Navigation item contract [#navigation-item-contract]

| Property        | Behavior                                                                                                                                                                     |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `label`   | Required unique identity and accessible label for a standard item.                                                                                                           |
| `icon`          | Optional decorative React node; the first label character is the fallback.                                                                                                   |
| `href`          | Uses a native anchor unless disabled. Use `renderControl` for router-specific Link components.                                                                               |
| `onClick`       | Called synchronously on selection; mobile navigation closes immediately, without awaiting async work.                                                                        |
| `active`        | Adds selected styling and `aria-current="page"`; the application determines the active item.                                                                                 |
| `disabled`      | Standard item becomes a disabled button, including when it has an href.                                                                                                      |
| `dividerAfter`  | Adds a separator after a standard or custom item.                                                                                                                            |
| `renderControl` | Returns one ReactElement control receiving `{ collapsed, closeNavigation }`. The shell composes it as a tooltip trigger on collapsed desktop only, without a button wrapper. |

With `renderControl`, the consumer owns the label, icon, active/disabled behavior, click callback, and mobile closing. The item-level `href`, `onClick`, `active`, and `disabled` do not apply automatically. `id` and `dividerAfter` still apply. Use `closeNavigation()` after a custom selection when dismissal is appropriate.

The returned native element or router Link must forward received props, handlers and ref to one focusable root. Fragments, multiple roots and components dropping these props are unsupported. This replaces the previous arbitrary-node contract. Preserve native href/modifier semantics and compose caller cancellation when deciding whether to close navigation. Tooltips use hover and keyboard focus; expanded/mobile navigation has no redundant tooltip.

### Optional search [#optional-search]

Supplying `onSearch` enables the search button and a document-level Cmd/Ctrl+K callback. AppShell does not render a search dialog or query results. With no callback, there is no search UI or global listener. Prevented, repeated, and Alt-modified key events are ignored; the shortcut also runs while editing an input. Mount only one search-enabled shell per document to avoid duplicate handlers.

### Consumer setup and optional stories [#consumer-setup-and-optional-stories]

Requires React 19, Tailwind CSS 4, `motion`, `cn`, and `lucide-react`, together with the Vandor Button, Drawer, Dropdown, and Tooltip registry items. Their transitive registry/package dependencies are installed by the CLI. Use your global Tailwind stylesheet and semantic `background`, `foreground`, `sidebar`, `sidebar-foreground`, `border`, `muted`, `muted-foreground`, `accent`, `accent-foreground`, `ring`, and underlying Button/Drawer theme tokens. Keep the standard Tailwind `md` breakpoint at 768px to match the drawer's desktop media query.

AppShell is a client component and can be imported from a Next.js Server Component. Functions such as navigation callbacks and render slots must be supplied inside a client boundary, not passed from a Server Component as serialized props. Installation uses the configured UI alias in `components.json`, with no fixed target directory. Review CLI overwrite prompts for shared dependencies before replacing customized primitives.

Copying the Source block alone is insufficient: AppShell requires its colocated `app-shell-user.tsx` helper plus Button, Drawer, Dropdown and Tooltip. Prefer CLI installation; for manual setup install these Vandor items and declared packages first, then copy both shell modules into the same UI directory. Examples are not distributed with the component.

Install AppShell first, then optionally install its portable stories:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/app-shell-stories.json
```

Use your existing `@storybook/react` with matching Storybook package versions (type/composition checked with 10.6.1), discover `**/*.stories.tsx` in the configured UI directory, and load the global Tailwind/theme stylesheet. The stories import colocated AppShell and Button. No additional providers or addons are required; the stories-only item does not reinstall core files, force-install Storybook packages, or modify Storybook configuration. Use the consumer's viewport and reduced-motion settings for those stories. This project has no Storybook server, so Storybook UI is not verified here.

## Behavior & Accessibility [#behavior--accessibility]

The shell renders a sidebar `aside`, a named navigation `nav`, a top `header`, and one `main`. Do not put another `main` in children. The skip link targets the main content, and the desktop toggle exposes `aria-expanded` and `aria-controls`. Navigation supports normal Tab and native link/button keyboard behavior, not treeview arrow-key navigation.

Desktop collapsed state is uncontrolled by default; `defaultCollapsed` is read only on mount. With `collapsed`, your application must update that prop in `onCollapsedChange`. The callback reports a toggle request synchronously, not animation completion. Mobile open state is separate and internal. Neither state is persisted automatically.

Hierarchy transitions use Motion's `AnimatePresence`. Outgoing panels are `inert` and hidden from assistive technology. If focus is inside a retiring panel, it moves to the incoming panel; otherwise the shell does not steal focus. The drawer provides modal focus management and opener focus restoration. Custom dropdowns/router links must preserve their own focus and dismissal contracts.

Sidebar width and label transitions are 220ms and 140ms respectively. Hierarchy panels travel 28px with a 220ms slide/fade. All respect `prefers-reduced-motion`; the existing Drawer handles its own reduced-motion styles. AppShell adds no page-enter animation, search implementation, audio, or haptics.

Standard navigation labels truncate visually but remain available through accessible names and collapsed tooltips. Keep header actions compact and give arbitrary slot content its own responsive layout. The shell accepts only its documented props; it does not forward native root attributes or refs.

## Props [#props]

| Prop              | Type                                                                                                                                               | Default            | Description                                                                                                                                                                                                                                                           |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| actions           | ReactNode                                                                                                                                          | undefined          | Trailing header actions. Consumers provide accessible names and responsive layouts.                                                                                                                                                                                   |
| brand             | \{ name: string; icon?: ReactNode; href?: string }                                                                                                 | Required           | Brand shown in the default desktop sidebar header. name also labels the mobile drawer when sidebarHeader is absent. href turns the desktop brand into a link.                                                                                                         |
| children          | ReactNode                                                                                                                                          | Required           | Content inside the shell's main landmark. Do not nest another main element.                                                                                                                                                                                           |
| className         | string                                                                                                                                             | undefined          | Merged classes on the shell root. Default is a viewport-height application layout.                                                                                                                                                                                    |
| collapsed         | boolean                                                                                                                                            | undefined          | Controlled desktop collapse state. The caller must update it in response to onCollapsedChange.                                                                                                                                                                        |
| contentClassName  | string                                                                                                                                             | undefined          | Merged classes on the main content element, not the navigation or top header.                                                                                                                                                                                         |
| defaultCollapsed  | boolean                                                                                                                                            | false              | Initial uncontrolled desktop collapse state. Later changes to this prop do not reset the state.                                                                                                                                                                       |
| header            | ReactNode                                                                                                                                          | undefined          | Content after the navigation trigger and optional search button in the top header.                                                                                                                                                                                    |
| labels            | \{ sidebar?: string; skipToContent?: string; openNavigation?: string; closeNavigation?: string; expandSidebar?: string; collapseSidebar?: string } | English labels     | Accessible shell labels, independent of navigationLabel and searchLabel.                                                                                                                                                                                              |
| navigation        | readonly AppShellNavigationItem\[]                                                                                                                 | Required           | Ordered navigation. Each item requires a unique id and label. Optional icon, href, onClick, active, disabled, dividerAfter and renderControl configure the item. An empty array is supported.                                                                         |
| navigationContext | \{ key: string; depth: number }                                                                                                                    | undefined          | Optional transition identity. A key change replaces the middle navigation panel. Increasing depth slides in, decreasing depth slides back; same depth slides in. Keep key stable for active-menu and same-panel resource changes.                                     |
| navigationLabel   | string                                                                                                                                             | "Main navigation"  | Accessible name of navigation; also the mobile drawer title when a custom sidebarHeader is supplied.                                                                                                                                                                  |
| onCollapsedChange | (collapsed: boolean) => void                                                                                                                       | undefined          | Called synchronously with the requested next state when the desktop toggle is activated. Not called for initial state or external prop updates.                                                                                                                       |
| onSearch          | () => void                                                                                                                                         | undefined          | Enables a search button and document-level Cmd/Ctrl+K callback. No search dialog or shortcut listener exists without it.                                                                                                                                              |
| searchLabel       | string                                                                                                                                             | "Search workspace" | Visible search-button label. Only used with onSearch.                                                                                                                                                                                                                 |
| showHeader        | boolean                                                                                                                                            | true               | Show the topbar. Headerless mode retains sidebar controls and a right-side mobile drawer; header/actions/search UI is hidden, not relocated.                                                                                                                          |
| sidebarFooter     | ReactNode \| ((state: AppShellNavigationState) => ReactNode)                                                                                       | undefined          | Persistent footer below the scrollable navigation. Receives collapsed and closeNavigation; mobile receives collapsed=false. Overrides user, including an explicit null.                                                                                               |
| sidebarHeader     | ReactNode \| ((state: AppShellNavigationState) => ReactNode)                                                                                       | undefined          | Replaces the brand row. Receives collapsed and closeNavigation. Mobile receives collapsed=false; closeNavigation closes only the mobile drawer.                                                                                                                       |
| user              | AppShellUser                                                                                                                                       | undefined          | Optional name, description, avatar, readonly DropdownEntry\[] items, pending and menuLabel. Custom sidebarFooter (including null) overrides this identity menu. Async drawer closure waits for accepted fulfillment; rejected and canceled actions retain navigation. |

AppShell uses a deliberately restricted props API; it does not forward arbitrary native attributes, refs or event handlers. Rendered slots and renderControl own their elements and native props. Navigation items with href use links unless disabled; without href they use buttons.

## Source [#source]

```tsx
"use client";

// Adapted from Universe's AppShell, originally inspired by 7ovr's
// app-shell-3 icon-rail layout (free blocks: MIT-0).
// https://7ovr.com/r/app-shell-3.json
// Application data, routing and resource controls remain consumer-owned.
import { cn } from "cn";
import {
  MenuIcon,
  PanelLeftCloseIcon,
  PanelLeftOpenIcon,
  SearchIcon,
} from "lucide-react";
import {
  AnimatePresence,
  motion,
  useIsPresent,
  useReducedMotion,
} from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";

import { AppShellUserFooter } from "./app-shell-user";
import type { AppShellUser } from "./app-shell-user";
import { Button } from "./button";
import { Drawer, DrawerBody } from "./drawer";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "./tooltip";

export type { AppShellUser } from "./app-shell-user";
export interface AppShellNavigationState {
  collapsed: boolean;
  closeNavigation: () => void;
}

export interface AppShellNavigationItem {
  id: string;
  label: string;
  icon?: ReactNode;
  href?: string;
  onClick?: () => void;
  active?: boolean;
  disabled?: boolean;
  /** Separates this item from the following navigation. */
  dividerAfter?: boolean;
  /** Replaces the standard item. The caller owns semantics and disabled state. */
  renderControl?: (state: AppShellNavigationState) => ReactElement;
}

export interface AppShellProps {
  brand: { name: string; icon?: ReactNode; href?: string };
  navigation: readonly AppShellNavigationItem[];
  children: ReactNode;
  header?: ReactNode;
  showHeader?: boolean;
  actions?: ReactNode;
  sidebarHeader?: ReactNode | ((state: AppShellNavigationState) => ReactNode);
  sidebarFooter?: ReactNode | ((state: AppShellNavigationState) => ReactNode);
  user?: AppShellUser;
  /** Enables the search button and Cmd/Ctrl+K listener only when supplied. */
  onSearch?: () => void;
  searchLabel?: string;
  defaultCollapsed?: boolean;
  collapsed?: boolean;
  onCollapsedChange?: (collapsed: boolean) => void;
  navigationLabel?: string;
  labels?: {
    sidebar?: string;
    skipToContent?: string;
    openNavigation?: string;
    closeNavigation?: string;
    expandSidebar?: string;
    collapseSidebar?: string;
  };
  /** Change key only for a new panel, not active items or same-level resources. */
  navigationContext?: { key: string; depth: number };
  className?: string;
  contentClassName?: string;
}

const NavigationPanel = ({
  children,
  direction,
  reduceMotion,
}: {
  children: ReactNode;
  direction: number;
  reduceMotion: boolean;
}) => {
  const present = useIsPresent();
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    // Never leave keyboard focus in an outgoing, inert navigation panel.
    if (!present && ref.current?.contains(document.activeElement)) {
      ref.current.parentElement
        ?.querySelector<HTMLElement>("[data-navigation-panel]:not([inert])")
        ?.focus();
    }
  }, [present]);
  return (
    <motion.div
      ref={ref}
      data-navigation-panel=""
      data-navigation-direction={direction}
      tabIndex={-1}
      inert={!present}
      aria-hidden={!present || undefined}
      custom={direction}
      variants={{
        enter: (travel: number) => ({
          opacity: reduceMotion ? 1 : 0,
          x: reduceMotion ? 0 : travel * 28,
        }),
        exit: (travel: number) => ({
          opacity: reduceMotion ? 1 : 0,
          x: reduceMotion ? 0 : travel * -28,
        }),
        visible: { opacity: 1, x: 0 },
      }}
      initial="enter"
      animate="visible"
      exit="exit"
      transition={{
        duration: reduceMotion ? 0 : 0.22,
        ease: [0.22, 1, 0.36, 1],
      }}
      className="col-start-1 row-start-1 min-w-0 self-start outline-none"
    >
      {children}
    </motion.div>
  );
};

const useNavigationDirection = (
  navigationContext: AppShellProps["navigationContext"]
) => {
  const [previousContext, setPreviousContext] = useState(navigationContext);
  const [direction, setDirection] = useState(1);
  if (navigationContext?.key !== previousContext?.key) {
    setPreviousContext(navigationContext);
    setDirection(
      (navigationContext?.depth ?? 0) >= (previousContext?.depth ?? 0) ? 1 : -1
    );
  }
  return direction;
};

const useShellTransitions = () => {
  const reduceMotion = useReducedMotion();
  return {
    labelTransition: {
      duration: reduceMotion ? 0 : 0.14,
      ease: "easeOut" as const,
    },
    reduceMotion,
    sidebarTransition: {
      duration: reduceMotion ? 0 : 0.22,
      ease: [0.22, 1, 0.36, 1] as const,
    },
  };
};

export const AppShell = ({
  brand,
  navigation,
  children,
  header,
  showHeader = true,
  actions,
  sidebarHeader,
  sidebarFooter,
  user,
  onSearch,
  searchLabel = "Search workspace",
  defaultCollapsed = false,
  collapsed: controlledCollapsed,
  onCollapsedChange,
  navigationLabel = "Main navigation",
  labels,
  navigationContext,
  className,
  contentClassName,
}: AppShellProps) => {
  const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
  const [mobileOpen, setMobileOpen] = useState(false);
  const collapsed = controlledCollapsed ?? internalCollapsed;
  const { reduceMotion, sidebarTransition, labelTransition } =
    useShellTransitions();
  const navigationDirection = useNavigationDirection(navigationContext);
  // Animate real width as in Universe: FLIP scaling distorts nested scroll areas.
  const contentId = useId();
  const sidebarId = useId();
  const closeNavigation = () => setMobileOpen(false);

  useEffect(() => {
    if (!onSearch) {
      return;
    }
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.defaultPrevented || event.repeat || event.altKey) {
        return;
      }
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
        event.preventDefault();
        onSearch();
      }
    };
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [onSearch]);
  useEffect(() => {
    const media = window.matchMedia("(min-width: 768px)");
    const closeOnDesktop = () => {
      if (media.matches) {
        setMobileOpen(false);
      }
    };
    media.addEventListener("change", closeOnDesktop);
    return () => media.removeEventListener("change", closeOnDesktop);
  }, []);

  const renderNavigation = (compact: boolean, mobile = false) => (
    <nav aria-label={navigationLabel} className="flex flex-col gap-1 p-2">
      {navigation.map((item) => {
        const label = (
          <>
            <span
              aria-hidden="true"
              className="flex size-5 shrink-0 items-center justify-center"
            >
              {item.icon ?? item.label.slice(0, 1)}
            </span>
            <motion.span
              initial={false}
              animate={{ opacity: compact ? 0 : 1 }}
              transition={labelTransition}
              aria-hidden={compact || undefined}
              className="min-w-0 truncate"
            >
              {item.label}
            </motion.span>
          </>
        );
        const props = {
          "aria-current": item.active ? ("page" as const) : undefined,
          "aria-label": item.label,
          className: cn(
            "h-10 w-full justify-start gap-3 overflow-hidden px-3",
            item.active && "bg-background text-foreground"
          ),
          onClick: () => {
            item.onClick?.();
            if (mobile) {
              closeNavigation();
            }
          },
        };
        const control =
          item.href && !item.disabled ? (
            <Button
              variant="ghost"
              nativeButton={false}
              render={<a href={item.href} aria-label={item.label} />}
              {...props}
            >
              {label}
            </Button>
          ) : (
            <Button variant="ghost" disabled={item.disabled} {...props}>
              {label}
            </Button>
          );
        return (
          <div key={item.id}>
            {item.renderControl ? (
              <Tooltip>
                <TooltipTrigger
                  disabled={!compact || mobile}
                  render={item.renderControl({
                    closeNavigation,
                    collapsed: compact,
                  })}
                />
                <TooltipContent side="right">{item.label}</TooltipContent>
              </Tooltip>
            ) : (
              <Tooltip>
                <TooltipTrigger
                  disabled={!compact || mobile}
                  render={control}
                />
                <TooltipContent side="right">{item.label}</TooltipContent>
              </Tooltip>
            )}
            {item.dividerAfter && (
              <div
                data-navigation-separator={item.id}
                role="separator"
                className="my-2 border-b border-border"
              />
            )}
          </div>
        );
      })}
    </nav>
  );
  const renderContextNavigation = (compact: boolean, mobile = false) =>
    navigationContext ? (
      <div className="grid min-w-0 overflow-x-clip">
        <AnimatePresence initial={false} custom={navigationDirection}>
          <NavigationPanel
            key={navigationContext.key}
            direction={navigationDirection}
            reduceMotion={!!reduceMotion}
          >
            {renderNavigation(compact, mobile)}
          </NavigationPanel>
        </AnimatePresence>
      </div>
    ) : (
      renderNavigation(compact, mobile)
    );

  const renderBrand = (compact: boolean) => {
    const content = (
      <>
        <span
          aria-hidden="true"
          className="flex size-6 shrink-0 items-center justify-center font-semibold"
        >
          {brand.icon ?? brand.name.slice(0, 1)}
        </span>
        <motion.span
          initial={false}
          animate={{ opacity: compact ? 0 : 1 }}
          transition={labelTransition}
          aria-hidden={compact || undefined}
          className={cn("min-w-0 truncate font-semibold", compact && "sr-only")}
        >
          {brand.name}
        </motion.span>
        {compact && <span className="sr-only">{brand.name}</span>}
      </>
    );
    const styles = cn(
      "flex h-full min-w-0 flex-1 items-center gap-3 overflow-hidden px-3",
      compact && "h-10 w-full flex-none justify-center gap-0 px-0"
    );
    return brand.href ? (
      <a
        href={brand.href}
        aria-label={brand.name}
        className={cn(
          styles,
          "focus-visible:outline-2 focus-visible:outline-ring"
        )}
      >
        {content}
      </a>
    ) : (
      <div className={styles}>{content}</div>
    );
  };
  const renderSidebarHeader = (compact: boolean) =>
    sidebarHeader ? (
      <div className="flex min-w-0 flex-1 items-center">
        {typeof sidebarHeader === "function"
          ? sidebarHeader({ closeNavigation, collapsed: compact })
          : sidebarHeader}
      </div>
    ) : (
      renderBrand(compact)
    );
  const renderFooter = (compact: boolean) => {
    if (sidebarFooter === undefined) {
      return user ? (
        <AppShellUserFooter
          user={user}
          collapsed={compact}
          closeNavigation={closeNavigation}
        />
      ) : null;
    }
    return (
      <div className="mt-auto shrink-0 border-t border-border p-2">
        {typeof sidebarFooter === "function"
          ? sidebarFooter({ closeNavigation, collapsed: compact })
          : sidebarFooter}
      </div>
    );
  };

  const renderCollapseControl = (rail = false) => (
    <Tooltip>
      <TooltipTrigger
        render={
          <Button
            variant="ghost"
            size="icon"
            className={cn(
              "hidden shrink-0 md:inline-flex",
              rail && "group relative"
            )}
            aria-label={
              collapsed
                ? (labels?.expandSidebar ?? "Expand sidebar")
                : (labels?.collapseSidebar ?? "Collapse sidebar")
            }
            aria-expanded={!collapsed}
            aria-controls={sidebarId}
            onClick={() => {
              if (controlledCollapsed === undefined) {
                setInternalCollapsed(!collapsed);
              }
              onCollapsedChange?.(!collapsed);
            }}
          >
            {rail && collapsed && (
              <span
                aria-hidden="true"
                className="absolute flex size-6 items-center justify-center font-semibold group-hover:opacity-0 group-focus-visible:opacity-0"
              >
                {brand.icon ?? brand.name.slice(0, 1)}
              </span>
            )}
            <span
              className={cn(
                "flex",
                rail &&
                  collapsed &&
                  "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
              )}
            >
              {collapsed ? (
                <PanelLeftOpenIcon aria-hidden="true" />
              ) : (
                <PanelLeftCloseIcon aria-hidden="true" />
              )}
            </span>
          </Button>
        }
      />
      <TooltipContent side="right">
        {collapsed
          ? (labels?.expandSidebar ?? "Expand sidebar")
          : (labels?.collapseSidebar ?? "Collapse sidebar")}
      </TooltipContent>
    </Tooltip>
  );
  const renderMobileNavigation = () => (
    <Drawer
      title={sidebarHeader ? navigationLabel : brand.name}
      open={mobileOpen}
      onOpenChange={setMobileOpen}
      swipeDirection={showHeader ? "left" : "right"}
      contentProps={{
        closeButtonLabel: labels?.closeNavigation ?? "Close navigation",
      }}
      showSwipeHandle={false}
      trigger={
        <Button
          variant="ghost"
          size="icon"
          className={cn(
            "md:hidden",
            !showHeader &&
              "fixed right-4 top-[max(1rem,env(safe-area-inset-top))] z-20 bg-background ring-1 ring-border"
          )}
          aria-label={labels?.openNavigation ?? "Open navigation"}
        >
          <MenuIcon aria-hidden="true" />
        </Button>
      }
    >
      <DrawerBody className="flex min-h-0 flex-1 flex-col p-0">
        {sidebarHeader && (
          <div className="flex h-16 shrink-0 items-center border-b border-border p-2">
            {renderSidebarHeader(false)}
          </div>
        )}
        <div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
          {renderContextNavigation(false, true)}
        </div>
        {renderFooter(false)}
      </DrawerBody>
    </Drawer>
  );

  const renderDesktopIdentity = () => (
    <div
      data-slot="app-shell-sidebar-header"
      className={cn(
        "flex h-16 shrink-0 items-center gap-1 border-b border-border px-2",
        collapsed && "flex-col justify-center gap-0",
        collapsed && (sidebarHeader || brand.href) && "h-auto gap-1 py-2"
      )}
    >
      {(!collapsed || sidebarHeader || brand.href) &&
        renderSidebarHeader(collapsed)}
      {renderCollapseControl(!sidebarHeader && !brand.href)}
    </div>
  );

  return (
    <TooltipProvider>
      <div
        data-slot="app-shell"
        data-collapsed={collapsed}
        className={cn(
          "flex min-h-svh overflow-x-clip bg-background text-foreground",
          className
        )}
      >
        <a
          href={`#${contentId}`}
          className="sr-only z-50 rounded-md bg-background p-3 focus:not-sr-only focus:fixed focus:left-4 focus:top-4"
        >
          {labels?.skipToContent ?? "Skip to content"}
        </a>
        <motion.aside
          id={sidebarId}
          data-slot="app-shell-sidebar"
          aria-label={labels?.sidebar ?? "Sidebar"}
          initial={false}
          animate={{ width: collapsed ? "4rem" : "15rem" }}
          transition={sidebarTransition}
          className="sticky top-0 hidden h-svh shrink-0 flex-col overflow-hidden border-r border-border bg-sidebar text-sidebar-foreground md:flex"
        >
          {renderDesktopIdentity()}
          <div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
            {renderContextNavigation(collapsed)}
          </div>
          {renderFooter(collapsed)}
        </motion.aside>
        <div className="flex min-w-0 flex-1 flex-col">
          {!showHeader && (
            <>
              <div
                data-slot="app-shell-brand-tab"
                className="fixed left-1/2 top-0 z-20 max-w-[calc(100%-8rem)] -translate-x-1/2 rounded-b-xl bg-sidebar px-5 pb-3 pt-[max(.75rem,env(safe-area-inset-top))] text-sidebar-foreground before:absolute before:-left-3 before:top-0 before:size-3 before:rounded-tr-xl before:shadow-[4px_-4px_0_4px_var(--sidebar)] after:absolute after:-right-3 after:top-0 after:size-3 after:rounded-tl-xl after:shadow-[-4px_-4px_0_4px_var(--sidebar)] md:hidden"
              >
                <span className="block truncate text-sm font-semibold">
                  {brand.name}
                </span>
              </div>
              {renderMobileNavigation()}
            </>
          )}
          {showHeader && (
            <header
              data-slot="app-shell-header"
              className="sticky top-0 z-20 flex h-16 shrink-0 items-center gap-3 border-b border-border bg-background px-4 sm:px-6"
            >
              {renderMobileNavigation()}
              {onSearch && (
                <Button
                  variant="outline"
                  onClick={onSearch}
                  className="min-w-0 flex-1 justify-start text-muted-foreground sm:max-w-sm"
                >
                  <SearchIcon aria-hidden="true" />
                  <span className="truncate">{searchLabel}</span>
                  <kbd className="ml-auto hidden text-xs sm:inline">
                    ⌘ / Ctrl K
                  </kbd>
                </Button>
              )}
              {header}
              {actions && (
                <div className="ml-auto flex shrink-0 items-center gap-2">
                  {actions}
                </div>
              )}
            </header>
          )}
          <main
            id={contentId}
            data-slot="app-shell-main"
            tabIndex={-1}
            className={cn(
              "min-w-0 flex-1 outline-none",
              !showHeader &&
                "pt-[calc(4.5rem+env(safe-area-inset-top))] md:pt-0",
              contentClassName
            )}
          >
            {children}
          </main>
        </div>
      </div>
    </TooltipProvider>
  );
};

```

## Credits [#credits]

* [7ovr](https://7ovr.com/): Original app-shell-3 icon-rail layout inspiration, adapted through Universe. The free upstream block is published under MIT-0.
