Docs Shell

Sidebar and content frame for a docs site

Docs Shell is the frame of a documentation site: a searchable sidebar built from a navigation config, beside the page content. On small screens the sidebar becomes an overlay opened from a menu button. It is built on Kookie UI's Shell and Sidebar.

This site runs on it. The sidebar to the left is a Docs Shell.

Installation

Install the dependencies.

npm install @kushagradhawan/kookie-ui @hugeicons/react @hugeicons/core-free-icons

Copy these files into components/blocks/docs-shell. Keep the folder names, since blocks import each other by relative path.

components/blocks/docs-shell/docs-shell.tsx
'use client';

import * as React from 'react';
import { Flex, IconButton, Shell } from '@kushagradhawan/kookie-ui';
import { HugeiconsIcon } from '@hugeicons/react';
import { SidebarLeft01Icon, SidebarLeftIcon } from '@hugeicons/core-free-icons';
import { createPortal } from 'react-dom';
import { DocsSidebar } from './docs-sidebar';
import type { DocsLinkComponent, DocsLogoConfig, DocsNavigationConfig } from './types';

export interface DocsShellProps {
  children: React.ReactNode;
  navigation: DocsNavigationConfig;
  logo?: DocsLogoConfig;
  /** Rendered next to the logo, e.g. a GitHub link or version badge. */
  headerActions?: React.ReactNode;
  sidebarFooter?: React.ReactNode;
  height?: React.ComponentProps<typeof Shell.Root>['height'];
  /** Width of the collapsed sidebar, in pixels. @default 80 */
  sidebarThinSize?: number;
  /** @default true */
  sidebarResizable?: boolean;
  /** Replaces the icon of the sidebar button in the top bar. */
  mobileTriggerIcon?: React.ReactNode;
  /** Current path, used to mark the active item. */
  pathname?: string;
  /** Your router's link, e.g. `next/link`. Defaults to a plain anchor. */
  linkComponent?: DocsLinkComponent;
  /** Add a collapse/expand button to the sidebar footer. @default false */
  sidebarToggle?: boolean;
}

/** Where a page puts its own actions: the end of the shell's top bar. */
const PageActionsSlot = React.createContext<HTMLElement | null>(null);

/**
 * Renders a page's actions (copy page, source and so on) in the shell's top bar, not in the page
 * header, so the header holds only the title and the description. Renders nothing outside a shell.
 */
export function DocsShellPageActions({ children }: { children: React.ReactNode }) {
  const slot = React.useContext(PageActionsSlot);
  return slot ? createPortal(children, slot) : null;
}

function SidebarThinToggle() {
  const { sidebarMode, setSidebarMode } = Shell.useSidebarMode();
  const isThin = sidebarMode === 'thin';

  return (
    <IconButton
      variant="ghost"
      size="2"
      color="gray"
      onClick={() => setSidebarMode(isThin ? 'expanded' : 'thin')}
      aria-label={isThin ? 'Expand sidebar' : 'Collapse sidebar'}
    >
      <HugeiconsIcon icon={isThin ? SidebarLeftIcon : SidebarLeft01Icon} strokeWidth={1.75} />
    </IconButton>
  );
}

/** Documentation app frame: a searchable, collapsible sidebar beside the page content. */
export function DocsShell({
  children,
  navigation,
  logo,
  headerActions,
  sidebarFooter,
  height,
  sidebarThinSize = 80,
  sidebarResizable = true,
  mobileTriggerIcon,
  pathname = '',
  linkComponent,
  sidebarToggle = false,
}: DocsShellProps) {
  const [presentation, setPresentation] = React.useState<'thin' | 'expanded'>('expanded');
  const [actionsSlot, setActionsSlot] = React.useState<HTMLElement | null>(null);

  const footer = sidebarToggle ? (
    <Flex justify="between" align="center" width="100%" gap="1">
      {sidebarFooter}
      <SidebarThinToggle />
    </Flex>
  ) : (
    sidebarFooter
  );

  return (
    <Shell.Root height={height}>
      <Shell.Sidebar
        toggleModes="single"
        thinSize={sidebarThinSize}
        resizable={sidebarResizable}
        defaultState={{ initial: 'collapsed', sm: 'expanded' }}
        onStateChange={(state) => setPresentation(state === 'thin' ? 'thin' : 'expanded')}
        presentation={{ initial: 'overlay', sm: 'fixed' }}
      >
        <DocsSidebar
          navigation={navigation}
          logo={logo}
          presentation={presentation}
          headerActions={headerActions}
          footer={footer}
          pathname={pathname}
          linkComponent={linkComponent}
        />
      </Shell.Sidebar>

      <Shell.Content>
        {/* The top bar: the sidebar button at the start, the current page's actions at the end. */}
        <Flex align="center" justify="between" gap="3" px={{ initial: '4', sm: '5' }} pt="4">
          <IconButton variant="ghost" size="3" color="gray" asChild aria-label="Toggle navigation">
            <Shell.Trigger target="sidebar">
              {mobileTriggerIcon ?? <HugeiconsIcon icon={SidebarLeftIcon} strokeWidth={1.75} />}
            </Shell.Trigger>
          </IconButton>
          <Flex ref={setActionsSlot} align="center" gap="4" />
        </Flex>

        <PageActionsSlot.Provider value={actionsSlot}>{children}</PageActionsSlot.Provider>
      </Shell.Content>
    </Shell.Root>
  );
}
components/blocks/docs-shell/docs-sidebar.tsx
'use client';

import * as React from 'react';
import { Avatar, Flex, Sidebar } from '@kushagradhawan/kookie-ui';
import { Logo } from '@/components/logo';
import { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react';
import type {
  DocsLinkComponent,
  DocsLinkProps,
  DocsLogoConfig,
  DocsNavigationConfig,
  DocsNavigationItem,
} from './types';

export interface DocsSidebarProps {
  navigation: DocsNavigationConfig;
  logo?: DocsLogoConfig;
  /** Set by `DocsShell` from the sidebar state. @default "expanded" */
  presentation?: 'thin' | 'expanded';
  /** Rendered next to the logo, e.g. a GitHub link. */
  headerActions?: React.ReactNode;
  footer?: React.ReactNode;
  /** @default "2" */
  size?: '1' | '2';
  /** @default "soft" */
  variant?: 'soft' | 'outline' | 'surface' | 'ghost';
  /** @default "soft" */
  menuVariant?: 'solid' | 'soft';
  /** @default "gray" */
  color?: React.ComponentProps<typeof Sidebar.Root>['color'];
  /** Current path, used to mark the active item. */
  pathname?: string;
  /** Your router's link, e.g. `next/link`. Defaults to a plain anchor. */
  linkComponent?: DocsLinkComponent;
}

function Anchor(props: DocsLinkProps) {
  return <a {...props} />;
}

function isIconSvgElement(icon: unknown): icon is IconSvgElement {
  return Array.isArray(icon) && icon.length > 0 && Array.isArray(icon[0]);
}

function renderIcon(icon: DocsNavigationItem['icon']) {
  if (!icon) return null;
  if (React.isValidElement(icon)) return icon;
  if (isIconSvgElement(icon)) return <HugeiconsIcon icon={icon} strokeWidth={1.75} />;
  if (typeof icon === 'function') {
    const Icon = icon;
    return <Icon />;
  }
  return null;
}

export function DocsSidebar({
  navigation,
  logo,
  presentation = 'expanded',
  headerActions,
  footer,
  size = '2',
  variant = 'soft',
  menuVariant = 'soft',
  color = 'gray',
  pathname = '',
  linkComponent: LinkComponent = Anchor,
}: DocsSidebarProps) {
  const [searchQuery, setSearchQuery] = React.useState('');
  const query = searchQuery.trim().toLowerCase();
  const isSearching = query.length > 0;

  const filteredGroups = React.useMemo(() => {
    if (!query) return navigation.groups;
    return navigation.groups
      .map((group) => ({ ...group, items: group.items.filter((item) => item.title.toLowerCase().includes(query)) }))
      .filter((group) => group.items.length > 0);
  }, [navigation.groups, query]);

  const renderItem = (item: DocsNavigationItem, withBadge: boolean) => (
    <Sidebar.MenuItem key={item.href}>
      <Sidebar.MenuButton asChild isActive={pathname === item.href} badge={withBadge ? item.badge : undefined}>
        <LinkComponent href={item.href}>
          {renderIcon(item.icon)}
          <span className="rt-SidebarMenuLabel">{item.title}</span>
        </LinkComponent>
      </Sidebar.MenuButton>
    </Sidebar.MenuItem>
  );

  const renderGroupItem = (item: DocsNavigationItem) => {
    if (!item.items?.length) return renderItem(item, true);

    const isNestedActive = item.items.some((subItem) => pathname === subItem.href);
    return (
      <Sidebar.MenuItem key={item.href}>
        <Sidebar.MenuSub defaultOpen={isNestedActive}>
          <Sidebar.MenuSubTrigger>
            {renderIcon(item.icon)}
            {item.title}
          </Sidebar.MenuSubTrigger>
          <Sidebar.MenuSubContent>
            {item.items.map((subItem) => (
              <Sidebar.MenuButton asChild key={subItem.href} isActive={pathname === subItem.href}>
                <LinkComponent href={subItem.href}>
                  {renderIcon(subItem.icon)}
                  <span className="rt-SidebarMenuLabel">{subItem.title}</span>
                </LinkComponent>
              </Sidebar.MenuButton>
            ))}
          </Sidebar.MenuSubContent>
        </Sidebar.MenuSub>
      </Sidebar.MenuItem>
    );
  };

  const flatResults = filteredGroups.flatMap((group) => group.items);

  return (
    <Sidebar.Root size={size} variant={variant} color={color} menuVariant={menuVariant} presentation={presentation}>
      {(logo || headerActions) && (
        <Sidebar.Header>
          <Flex justify="between" align="center" width="100%">
            {logo && (
              <LinkComponent href={logo.href ?? '/'} aria-label={logo.alt ?? 'Home'}>
                {logo.label ? (
                  /* THE MARK ALONE (2026-09-27, Kushagra): the word said the name a second time
                     in a row that is only ever glanced at. `label` stays the accessible name. */
                  <Logo height="var(--line-height-7)" />
                ) : (
                  <Avatar fallback={logo.alt?.[0] ?? 'K'} size={logo.size ?? '3'} src={logo.src} />
                )}
              </LinkComponent>
            )}
            {headerActions && <Flex align="center">{headerActions}</Flex>}
          </Flex>
        </Sidebar.Header>
      )}

      <Sidebar.Search value={searchQuery} onValueChange={setSearchQuery} placeholder="Search...">
        {/* In thin mode, results render inside the search popover. */}
        {presentation === 'thin' && isSearching && (
          <Sidebar.Menu>{flatResults.map((item) => renderItem(item, false))}</Sidebar.Menu>
        )}
      </Sidebar.Search>

      <Sidebar.Content>
        <Sidebar.Menu>
          {isSearching
            ? flatResults.map((item) => renderItem(item, true))
            : filteredGroups.map((group) => (
                <Sidebar.Group key={group.label}>
                  <Sidebar.GroupLabel>{group.label}</Sidebar.GroupLabel>
                  <Sidebar.GroupContent>{group.items.map(renderGroupItem)}</Sidebar.GroupContent>
                </Sidebar.Group>
              ))}
        </Sidebar.Menu>
      </Sidebar.Content>

      {footer && <Sidebar.Footer>{footer}</Sidebar.Footer>}
    </Sidebar.Root>
  );
}
components/blocks/docs-shell/types.ts
import type * as React from 'react';
import type { Sidebar } from '@kushagradhawan/kookie-ui';
import type { IconSvgElement } from '@hugeicons/react';

export interface DocsNavigationItem {
  href: string;
  title: string;
  /** A HugeIcons icon, a component, or any element. */
  icon?: IconSvgElement | React.ComponentType | React.ReactElement;
  /** Text badge, or a config object for color and variant. */
  badge?: string | Sidebar.BadgeConfig;
  /** Nested pages, shown in a collapsible sub-menu. */
  items?: DocsNavigationItem[];
}

export interface DocsNavigationGroup {
  label: string;
  items: DocsNavigationItem[];
}

export interface DocsNavigationConfig {
  groups: DocsNavigationGroup[];
}

export interface DocsLogoConfig {
  /** An image mark. Ignored when `label` is set. */
  src?: string;
  /** A text wordmark instead of an image. The thin sidebar shows its first letter. */
  label?: string;
  alt?: string;
  /** @default "/" */
  href?: string;
  /** @default "3" */
  size?: '1' | '2' | '3' | '4' | '5';
}

/** Props the shell passes to your link component, e.g. `next/link`. */
export interface DocsLinkProps {
  href: string;
  children: React.ReactNode;
  'aria-label'?: string;
}

export type DocsLinkComponent = React.ComponentType<DocsLinkProps>;

Usage

Describe the navigation once, then wrap your docs layout.

// navigation.ts
import type { DocsNavigationConfig } from '@/components/blocks/docs-shell/types';
 
export const navigation: DocsNavigationConfig = {
  groups: [
    {
      label: 'Get Started',
      items: [{ href: '/docs/installation', title: 'Installation' }],
    },
    {
      label: 'Components',
      items: [
        { href: '/docs/button', title: 'Button', badge: 'New' },
        { href: '/docs/card', title: 'Card' },
      ],
    },
  ],
};
// app/docs/layout.tsx
'use client';
 
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { DocsShell } from '@/components/blocks/docs-shell/docs-shell';
import { navigation } from '@/navigation';
 
export default function DocsLayout({ children }: { children: React.ReactNode }) {
  return (
    <DocsShell
      navigation={navigation}
      logo={{ src: '/logo.png', alt: 'Acme' }}
      pathname={usePathname()}
      linkComponent={Link}
    >
      {children}
    </DocsShell>
  );
}

Anatomy

FileContains
docs-shell.tsxDocsShell: the Shell layout, mobile trigger and optional collapse toggle
docs-sidebar.tsxDocsSidebar: logo, search, grouped menu and footer. Usable on its own inside any Shell.Sidebar
types.tsNavigation, logo and link types

Props

PropTypeDefaultDescription
navigationDocsNavigationConfig-Groups and items for the sidebar.
pathnamestring''Current path. The matching item is marked active.
linkComponentDocsLinkComponent<a>Your router's link, such as next/link.
logoDocsLogoConfig-Logo in the sidebar header.
headerActionsReactNode-Beside the logo, e.g. a GitHub link or version badge.
sidebarFooterReactNode-Bottom of the sidebar, e.g. a theme switch.
sidebarTogglebooleanfalseAdd a button that collapses the sidebar to icons.
sidebarThinSizenumber80Width of the collapsed sidebar, in pixels.
sidebarResizablebooleantrueLet people drag the sidebar edge.
mobileTriggerIconReactNodeMenu iconIcon for the small-screen menu button.
heightShell.Root height-Height of the shell.
FieldTypeDescription
hrefstringLink target. Also the key, so keep it unique.
titlestringLabel. Search matches against it.
iconIconSvgElement | ComponentType | ReactElementOptional. A HugeIcons icon, a component or an element.
badgestring | Sidebar.BadgeConfigOptional text badge, or a config for color and variant.
itemsDocsNavigationItem[]Optional nested pages, shown in a collapsible sub-menu that opens when one is active.
FieldTypeDefaultDescription
srcstring-Image URL. Ignored when label is set.
labelstring-A text wordmark instead of an image. The collapsed sidebar shows its first letter.
altstring-Accessible name. Its first letter is the image fallback.
hrefstring'/'Where the logo links.
size'1' – '5''3'Image size.

Examples

Status Badges

{
  href: '/docs/combobox',
  title: 'Combobox',
  badge: { content: 'Alpha', size: '1', color: 'gray' },
}

Nested Pages

{
  href: '/docs/forms',
  title: 'Forms',
  items: [
    { href: '/docs/forms/validation', title: 'Validation' },
    { href: '/docs/forms/submission', title: 'Submission' },
  ],
}

Collapsible Sidebar

<DocsShell navigation={navigation} sidebarToggle sidebarFooter={<ThemeSwitch />}>
  {children}
</DocsShell>

Items show only their icon when collapsed, so give each one an icon if you enable this.

Accessibility

Search filters the menu as you type and shows a flat list of matches. The logo link and the small-screen menu button have accessible names. Active items are marked through Sidebar.MenuButton, which sets aria-current.

© 2026 Kushagra Dhawan. Licensed under MIT. GitHub.

Theme

Accent color

Gray color

Appearance

Radius

Scaling

Panel background