# Docs Layout

> A documentation screen from a list of pages: nav, page and table of contents, one column for a short guide, or text beside its code. Collapses on phones.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/docs-layout).

- Page, with a live preview: https://shadcn.exchange/r/miseui/docs-layout
- Screenshot: https://shadcn.exchange/shot/miseui/docs-layout/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/docs-layout.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/docs-layout.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/code-block.json`, `https://miseui.junsu4621.workers.dev/r/collapsible.json`, `https://miseui.junsu4621.workers.dev/r/sidebar.json`, `https://miseui.junsu4621.workers.dev/r/table-of-contents.json`

## Files

### `registry/miseui/blocks/docs-layout.tsx`

```tsx
import type * as React from "react"
import { DocsCentered } from "./docs-layout-centered"
import { DocsReference } from "./docs-layout-reference"
import { SAMPLE_GROUPS } from "./docs-layout-sample"
import { DocsSidebar } from "./docs-layout-sidebar"
import { useDocsPages, type DocsCode, type DocsGroup, type DocsPage, type DocsSection } from "./docs-layout-state"

type DocsLayoutVariant = "sidebar" | "centered" | "reference"

interface DocsLayoutProps extends React.ComponentProps<"section"> {
  /** `sidebar` nav, page and table of contents, `centered` one column for a short guide, `reference` text beside its code. Anything else is `sidebar`. */
  variant?: DocsLayoutVariant
  /** Pages in their nav groups. `centered` and `reference` show no group list, and move between pages with the links at the end. */
  groups?: readonly DocsGroup[]
  /** The page showing, if your router owns it. */
  current?: string
  defaultCurrent?: string
  /** Fires when someone picks a page. Pages with an `href` still follow the link. */
  onNavigate?: (id: string) => void
  brandName?: React.ReactNode
  brandHref?: string
}

/** `sidebar` and `reference` scroll inside themselves at a fixed height. Set the height through className, for example h-dvh. */
function DocsLayout({ variant, groups = SAMPLE_GROUPS, current, defaultCurrent, onNavigate, brandName = "Paperline docs", brandHref = "#docs", ...props }: DocsLayoutProps) {
  const docs = useDocsPages({ groups, current, defaultCurrent, onNavigate })
  const shared = { docs, brandName, brandHref, ...props }
  if (variant === "centered") return <DocsCentered {...shared} />
  if (variant === "reference") return <DocsReference {...shared} />
  return <DocsSidebar {...shared} groups={groups} />
}

export { DocsLayout, type DocsCode, type DocsGroup, type DocsLayoutProps, type DocsLayoutVariant, type DocsPage, type DocsSection }
```

### `registry/miseui/blocks/docs-layout-centered.tsx`

```tsx
import { cn } from "@/registry/miseui/lib/utils"
import { TableOfContents } from "@/registry/miseui/ui/table-of-contents"
import { DocsBrand, DocsEmpty, DocsPager, DocsTopBar, PageHeading, SectionCode, SectionText } from "./docs-layout-parts"
import { usePageArrival } from "./docs-layout-state"
import type { DocsCommonProps } from "./docs-layout-sidebar"

/** The sticky bar's 56 px plus a little air, so a heading does not land under it after a jump. */
const BAR_OFFSET_PX = 72

/** One column and no chrome, for a short guide read once from top to bottom. The page itself scrolls, so a phone's browser bar can collapse. */
function DocsCentered({ docs, brandName, brandHref, className, ...props }: DocsCommonProps) {
  const titleRef = usePageArrival(docs.page?.id, undefined)
  const page = docs.page
  return (
    <section data-slot="docs-layout" data-variant="centered" className={cn("@container relative flex min-h-[640px] w-full flex-col bg-background", className)} {...props}>
      <div className="sticky top-0 z-10">
        <DocsTopBar>
          <DocsBrand name={brandName} href={brandHref} />
        </DocsTopBar>
      </div>
      {page ? (
        <main className="mx-auto flex w-full max-w-2xl min-w-0 flex-col gap-12 px-4 py-12 @3xl:py-16">
          <div className="flex min-w-0 flex-col gap-8">
            <PageHeading page={page} titleRef={titleRef} />
            <div className="rounded-2xl bg-muted p-4">
              <TableOfContents variant="dots" title="In this guide" label="In this guide" items={page.sections} scrollOffset={BAR_OFFSET_PX} />
            </div>
          </div>
          {page.sections.map((section) => (
            <section key={section.id} className="flex min-w-0 flex-col gap-3">
              <SectionText section={section} scrollMargin="scroll-mt-20" />
              {section.code ? <SectionCode code={section.code} /> : null}
            </section>
          ))}
          <DocsPager docs={docs} />
        </main>
      ) : (
        <DocsEmpty />
      )}
    </section>
  )
}

export { DocsCentered }
```

### `registry/miseui/blocks/docs-layout-parts.tsx`

```tsx
import type * as React from "react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { CodeBlock } from "@/registry/miseui/ui/code-block"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/registry/miseui/ui/collapsible"
import { TableOfContents } from "@/registry/miseui/ui/table-of-contents"
import type { DocsCode, DocsPage, DocsPages, DocsSection } from "./docs-layout-state"

const PROSE_CLASS = "flex flex-col gap-3 text-[15px] leading-relaxed text-pretty text-foreground/85"

function DocsBrand({ name, href }: { name: React.ReactNode; href: string }) {
  return (
    <a href={href} className="inline-flex min-h-10 items-center rounded-md text-[15px] font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
      {name}
    </a>
  )
}

function DocsTopBar({ start, children }: { start?: React.ReactNode; children: React.ReactNode }) {
  return (
    <header data-slot="docs-layout-bar" className="flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
      {start}
      {children}
    </header>
  )
}

/** Two tones in one sentence. The heading is focusable so a new page can announce itself. */
function PageHeading({ page, titleRef }: { page: DocsPage; titleRef: React.RefObject<HTMLHeadingElement | null> }) {
  return (
    <h1 ref={titleRef} tabIndex={-1} className="text-display text-3xl leading-[1.12]! text-balance outline-none @3xl:text-4xl">
      {page.lead ? (
        <>
          {page.title}. <span className="text-muted-foreground">{page.lead}</span>
        </>
      ) : (
        page.title
      )}
    </h1>
  )
}

function SectionCode({ code }: { code: DocsCode }) {
  return <CodeBlock variant="window" title={code.title} label={code.title ?? "Code"} code={code.code} language={code.language} />
}

function SectionText({ section, scrollMargin = "scroll-mt-4" }: { section: DocsSection; scrollMargin?: string }) {
  return (
    <>
      <h2 id={section.id} className={cn("text-title text-xl text-balance outline-none", scrollMargin)}>
        {section.title}
      </h2>
      <div className={PROSE_CLASS}>{section.body}</div>
    </>
  )
}

/** Folds into one row above the article when the side column has no room. Stays open after a jump: closing it would move the text being scrolled to. */
function PageTocFold({ page, scrollRef, className }: { page: DocsPage; scrollRef?: React.RefObject<HTMLElement | null>; className?: string }) {
  return (
    <Collapsible variant="card" className={cn("min-w-0 rounded-2xl", className)}>
      <CollapsibleTrigger className="rounded-2xl pointer-coarse:min-h-10">On this page</CollapsibleTrigger>
      <CollapsibleContent>
        <TableOfContents variant="plain" items={page.sections} scrollRef={scrollRef} />
      </CollapsibleContent>
    </Collapsible>
  )
}

function PagerLink({ page, direction, onSelect }: { page: DocsPage; direction: "previous" | "next"; onSelect: (id: string) => void }) {
  const next = direction === "next"
  const className = cn(
    "flex min-h-14 min-w-0 flex-col justify-center gap-0.5 rounded-2xl border px-4 py-2 text-start outline-none",
    "transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent/60 focus-visible:ring-[3px] focus-visible:ring-ring/50",
    next && "col-start-2 items-end text-end",
  )
  const body = (
    <>
      <span className="inline-flex items-center gap-1 text-[13px] text-muted-foreground">
        {next ? null : <ArrowLeft aria-hidden="true" className="size-3" />}
        {next ? "Next" : "Previous"}
        {next ? <ArrowRight aria-hidden="true" className="size-3" /> : null}
      </span>
      <span className="max-w-full text-sm font-medium wrap-break-word">{page.title}</span>
    </>
  )
  if (page.href) {
    return (
      <a href={page.href} className={className} onClick={() => onSelect(page.id)}>
        {body}
      </a>
    )
  }
  return (
    <button type="button" className={className} onClick={() => onSelect(page.id)}>
      {body}
    </button>
  )
}

function DocsPager({ docs }: { docs: DocsPages }) {
  if (!docs.previous && !docs.next) return null
  return (
    <nav aria-label="Pages" className="grid grid-cols-2 gap-3 border-t pt-8">
      {docs.previous ? <PagerLink page={docs.previous} direction="previous" onSelect={docs.select} /> : null}
      {docs.next ? <PagerLink page={docs.next} direction="next" onSelect={docs.select} /> : null}
    </nav>
  )
}

/** A docs block with no pages yet still says so, instead of a blank frame. */
function DocsEmpty() {
  return (
    <div className="flex flex-1 items-center justify-center px-4 py-16 text-center">
      <p className="text-[15px] text-muted-foreground">There are no pages here yet.</p>
    </div>
  )
}

export { DocsBrand, DocsEmpty, DocsPager, DocsTopBar, PageHeading, PageTocFold, SectionCode, SectionText }
```

### `registry/miseui/blocks/docs-layout-reference.tsx`

```tsx
import { useRef } from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { TableOfContents } from "@/registry/miseui/ui/table-of-contents"
import { DocsBrand, DocsEmpty, DocsPager, DocsTopBar, PageHeading, PageTocFold, SectionCode, SectionText } from "./docs-layout-parts"
import type { DocsCommonProps } from "./docs-layout-sidebar"
import { usePageArrival, type DocsPages } from "./docs-layout-state"

function ReferenceBody({ docs }: { docs: DocsPages }) {
  const scrollRef = useRef<HTMLElement>(null)
  const titleRef = usePageArrival(docs.page?.id, scrollRef)
  const page = docs.page
  if (!page) return <DocsEmpty />
  return (
    <div className="flex min-h-0 flex-1">
      <div className="hidden w-56 shrink-0 overflow-y-auto border-e p-4 @4xl:block">
        <TableOfContents variant="line" effect="spring" label="On this page" title={page.title} items={page.sections} scrollRef={scrollRef} />
      </div>
      {/* relative: anything sr-only inside stays clipped by this scroller instead of widening the page. */}
      <main ref={scrollRef} className="relative min-w-0 flex-1 overflow-y-auto">
        <div className="flex min-w-0 flex-col gap-8 px-4 py-8 @3xl:px-8">
          <PageHeading page={page} titleRef={titleRef} />
          <PageTocFold page={page} scrollRef={scrollRef} className="@4xl:hidden" />
          {page.sections.map((section) => (
            <section key={section.id} data-testid="reference-row" className="grid min-w-0 grid-cols-[minmax(0,1fr)] items-start gap-4 border-t pt-8 @5xl:grid-cols-2 @5xl:gap-8">
              <div className="flex min-w-0 flex-col gap-3">
                <SectionText section={section} />
              </div>
              {/* Stays beside its text while a long explanation scrolls, which is the whole reason for two columns. */}
              {section.code ? (
                <div className="min-w-0 @5xl:sticky @5xl:top-4">
                  <SectionCode code={section.code} />
                </div>
              ) : null}
            </section>
          ))}
          <DocsPager docs={docs} />
        </div>
      </main>
    </div>
  )
}

/** Text beside its code, for people who came to copy something and want the explanation in the same glance. */
function DocsReference({ docs, brandName, brandHref, className, ...props }: DocsCommonProps) {
  return (
    <section data-slot="docs-layout" data-variant="reference" className={cn("@container flex h-[720px] max-h-dvh w-full flex-col bg-background", className)} {...props}>
      <DocsTopBar>
        <DocsBrand name={brandName} href={brandHref} />
      </DocsTopBar>
      <ReferenceBody docs={docs} />
    </section>
  )
}

export { DocsReference }
```

### `registry/miseui/blocks/docs-layout-sample.tsx`

```tsx
import type * as React from "react"
import type { DocsGroup } from "./docs-layout-state"

function Term({ children }: { children: React.ReactNode }) {
  return <code className="rounded bg-muted px-1 font-mono text-[13px]">{children}</code>
}

const CURL_INVOICE = `curl https://api.paperline.example/v1/invoices \\
  -H "Authorization: Bearer pl_test_example" \\
  -d customer=cus_example \\
  -d amount=128000`

const SDK_SETUP = `import { Paperline } from "paperline"

const client = new Paperline(process.env.PAPERLINE_KEY)`

const CREATE_INVOICE = `const invoice = await client.invoices.create({
  customer: "cus_example",
  amount: 128000,
  dueInDays: 14,
})`

const INVOICE_JSON = `{
  "id": "inv_example",
  "status": "open",
  "amount": 128000,
  "hostedUrl": "https://pay.paperline.example/inv_example"
}`

const WEBHOOK_HANDLER = `export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get("paperline-signature")
  const event = client.webhooks.verify(body, signature)
  return new Response(null, { status: 200 })
}`

/** Every key, id and amount here is an example; none of it reaches a real service. */
const SAMPLE_GROUPS: readonly DocsGroup[] = [
  {
    label: "Get started",
    pages: [
      {
        id: "quick-start",
        title: "Quick start",
        lead: "Send your first invoice in about five minutes.",
        sections: [
          {
            id: "quick-start-install",
            title: "Install the library",
            body: <p>Add the package to your project. It works in Node 20 and later, and in any runtime with a standard fetch.</p>,
            code: { title: "Terminal", code: "npm install paperline", language: "bash" },
          },
          {
            id: "quick-start-client",
            title: "Create a client",
            body: <p>Read the key from your environment, so it never lands in your repository. Test keys start with <Term>pl_test_</Term> and never move real money.</p>,
            code: { title: "client.ts", code: SDK_SETUP, language: "ts" },
          },
          {
            id: "quick-start-invoice",
            title: "Send an invoice",
            body: <p>Amounts are in the smallest unit of the currency, so 128000 means 1,280.00. The customer gets an email with a link to pay.</p>,
            code: { title: "send.ts", code: CREATE_INVOICE, language: "ts" },
          },
          {
            id: "quick-start-next",
            title: "Where to go next",
            body: <p>Keep the invoice id. You will need it to read the status, and webhooks use it to tell you when the invoice is paid.</p>,
          },
        ],
      },
      {
        id: "authentication",
        title: "Authentication",
        lead: "One key per environment, sent as a bearer token.",
        sections: [
          {
            id: "authentication-keys",
            title: "Test and live keys",
            body: <p>A test key only reaches test data. Create a live key once you are ready, and keep the two in separate environments.</p>,
          },
          {
            id: "authentication-request",
            title: "Sending a request",
            body: <p>Put the key in the <Term>Authorization</Term> header. A request without one gets a 401 and nothing else happens.</p>,
            code: { title: "Terminal", code: CURL_INVOICE, language: "bash" },
          },
        ],
      },
    ],
  },
  {
    label: "Guides",
    pages: [
      {
        id: "invoices",
        title: "Invoices",
        lead: "What an invoice holds, and the states it moves through.",
        sections: [
          {
            id: "invoices-object",
            title: "The invoice object",
            body: <p>Status starts at <Term>open</Term> and moves to <Term>paid</Term> or <Term>void</Term>. The hosted link stays valid until then.</p>,
            code: { title: "Response", code: INVOICE_JSON, language: "json" },
          },
          {
            id: "invoices-reminders",
            title: "Reminders",
            body: <p>By default a reminder goes out three days before the due date and again on the day. Turn either off per invoice.</p>,
          },
        ],
      },
      {
        id: "webhooks",
        title: "Webhooks",
        lead: "Hear about a payment without asking for it.",
        sections: [
          {
            id: "webhooks-receive",
            title: "Receive an event",
            body: <p>Verify the signature before you trust the body, then answer quickly with a 200. Do slow work after the response.</p>,
            code: { title: "route.ts", code: WEBHOOK_HANDLER, language: "ts" },
          },
          {
            id: "webhooks-retries",
            title: "Retries",
            body: <p>An event that does not get a 200 is sent again, up to five times over a day. The same event can arrive twice, so treat the id as the key.</p>,
          },
        ],
      },
    ],
  },
]

export { SAMPLE_GROUPS }
```

### `registry/miseui/blocks/docs-layout-sidebar.tsx`

```tsx
import type * as React from "react"
import { useEffect, useRef } from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { Sidebar, SidebarContent, SidebarGroup, SidebarItem, SidebarProvider, SidebarTrigger, useSidebar } from "@/registry/miseui/ui/sidebar"
import { TableOfContents } from "@/registry/miseui/ui/table-of-contents"
import { DocsBrand, DocsEmpty, DocsPager, DocsTopBar, PageHeading, PageTocFold, SectionCode, SectionText } from "./docs-layout-parts"
import { useNarrowerThan, usePageArrival, type DocsGroup, type DocsPages } from "./docs-layout-state"

/** Matches @3xl, where the column appears. */
const DRAWER_BELOW_PX = 768

interface DocsCommonProps extends React.ComponentProps<"section"> {
  docs: DocsPages
  brandName: React.ReactNode
  brandHref: string
}

function DocsNav({ docs, groups }: { docs: DocsPages; groups: readonly DocsGroup[] }) {
  return (
    <Sidebar label="Documentation" width={248}>
      <SidebarContent label="Documentation" className="p-3">
        {groups.map((group) => (
          <SidebarGroup key={group.label} label={group.label}>
            {group.pages.map((page) => (
              <SidebarItem key={page.id} href={page.href} active={page.id === docs.page?.id} onClick={() => docs.select(page.id)}>
                {page.title}
              </SidebarItem>
            ))}
          </SidebarGroup>
        ))}
      </SidebarContent>
    </Sidebar>
  )
}

/**
 * The sidebar's drawer has no Radix trigger, so Radix has nothing to hand focus back to after Escape.
 * Skipped when focus already moved on: choosing a page sends it to the new title.
 */
function useDrawerFocusReturn(triggerRef: React.RefObject<HTMLButtonElement | null>) {
  const { drawerOpen } = useSidebar()
  const wasOpenRef = useRef(drawerOpen)
  useEffect(() => {
    const closed = wasOpenRef.current && !drawerOpen
    wasOpenRef.current = drawerOpen
    if (!closed) return
    const id = window.setTimeout(() => {
      const active = document.activeElement
      const stranded = !active || active === document.body || active.closest("[data-slot='drawer']") !== null
      if (stranded) triggerRef.current?.focus()
    })
    return () => window.clearTimeout(id)
  }, [drawerOpen, triggerRef])
}

function NavTrigger() {
  const triggerRef = useRef<HTMLButtonElement>(null)
  useDrawerFocusReturn(triggerRef)
  return <SidebarTrigger ref={triggerRef} aria-label="Pages" className="-ms-2 @3xl:hidden" />
}

function SidebarArticle({ docs }: { docs: DocsPages }) {
  const scrollRef = useRef<HTMLElement>(null)
  const titleRef = usePageArrival(docs.page?.id, scrollRef)
  const page = docs.page
  if (!page) return <DocsEmpty />
  return (
    // relative: anything sr-only inside stays clipped by this scroller instead of widening the page.
    <main ref={scrollRef} className="relative min-w-0 flex-1 overflow-y-auto">
      <div className="mx-auto grid max-w-5xl grid-cols-[minmax(0,1fr)] items-start gap-8 px-4 py-8 @3xl:px-8 @5xl:grid-cols-[minmax(0,1fr)_200px] @5xl:gap-12">
        <article className="flex min-w-0 flex-col gap-8">
          <PageHeading page={page} titleRef={titleRef} />
          <PageTocFold page={page} scrollRef={scrollRef} className="@5xl:hidden" />
          {page.sections.map((section) => (
            <section key={section.id} className="flex min-w-0 flex-col gap-3">
              <SectionText section={section} />
              {section.code ? <SectionCode code={section.code} /> : null}
            </section>
          ))}
          <DocsPager docs={docs} />
        </article>
        <aside className="sticky top-8 hidden min-w-0 @5xl:block">
          <TableOfContents variant="line" effect="spring" title="On this page" items={page.sections} scrollRef={scrollRef} />
        </aside>
      </div>
    </main>
  )
}

/** Three columns, for a product with enough pages that people move around: where you are, what you are reading, and what is on this page. */
function DocsSidebar({ docs, groups, brandName, brandHref, className, ...props }: DocsCommonProps & { groups: readonly DocsGroup[] }) {
  const rootRef = useRef<HTMLElement>(null)
  const narrow = useNarrowerThan(rootRef, DRAWER_BELOW_PX)
  return (
    <section ref={rootRef} data-slot="docs-layout" data-variant="sidebar" className={cn("@container flex h-[720px] max-h-dvh w-full flex-col bg-background", className)} {...props}>
      {/* The shortcut stays off: Mod+B belongs to the page, and a docs page has no reason to take it. */}
      <SidebarProvider mobile={narrow} shortcut={false}>
        <DocsTopBar start={<NavTrigger />}>
          <DocsBrand name={brandName} href={brandHref} />
        </DocsTopBar>
        <div className="flex min-h-0 flex-1">
          <DocsNav docs={docs} groups={groups} />
          <SidebarArticle docs={docs} />
        </div>
      </SidebarProvider>
    </section>
  )
}

export { DocsSidebar, type DocsCommonProps }
```

### `registry/miseui/blocks/docs-layout-state.ts`

```ts
import type * as React from "react"
import { useLayoutEffect, useEffect, useRef, useState } from "react"
import type { CodeLanguage } from "@/registry/miseui/ui/code-block"

interface DocsCode {
  code: string
  language?: CodeLanguage
  /** A file name, or a word such as Terminal. */
  title?: string
}

interface DocsSection {
  /** Becomes the heading id and the address of the jump, so keep it unique across the document. */
  id: string
  title: string
  body: React.ReactNode
  code?: DocsCode
}

interface DocsPage {
  id: string
  title: string
  /** Continues the title in the heading, one tone quieter. */
  lead?: string
  /** A real address, if your router owns the page. Left out, the block swaps the page itself. */
  href?: string
  sections: readonly DocsSection[]
}

interface DocsGroup {
  label: string
  pages: readonly DocsPage[]
}

function flattenPages(groups: readonly DocsGroup[]) {
  return groups.flatMap((group) => group.pages)
}

/** The page to show and its neighbours. An unknown id falls back to the first page, not a blank screen. */
function locatePage(pages: readonly DocsPage[], id: string | undefined) {
  const found = pages.findIndex((page) => page.id === id)
  const index = Math.max(found, 0)
  return { page: pages[index] as DocsPage | undefined, previous: pages[index - 1] as DocsPage | undefined, next: pages[index + 1] as DocsPage | undefined }
}

interface DocsPagesOptions {
  groups: readonly DocsGroup[]
  current?: string
  defaultCurrent?: string
  onNavigate?: (id: string) => void
}

function useDocsPages({ groups, current, defaultCurrent, onNavigate }: DocsPagesOptions) {
  const pages = flattenPages(groups)
  const [uncontrolled, setUncontrolled] = useState(defaultCurrent)
  const located = locatePage(pages, current ?? uncontrolled)
  const select = (id: string) => {
    setUncontrolled(id)
    onNavigate?.(id)
  }
  return { ...located, select }
}

type DocsPages = ReturnType<typeof useDocsPages>

/** A new page starts at its top, and the title takes focus so a screen reader hears where it landed. Not on arrival: that would steal focus from the page. */
function usePageArrival(pageId: string | undefined, scrollRef: React.RefObject<HTMLElement | null> | undefined) {
  const titleRef = useRef<HTMLHeadingElement>(null)
  const lastRef = useRef(pageId)
  useEffect(() => {
    if (lastRef.current === pageId) return
    lastRef.current = pageId
    scrollRef?.current?.scrollTo({ top: 0 })
    titleRef.current?.focus({ preventScroll: Boolean(scrollRef) })
  }, [pageId, scrollRef])
  return titleRef
}

/** Measured so the sidebar's drawer can follow the block's own width, which a media query cannot see inside a frame. */
function useNarrowerThan(ref: React.RefObject<HTMLElement | null>, width: number) {
  const [narrower, setNarrower] = useState(false)
  useLayoutEffect(() => {
    const element = ref.current
    if (!element || typeof ResizeObserver === "undefined") return
    const observer = new ResizeObserver(([entry]) => setNarrower(entry.contentRect.width < width))
    observer.observe(element)
    return () => observer.disconnect()
  }, [ref, width])
  return narrower
}

export { flattenPages, locatePage, useDocsPages, useNarrowerThan, usePageArrival, type DocsCode, type DocsGroup, type DocsPage, type DocsPages, type DocsPagesOptions, type DocsSection }
```
