Docs Page
Docs page with header, content and contents listDocs Page lays out a single documentation page: a header with the category and actions on one row, then the title, a lead and optional tabs; a reading column capped at a comfortable line length; and a table of contents that sticks beside it on large screens. Every page on this site uses it.
Installation
Install the dependencies.
npm install @kushagradhawan/kookie-ui @hugeicons/react @hugeicons/core-free-iconsThis block imports Page Header. Copy that into components/blocks first.
Copy these files into components/blocks/docs-page. Keep the folder names, since blocks import each other by relative path.
import * as React from 'react';
import { Box, Flex, Link, Text } from '@kushagradhawan/kookie-ui';
import { DocsPageHeader } from './docs-page-header';
export interface DocsPageMeta {
title: string;
description?: string;
/** Shown above the title, e.g. "Components". */
category?: string;
/** Link to the page's source, shown as a "Source" button. */
source?: string;
}
export interface DocsPageProps {
children: React.ReactNode;
/** Builds the default header. Ignored when `header` is set. */
meta?: DocsPageMeta;
/** Replaces the meta-based header entirely. */
header?: React.ReactNode;
/** Extra buttons in the header's action row. */
headerActions?: React.ReactNode;
/** Tabs under the header, e.g. Documentation and Examples. */
headerTabs?: React.ReactNode;
/** Show the header's "Copy page" button. Off for pages that are an index, not a document. @default true */
showCopyButton?: boolean;
/** Rendered in a sticky column from the `lg` breakpoint. Without it, the column is centred. */
tableOfContents?: React.ReactNode;
/** Maximum width of the reading column. @default "44rem" */
maxWidth?: string;
/** Copyright line under the content. */
footer?: { name: string; url?: string; githubUrl?: string };
}
/**
* A documentation page: header, a reading column and an optional table of contents. The column
* carries `data-content-area`, which the table of contents and "Copy page" read from.
*/
export function DocsPage({
children,
meta,
header,
headerActions,
headerTabs,
showCopyButton,
tableOfContents,
maxWidth = '44rem',
footer,
}: DocsPageProps) {
return (
<Flex
justify="center"
align="start"
gap="9"
px={{ initial: '5', sm: '7', md: '9' }}
pt={{ initial: '6', sm: '7' }}
pb="9"
>
<Box flexGrow="1" minWidth="0" maxWidth={maxWidth}>
<Flex direction="column" gap={{ initial: '6', sm: '7' }} minWidth="0">
{header ?? (meta && <DocsPageHeader meta={meta} actions={headerActions} tabs={headerTabs} showCopyButton={showCopyButton} />)}
<Box minWidth="0" data-content-area>
{children}
</Box>
{footer && (
<Text as="p" size="2" emphasis="medium" mt="9">
© {new Date().getFullYear()}{' '}
{footer.url ? (
<Link href={footer.url} target="_blank" rel="noreferrer" color="gray">
{footer.name}
</Link>
) : (
footer.name
)}
. Licensed under MIT.
{footer.githubUrl && (
<>
{' '}
<Link href={footer.githubUrl} target="_blank" rel="noreferrer" color="gray">
GitHub
</Link>
.
</>
)}
</Text>
)}
</Flex>
</Box>
{/* Chapters always pass one, so their column sits in the same place whether or not the
page has headings. A page without one, like the homepage, centres its column instead. */}
{tableOfContents && (
<Box
display={{ initial: 'none', lg: 'block' }}
width="13rem"
flexShrink="0"
position="sticky"
top="6"
>
{tableOfContents}
</Box>
)}
</Flex>
);
}
'use client';
import * as React from 'react';
import { Button, Separator } from '@kushagradhawan/kookie-ui';
import { HugeiconsIcon } from '@hugeicons/react';
import { ArrowUpRight01Icon, Copy01Icon, Tick01Icon } from '@hugeicons/core-free-icons';
import { PageHeader } from '../page-header/page-header';
import { DocsShellPageActions } from '../docs-shell/docs-shell';
import type { DocsPageMeta } from './docs-page';
export interface DocsPageHeaderProps {
meta: DocsPageMeta;
/** Extra actions, placed before "Copy page". */
actions?: React.ReactNode;
/** Show a button that copies the page's text. @default true */
showCopyButton?: boolean;
/** Tabs under the header, e.g. Documentation and Examples. */
tabs?: React.ReactNode;
/** Rule under the header. Ignored when there are tabs, which draw their own. @default false */
separator?: boolean;
}
function useCopyPage(meta: DocsPageMeta) {
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), 2000);
return () => window.clearTimeout(timeout);
}, [copied]);
const copy = async () => {
const contentArea = document.querySelector('[data-content-area]');
if (!contentArea) return;
const parts = [`# ${meta.title}`];
if (meta.description) parts.push(meta.description);
if (meta.source) parts.push(`Source: ${meta.source}`);
parts.push((contentArea.textContent ?? '').trim());
try {
await navigator.clipboard.writeText(parts.join('\n\n'));
setCopied(true);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('[DocsPageHeader] Failed to copy page:', error);
}
}
};
return { copied, copy };
}
/**
* The title, the description and optional tabs. The page's actions go to the shell's top bar. There
* is no category line: the sidebar already shows which section the page is in.
*/
export function DocsPageHeader({ meta, actions, showCopyButton = true, tabs, separator = false }: DocsPageHeaderProps) {
const { copied, copy } = useCopyPage(meta);
return (
<PageHeader.Root gap="0">
<DocsShellPageActions>
{actions}
{meta.source && (
<Button asChild size="2" variant="ghost" color="gray">
<a href={meta.source} target="_blank" rel="noreferrer">
Source
<HugeiconsIcon icon={ArrowUpRight01Icon} strokeWidth={1.75} />
</a>
</Button>
)}
{showCopyButton && (
<Button size="2" variant="ghost" color="gray" onClick={copy}>
<HugeiconsIcon icon={copied ? Tick01Icon : Copy01Icon} strokeWidth={1.75} />
{copied ? 'Copied' : 'Copy page'}
</Button>
)}
</DocsShellPageActions>
<PageHeader.Content gap="5">
<PageHeader.Title size={{ initial: '8', sm: '9' }}>{meta.title}</PageHeader.Title>
{meta.description && (
<PageHeader.Description size={{ initial: '3', sm: '4' }} emphasis="loud">{meta.description}</PageHeader.Description>
)}
</PageHeader.Content>
{tabs ? (
<PageHeader.Tabs mt="6">{tabs}</PageHeader.Tabs>
) : (
separator && <Separator size="4" mt="6" />
)}
</PageHeader.Root>
);
}
Usage
'use client';
import { DocsPage } from '@/components/blocks/docs-page/docs-page';
import { TableOfContents } from '@/components/blocks/table-of-contents/table-of-contents';
import Content from './content.mdx';
export default function ButtonPage() {
return (
<DocsPage
meta={{
title: 'Button',
description: 'Trigger an action or event.',
category: 'Components',
}}
tableOfContents={<TableOfContents />}
>
<Content />
</DocsPage>
);
}The content column is marked with data-content-area. Table of Contents reads headings from it, and "Copy page" copies its text.
Anatomy
| File | Contains |
|---|---|
docs-page.tsx | DocsPage and the DocsPageMeta type |
docs-page-header.tsx | DocsPageHeader, built on Page Header. Usable on its own |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
meta | DocsPageMeta | - | Category, title, description and source link for the header. |
header | ReactNode | - | Replaces the generated header. |
headerActions | ReactNode | - | Extra buttons in the header's action row, before "Copy page". |
headerTabs | ReactNode | - | Tabs under the header, e.g. Documentation and Examples. They replace the header's rule. |
tableOfContents | ReactNode | - | Shown in a sticky column from the lg breakpoint. |
maxWidth | string | '44rem' | Width cap for the reading column, around 75 characters of body text. |
footer | { name: string; url?: string; githubUrl?: string } | - | Copyright line under the content. |
DocsPageMeta
| Field | Type | Description |
|---|---|---|
title | string | Page title, rendered as the h1. |
description | string | The lead under the title. |
category | string | Shown at the start of the action row. |
source | string | URL for a "Source" button in the action row. |
Examples
Site Defaults
Wrap DocsPage once with your footer and use the wrapper everywhere. This site does this.
import { DocsPage, type DocsPageProps } from '@/components/blocks/docs-page/docs-page';
const FOOTER = { name: 'Acme', url: 'https://acme.com', githubUrl: 'https://github.com/acme/docs' };
export function SiteDocsPage(props: DocsPageProps) {
return <DocsPage {...props} footer={FOOTER} />;
}With Markdown
The page doesn't style its content. Render MDX inside Markdown's MarkdownContent for type sizes and spacing that match the header.
Accessibility
The title renders as the page's only h1. "Copy page" changes to "Copied" once the text is on the clipboard, and "Source" opens in a new tab.
© 2026 Kushagra Dhawan. Licensed under MIT. GitHub.