Skip to content

Layouts

DocsProvider at the root, DocsLayout and DocsPage for the docs, HomeLayout for the other pages, and the parts for a layout of your own.

The layouts come from @contour-ui/fumadocs, inside one DocsProvider at the root of the app. DocsLayout holds the navigation of the docs and DocsPage one page of them; HomeLayout holds the other pages of the site, such as the home page and the 404 page. They read the page tree of fumadocs-core, and the current path from the adapter of the framework.

import { DocsLayout, DocsPage, HomeLayout } from '@contour-ui/fumadocs';
import { getMdxComponents, Prose } from '@contour-ui/fumadocs/mdx';

// A page of the docs
<DocsLayout tree={source.getPageTree()}>
  <DocsPage title={page.data.title} description={page.data.description} toc={page.data.toc}>
    <Prose>
      <Mdx components={getMdxComponents()} />
    </Prose>
  </DocsPage>
</DocsLayout>;

// Any other page
<HomeLayout>
  <h1>My docs</h1>
</HomeLayout>;

DocsProvider

DocsProvider holds what every layout needs: the site, the search, and a ThemeProvider. Render it once, around the whole app, inside the adapter of the framework (Setup); the layouts throw outside it.

import { DocsProvider } from '@contour-ui/fumadocs';
import type { DocsSite } from '@contour-ui/fumadocs';
import { githubIcon } from '@contour-ui/icons';

const site: DocsSite = {
  name: 'My docs',
  nav: [{ label: 'Docs', href: '/docs' }],
  links: [{ label: 'GitHub', href: 'https://github.com/me/my-docs', icon: githubIcon }],
};

<DocsProvider site={site}>{children}</DocsProvider>;
PropTypeDefault
siteDocsSite: the name and the links of the siterequired
searchDocsSearchOptions, { from?: string }: the URL of the index; or false for a site without searchthe index at /api/search
themethe props of ThemeProvider: defaultTheme, storageKey, attribute, disableTransitionOnChangethe defaults of ThemeProvider
themeToggleDocsThemeToggleOptions, { mode?: 'light-dark' | 'light-dark-system' }: the theme toggle of the layouts{ mode: 'light-dark' }
sidebarStorageKeystring: the localStorage key that remembers a collapsed sidebar'contour-docs-sidebar'

search is on the Search page, and the options of the theme on the ThemeProvider page: until the user picks a theme, the site follows the operating system. themeToggle sets how the theme toggle of the layouts lets the reader choose.

The site

FieldTypeDefault
nameReactNode: the brand, a link home at the start of the headers and the sidebarrequired
urlstring: where the brand leads'/'
navDocsNavLink[], { label, href }: text links in the header of HomeLayout, its navigation "Main"—
linksDocsLink[], { label, href, icon }: icon buttons in the header of HomeLayout, and at the foot of the sidebar and of the menu—

A DocsLink is named by its label and shows its icon, one of the <camelName>Icon exports of @contour-ui/icons, such as githubIcon. A link to another site, an href with a scheme such as https:, opens in a new tab, and its name says so: "GitHub (opens in a new tab)". A path of the site goes through the Link of the framework, as the nav links do.

The script

What only the browser knows, the theme the user chose and a sidebar they collapsed, would show only after hydration, and a page rendered on the server would flash. docsScript() returns the source of an inline script that sets both on <html> before the first paint: data-theme, as themeScript() does, and data-contour-docs-sidebar="collapsed". The layouts render every state, and CSS shows the one the attributes ask for, so the page neither flashes nor hydrates differently.

Render it at the top of <head>, put suppressHydrationWarning on <html>, and give the script the options of DocsProvider, so both read the same keys:

import { DocsProvider, docsScript } from '@contour-ui/fumadocs';
import type { DocsScriptOptions } from '@contour-ui/fumadocs';

const options = {
  theme: { defaultTheme: 'light', storageKey: 'my-docs-theme' },
  sidebarStorageKey: 'my-docs-sidebar',
} satisfies DocsScriptOptions;

<html lang="en" suppressHydrationWarning>
  <head>
    <script dangerouslySetInnerHTML={{ __html: docsScript(options) }} />
  </head>
  <body>
    <DocsProvider site={site} {...options}>
      {children}
    </DocsProvider>
  </body>
</html>;

docsScript(options) takes theme (defaultTheme, storageKey, attribute) and sidebarStorageKey, with the defaults of DocsProvider. The source is the same for the same options, so a Content Security Policy can allow the script by its hash.

The theme toggle

The layouts hold a theme toggle: at the foot of the sidebar and of the menu, and in the header of HomeLayout. themeToggle.mode of DocsProvider sets how it lets the reader choose:

<DocsProvider site={site} themeToggle={{ mode: 'light-dark-system' }}>
  {children}
</DocsProvider>
  • light-dark, the default: a button that switches between the light and the dark theme. It shows the icon of the theme it switches to, and its name says so: "Switch to the light theme". Both choices are in the HTML, and CSS shows the one that fits the data-theme of <html>, so it is right before the page hydrates.
  • light-dark-system: three choices, Light, Dark and System, the one of the operating system, as icons on a SegmentGroup, the radio group "Theme". The choice the reader made is checked. data-theme cannot tell System from the theme it resolves to, so no choice is checked until the page has hydrated and the stored choice is known.

The two examples above switch the theme of this site.

DocsLayout

PropTypeDefault
treeRoot: the page tree of fumadocs-core, source.getPageTree()required
childrenReactNode: the page, a DocsPagerequired

DocsLayout renders the skip link, then the navigation of the docs next to the page: a sidebar from 48rem, a header with a menu below.

Sections

The layout splits the docs into sections: the root folders of the page tree, whose meta.json says "root": true, such as Essentials and Headless on this site. The navigation lists the pages of the current section only, and the section switcher at its top goes to another one: its button shows the section of the page, and its list every section with its description, the current one checked. A section starts at its index page, or else at its first page.

content/docs/essentials/meta.json
{
  "title": "Essentials",
  "description": "The everyday components of @contour-ui/react",
  "root": true,
  "pages": ["index", "---Components---", "alert", "button", "card", "forms"]
}
content/docs/essentials/forms/meta.json
{
  "title": "Forms",
  "defaultOpen": true,
  "pages": ["index", "checkbox", "select"]
}
  • Groups. A separator in pages, ---Components---, starts a group of the navigation, labeled with its name. The pages before the first separator come first, without a label.
  • Folders inside a section open and close. The name of a folder links to its index page, and the button after it, named "Forms pages" for a folder Forms, shows or hides its pages, indented under it; a folder without an index page is that button itself. A folder starts open when it holds the page, or with "defaultOpen": true in its meta.json, and opens as the reader goes to one of its pages; it stays as the reader leaves it otherwise. "collapsible": false keeps a folder open, its index page and its pages listed under its name.
  • Links in pages, [GitHub](https://github.com/me/my-docs), open another site in a new tab, with an arrow that says so; external:[Name](/path) does that for a path too.
  • The current page is marked, as aria-current="page".
  • Outside the sections, as on the root page of the docs, the switcher reads "Sections", and the navigation lists the whole tree, each section as a link to where it starts.

The sidebar and the header

WidthNavigationTable of contents
below 48remthe header, sticky, with the search and the button of the menu, a drawer from the start edgethe "On this page" bar, sticky below the header
48rem to 80remthe sidebar, beside the page, as high as the viewportthe bar, sticky at the top
from 80remthe sidebara column beside the article

The sidebar holds the brand and the button that collapses it, the search, the section switcher, the pages of the section, which scroll on their own, and at its foot the links of the site and the theme toggle. The menu holds the switcher, the pages, the links and the toggle.

  • Collapse. The collapse button hides the sidebar, and a small toolbar at the top start of the viewport holds the buttons that expand it and open the search; the article stays in the middle, clear of the toolbar. Focus moves to the button that undoes the change, since the one pressed goes away.
  • Remembered. A collapsed sidebar is data-contour-docs-sidebar="collapsed" on <html>, and collapsed in localStorage under sidebarStorageKey; the script restores it before the first paint. Where the browser blocks the storage, the choice lasts as long as the page.
  • The menu closes as a link inside it is followed, a link to the page itself too, as the page changes in any other way, and when the viewport grows wide enough for the sidebar. Escape and its close button close it, and focus goes back to the menu button.

DocsPage

PropTypeDefault
titlestring: the title of the page, its h1required
descriptionstring: the description, under the title—
tocTableOfContents: the table of contents of fumadocs-core, page.data.tocrequired
childrenReactNode: the content of the page, its MDX in Proserequired

The article is main#content, the target of the skip link, as wide as a readable line: the title as the only h1 of the page and the description, then the content, then links to the previous and the next page of the section, in the order of the navigation. The first page of a section has no previous one and the last no next one. The content takes the height of a short page, so the links stay at the bottom.

DocsPage finds the page in the tree of its DocsLayout by the current path, and throws outside one.

Table of contents

The table of contents lists the h2 and h3 headings of the page; a deeper heading belongs to the h3 above it. A page without these headings shows none.

  • From 80rem, the column "On this page" beside the article: the styled Toc, which marks the headings in view. It stays in view as the page scrolls, and scrolls on its own when it is taller than the viewport.
  • Below 80rem, a bar above the article, sticky below the header. Its button shows the heading being read, "On this page › Props", and opens the list in a popover, with the focus on the link of that heading. Following a link scrolls to its heading and closes the popover.
  • A heading that a link scrolls to stops below the sticky header and bar.

HomeLayout

PropTypeDefault
childrenReactNode: the content, in main#contentrequired
searchboolean: whether the header holds the button of the searchtrue

HomeLayout renders the skip link, the header of the site, then its children in main#content, which fills the rest of the viewport. HomeLayout renders on the server too: the home page can be a React Server Component.

// A home page whose search lives in the docs: ⌘K still opens it.
<HomeLayout search={false}>
  <Hero />
</HomeLayout>

The header

The header sticks to the top of the page, on the color of the page and without a divider. It lays its content out in the column of the page: as wide as --contour-docs-home-width, 80rem by default, centered, with at least the page inset at its sides:

  • At the start, the brand.
  • At the end, the nav links of the site, its navigation "Main", in the type of a site's navigation (--ct-text-nav) and the color of the brand, underlined on hover; then the search (a field from 48rem, an icon below), unless search is false; then the links of the site and the theme toggle.

It is 4.5rem high from 48rem, and 3rem on phones, as the header of the docs pages there, so the brand stays in place from one layout to the other. Where a phone leaves the header too little room, the nav links scroll sideways in their own box, and the brand, the buttons and the page stay in place: keep the labels short, or use a logo that turns into its mark on phones.

search={false} only takes the button out of the header: the search of DocsProvider is still there, and its shortcut, ⌘K or Ctrl K, opens it on every page.

The column of the page

The content spans the width of the page, so a section can run its background or its border from edge to edge. A column of your own lines up with the header when it reads the same variables:

home.css
.home-section {
  box-sizing: border-box;
  max-inline-size: calc(var(--contour-docs-home-width) + 2 * var(--contour-docs-page-inset));
  margin-inline: auto;
  padding-inline: var(--contour-docs-page-inset);
}

Set --contour-docs-home-width on .contour-docs-home-layout from your CSS for another width: the header and the columns that read it follow.

A layout of your own

The layouts are made of parts, three of which the package exports for a layout of your own: the button of the search, the theme toggle and the skip link. This site runs on them, so the ones below open its search and switch its theme:

app-layout.tsx
import { SearchTrigger, SkipLink, ThemeToggle } from '@contour-ui/fumadocs';
import type { ReactNode } from 'react';

export function AppLayout({ children }: { children: ReactNode }) {
  return (
    <>
      <SkipLink />
      <header className="app-header">
        <a href="/">My docs</a>
        <SearchTrigger variant="icon" />
        <ThemeToggle />
      </header>
      <main id="content" tabIndex={-1}>
        {children}
      </main>
    </>
  );
}
  • SearchTrigger opens the search dialog of DocsProvider, and is named "Search" in both variants: field, the default, a wide button with the label and the shortcut, as wide as its container lets it; icon, an icon button. Both have the lg size of the buttons around them, and the shortcut as aria-keyshortcuts once the page has hydrated. On a site without search, it renders nothing.
  • ThemeToggle is the theme toggle of the layouts, in the mode of DocsProvider's themeToggle, or in the one you give it.
  • SkipLink, "Skip to content", is the first stop of the Tab order, out of sight until it has the focus. Render it first, and give the main content id="content" and tabIndex={-1}.
PartProps
SearchTriggervariant: 'field' (default) or 'icon'; className
ThemeTogglemode: 'light-dark' or 'light-dark-system'
SkipLinknone

SearchTrigger and ThemeToggle read DocsProvider: render them inside it, as the layouts are.

Accessibility

  • Landmarks. The sidebar from 48rem, the header below, is the banner of a docs page. The pages of the section are the navigation "Docs", the table of contents the navigation "On this page", and the links to the previous and the next page the navigation "Previous and next page". The article is the main, and the title of the page its only h1. On a page of HomeLayout, the header is the banner, and its nav links, a list, the navigation "Main".
  • The skip link is the first stop of the Tab order, and leads past the navigation to the article; the next Tab goes on in the content.
  • Focus. Collapsing and expanding the sidebar move the focus to the other button. Escape closes the menu, the section switcher and the popover of the table of contents, and gives the focus back to their button. A result of the search moves the focus to its heading, or to the article for a page, so reading and the Tab order go on from there.
  • Names. The current page is aria-current="page" in the navigation; the switcher reads a section's description after its name; links to another site say that they open in a new tab. The button of a folder says whether its pages show (aria-expanded).
  • Motion. Under prefers-reduced-motion: reduce, the overlays fade without moving.