Docs Shell
Sidebar and content frame for a docs siteDocs 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-iconsCopy these files into components/blocks/docs-shell. Keep the folder names, since blocks import each other by relative path.
'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>
);
}
'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>
);
}
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
| File | Contains |
|---|---|
docs-shell.tsx | DocsShell: the Shell layout, mobile trigger and optional collapse toggle |
docs-sidebar.tsx | DocsSidebar: logo, search, grouped menu and footer. Usable on its own inside any Shell.Sidebar |
types.ts | Navigation, logo and link types |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
navigation | DocsNavigationConfig | - | Groups and items for the sidebar. |
pathname | string | '' | Current path. The matching item is marked active. |
linkComponent | DocsLinkComponent | <a> | Your router's link, such as next/link. |
logo | DocsLogoConfig | - | Logo in the sidebar header. |
headerActions | ReactNode | - | Beside the logo, e.g. a GitHub link or version badge. |
sidebarFooter | ReactNode | - | Bottom of the sidebar, e.g. a theme switch. |
sidebarToggle | boolean | false | Add a button that collapses the sidebar to icons. |
sidebarThinSize | number | 80 | Width of the collapsed sidebar, in pixels. |
sidebarResizable | boolean | true | Let people drag the sidebar edge. |
mobileTriggerIcon | ReactNode | Menu icon | Icon for the small-screen menu button. |
height | Shell.Root height | - | Height of the shell. |
Navigation Item
| Field | Type | Description |
|---|---|---|
href | string | Link target. Also the key, so keep it unique. |
title | string | Label. Search matches against it. |
icon | IconSvgElement | ComponentType | ReactElement | Optional. A HugeIcons icon, a component or an element. |
badge | string | Sidebar.BadgeConfig | Optional text badge, or a config for color and variant. |
items | DocsNavigationItem[] | Optional nested pages, shown in a collapsible sub-menu that opens when one is active. |
Logo
| Field | Type | Default | Description |
|---|---|---|---|
src | string | - | Image URL. Ignored when label is set. |
label | string | - | A text wordmark instead of an image. The collapsed sidebar shows its first letter. |
alt | string | - | Accessible name. Its first letter is the image fallback. |
href | string | '/' | 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.