Skip to content
CtrlK

Layout & Navigation

Add pages and sidebar sections, change the logo, header, and footer, and compose the site chrome in root.tsx.

On this page

Build-time options belong in ardo.config.ts. Everything around the content, from the header to the sidebar and the footer, is React in app/root.tsx. Most sites need only a few of the common tasks below; the rest of the page is the reference.

Common Tasks

Add a Page

Create a Markdown or MDX file under app/routes/. Its path becomes its URL: app/routes/guide/install.mdx is served at /guide/install/. The development server picks it up right away, and the page appears in the sidebar and in search.

app/routes/guide/install.mdx
---
title: Install
description: Add the package to your project.
---

# Install

Run the installer for your package manager.

The title sets the sidebar label and the browser tab. TSX files in app/routes/ become pages too, but the generated sidebar lists Markdown and MDX pages only, so link a TSX page from the header or from another page.

Order Pages

Pages with an order come first, lowest number first. The rest follow alphabetically by title.

---
title: Install
order: 2
---

Group Pages

Put related pages in a folder. An index.mdx in that folder labels the group, places it among its siblings, and decides whether it starts collapsed. The index page is also the group's own page.

app/routes/guide/releases/index.mdx
---
title: Releases
order: 20
collapsed: true
---

# Releases

Without an index page, the group takes its label from the folder name. Frontmatter lists the options.

Hide a Page from the Sidebar

Set sidebar: false in the page's frontmatter. The page still builds and stays reachable by link.

Add a Sidebar Section

Each top-level folder in app/routes/ becomes a section with its own tab, such as guide/ and api-reference/. Folder names Ardo knows, such as guide, reference, api, examples, or adr, get a matching label and icon; any other folder gets a text tab named after it. The tab opens the folder's index page, or its first page.

Order the tabs in ardo.config.ts:

ardo.config.ts
import { defineArdoConfig } from "ardo/config"

export default defineArdoConfig({
  title: "My Docs",
  sidebar: { sectionOrder: ["guide", "reference", "api"] },
})

To write sections by hand instead, see Sidebar Sections.

Pass an ArdoHeader with navigation links. ArdoNavLink takes a page path in to, which gets the base path, or any URL in href. Social links go into ArdoHeaderActions:

app/root.tsx
import {
  ArdoHeader,
  ArdoHeaderActions,
  ArdoNav,
  ArdoNavLink,
  ArdoRoot,
  ArdoSocialLink,
} from "ardo/ui"

export default function Root() {
  return (
    <ArdoRoot>
      <ArdoHeader>
        <ArdoNav>
          <ArdoNavLink to="/guide/getting-started">Guide</ArdoNavLink>
          <ArdoNavLink href="https://github.com/example/project/releases">Releases</ArdoNavLink>
        </ArdoNav>
        <ArdoHeaderActions>
          <ArdoSocialLink href="https://github.com/example/project" icon="github" />
        </ArdoHeaderActions>
      </ArdoHeader>
    </ArdoRoot>
  )
}

ArdoSocialLink knows the github, discord, linkedin, npm, twitter, and youtube icons.

Change the Logo and Favicon

Set brand.logo in ardo.config.ts. A local SVG becomes the header logo and the source of the generated favicons:

ardo.config.ts
import { defineArdoConfig } from "ardo/config"

export default defineArdoConfig({
  title: "My Docs",
  brand: { logo: "./app/assets/logo.svg" },
})

brand.logo also accepts { light, dark } variants. To build the favicons from a different image, set icons.source; to manage favicon files yourself in public/, set icons: false.

Pass an ArdoFooter with your own text:

app/root.tsx
import { ArdoFooter, ArdoRoot } from "ardo/ui"

export default function Root() {
  return (
    <ArdoRoot>
      <ArdoFooter message="Released under the MIT License." copyright="© 2026 Example Co" />
    </ArdoRoot>
  )
}

Without an ArdoFooter, Ardo renders its default footer; footer={false} on ArdoRoot leaves it out. Footer lists the props.

Minimal Root

<ArdoRoot /> is a complete site: Ardo's header with search and the theme toggle, a sidebar with one section per top-level route folder, and the footer. The site config and the stylesheet come from Ardo's entries, so root.tsx needs no imports beyond the components it uses.

import { ArdoRoot } from "ardo/ui"

export default function Root() {
  return <ArdoRoot />
}

Navigation is composed with React components. Pass ArdoHeader, ArdoSidebar, and ArdoFooter as children to change any part, or set header, sidebar, or footer to false to leave it out:

import {
  ArdoGeneratedSidebar,
  ArdoHeader,
  ArdoNav,
  ArdoNavLink,
  ArdoRoot,
  ArdoSidebar,
  ArdoSidebarSection,
} from "ardo/ui"

export default function Root() {
  return (
    <ArdoRoot>
      <ArdoHeader>
        <ArdoNav>
          <ArdoNavLink to="/guide/getting-started">Guide</ArdoNavLink>
        </ArdoNav>
      </ArdoHeader>

      <ArdoSidebar>
        <ArdoSidebarSection id="guide" label="Guide" to="/guide/getting-started">
          <ArdoGeneratedSidebar section="guide" />
        </ArdoSidebarSection>
      </ArdoSidebar>
    </ArdoRoot>
  )
}

ArdoRoot owns the Ardo providers, the current page's content, and the shell slots supplied as JSX children. It is the part of the page that hydrates in the browser.

ArdoRoot

ArdoRoot takes site-wide content settings as props and the chrome as children.

PropTypePurpose
headerReactNode | falseYour own header in place of ArdoHeader, or false for none.
sidebarReactNode | falseYour own sidebar in place of ArdoSidebar, or false for none.
footerReactNode | falseYour own footer in place of ArdoFooter, or false for none.
editLink{ pattern: string; text?: string }Site-wide edit-link pattern for Markdown pages.
lastUpdated{ enabled?: boolean; text?: string; formatOptions?: Intl.DateTimeFormatOptions }Site-wide last-updated display settings.
labelsArdoLabelsInputOverride default UI chrome strings.
scrollMode"viewport" | "document"Use the viewport shell (default) or let the document scroll.
tocLabelstringLabel for the table-of-contents panel.
classNamestringReplace the default layout class.
configArdoConfigThe site config. Ardo's entries provide it from ardo.config.ts; pass it in tests and previews.
componentsArdoMDXComponentsAdd to or replace the default Markdown component set, including its wrapper.
childrenReactNodeArdoHeader, ArdoSidebar, ArdoFooter, and optional content overrides.

Use ArdoHeader for the default responsive header. Put ArdoNav links directly inside it. Use ArdoHeaderActions for right-side actions.

import { ArdoHeader, ArdoHeaderActions, ArdoNav, ArdoNavLink, ArdoSocialLink } from "ardo/ui"
import logo from "./assets/logo.svg"
;<ArdoHeader logo={logo} title="My Docs" searchPlaceholder="Search documentation...">
  <ArdoNav>
    <ArdoNavLink to="/guide/getting-started">Guide</ArdoNavLink>
    <ArdoNavLink to="/api-reference">API</ArdoNavLink>
  </ArdoNav>
  <ArdoHeaderActions>
    <ArdoSocialLink href="https://github.com/example/project" icon="github" />
  </ArdoHeaderActions>
</ArdoHeader>

If brand.logo is set in ardo.config.ts, ArdoRoot passes it to the header unless the header sets its own logo.

Header propPurpose
logoString URL or { light, dark } logo variants.
titleHeader title. Defaults to config.title.
search"auto", "field", "icon", or false to hide search. Default "auto".
searchPlaceholderPlaceholder for the search input.
themeToggleSet to false to hide the theme toggle. Defaults to true.
classNameReplace the default header class.

Search in the header

With search="auto", the page layout decides how search looks:

  • Docs pages show a search field in the center of the header.
  • Bare pages (handle = { layout: "bare" }, including the home page) show a search icon, so the hero's call to action has the stage.

Use search="field" or search="icon" to pick one presentation for every page. Below 1024px the field also collapses to the icon. In every mode, ⌘K (Ctrl+K on Windows and Linux) opens search.

Narrow screens

Below 1024px the header keeps only the menu button, the logo, and search, so a long logo still fits. The menu ends with the site controls: the version switcher, the language switcher, the theme toggle, and everything inside ArdoHeaderActions, such as social links.

Version controls

With versioning configured, the version switcher sits at the end of the sidebar instead of the header. Bare pages show it in the footer. Pages of an older version start with a banner that links to the same page in the latest version.

Custom layouts can place the same pieces themselves: ArdoVersionSwitcher is the plain select, ArdoVersionControl adds the visible label, and ArdoVersionBanner is the older-version notice. Each renders nothing unless the build has two or more versions.

On bare pages the header content shares one page width with ArdoHero, ArdoFeatures, and ArdoFooter, so the logo lines up with the page content. Docs pages keep the full-width header above the sidebar, content, and table of contents. Change the width with the --ardo-layout-pageMaxWidth token, and use vars.layout.pageMaxWidth to align your own bare-page sections.

The left navigation is always a rail plus a panel. Define both with ArdoSidebarSection: the section props create the rail item, and the section children create the panel.

import { ArdoGeneratedSidebar, ArdoSidebar, ArdoSidebarSection } from "ardo/ui"
import { BookOpen, Code } from "ardo/icons"
;<ArdoSidebar>
  <ArdoSidebarSection
    id="guide"
    label="Guide"
    to="/guide/getting-started"
    icon={<BookOpen size={18} />}
  >
    <ArdoGeneratedSidebar section="guide" />
  </ArdoSidebarSection>

  <ArdoSidebarSection id="api-reference" label="API" to="/api-reference" icon={<Code size={18} />}>
    <ArdoGeneratedSidebar section="api-reference" />
  </ArdoSidebarSection>
</ArdoSidebar>

ArdoGeneratedSidebar renders the Markdown and API navigation generated from route files for the given top-level section. For fully manual navigation, compose links and groups directly:

<ArdoSidebarSection id="guide" label="Guide" to="/guide/getting-started">
  <ArdoSidebarGroup title="Guide">
    <ArdoSidebarLink to="/guide/getting-started">Getting Started</ArdoSidebarLink>
    <ArdoSidebarLink to="/guide/configuration">Configuration</ArdoSidebarLink>
  </ArdoSidebarGroup>
</ArdoSidebarSection>

Pass ArdoFooter as a child of ArdoRoot.

<ArdoFooter
  sponsor={{ text: "Example Co", link: "https://example.com" }}
  message="Released under the MIT License."
  copyright="Copyright 2026 Example Co"
/>

editLink and lastUpdated are site-wide content settings. Ardo applies them to Markdown and MDX document pages, and page frontmatter can opt out with editLink: false or lastUpdated: false.

<ArdoRoot
  editLink={{
    pattern: "https://github.com/example/project/edit/main/docs/app/routes/:path",
    text: "Edit this page on GitHub",
  }}
  lastUpdated={{
    enabled: true,
    text: "Last updated",
    formatOptions: { dateStyle: "medium" },
  }}
  tocLabel="On this page"
>
  {/* Header, sidebar, and footer here */}
</ArdoRoot>

The :path placeholder receives the authored file path relative to app/routes (or your configured routesDir), including the file extension. Keep the repository and source-directory prefix in the pattern. Edit links remain optional.

Last-updated dates use each file's latest Git commit. Fetch the full history in CI (for example, fetch-depth: 0 with actions/checkout); source archives and files without history fall back to filesystem modification times. Dates use the site's language and UTC unless a different time zone is supplied in formatOptions. Generated API pages disable edit links and file dates. Materialized content disables them by default; its frontmatter mapping can opt in when a meaningful source pattern is configured.

On viewports up to 1280 pixels wide, the outline appears as an expandable "On this page" section below the title, and page actions appear at the end of the article. Wider screens keep the page rail.

Route-Level Chrome

For route-level chrome suppression, export a handle from the route:

app/routes/landing.tsx
export const handle = { layout: "bare", chrome: false }

chrome: false removes header and footer for that route. layout: "bare" removes sidebar and rail rendering.

Bare routes with custom chrome

Use export const handle = { layout: "bare", chrome: false } on a route that owns its site header and footer. Ardo reserves no space for an absent header, renders no additional skip link, and leaves the main element's padding, background, and overflow to your site. Provide your own skip link for keyboard navigation.

Bare routes that retain Ardo's header keep its offset and built-in skip link. Documentation routes retain the existing inner-scroll layout.

Replace Parts of the Chrome

When CSS is not enough, replace a part of the chrome with your own React component. Write the component:

app/components/MyHeader.tsx
import { useArdoConfig } from "ardo/runtime"

export function MyHeader() {
  const config = useArdoConfig()

  return (
    <header className="my-custom-header">
      <h1>{config.title}</h1>
      {/* Your custom header content */}
    </header>
  )
}

Then pass it to ArdoRoot. Replace the parts you want and keep Ardo's defaults for the rest:

app/root.tsx
import { ArdoRoot } from "ardo/ui"

import { MyHeader } from "./components/MyHeader"

export default function Root() {
  return <ArdoRoot header={<MyHeader />} />
}

header, sidebar, and footer take any React element, or false to leave that part out. As children, ArdoRoot recognizes only Ardo's own ArdoHeader, ArdoSidebar, and ArdoFooter; any other child replaces the page content. Keep ArdoRoot around your components: it provides the context the runtime hooks below read.

Footer message and copyright props render as React content, so plain strings are escaped. If you need footer markup from a trusted source, use trustedMessageHtml or trustedCopyrightHtml; do not pass user- or CMS-provided content to those props.

Reuse Search and Mobile Navigation

ArdoHeaderSearch and ArdoMobileNavigation are available from ardo/ui for a site-owned header. They use the same search dialog, keyboard shortcuts, focus management, and navigation panel as the default header. Render them inside ArdoRoot's header slot so they share the site providers.

import { useState } from "ardo/react"
import {
  ArdoGeneratedSidebar,
  ArdoHeaderSearch,
  ArdoMobileNavigation,
  ArdoRoot,
  ArdoSidebar,
  ArdoSidebarSection,
} from "ardo/ui"

function SiteHeader() {
  const [menuOpen, setMenuOpen] = useState(false)
  return (
    <header className="site-header">
      <a href="/">My project</a>
      <ArdoMobileNavigation open={menuOpen} onOpenChange={setMenuOpen}>
        <ArdoSidebar>
          <ArdoSidebarSection id="guide" label="Guide" to="/guide/install/">
            <ArdoGeneratedSidebar section="guide" />
          </ArdoSidebarSection>
        </ArdoSidebar>
      </ArdoMobileNavigation>
      <ArdoHeaderSearch variant="icon" onActivate={() => setMenuOpen(false)} />
    </header>
  )
}

export default function Root() {
  return <ArdoRoot header={<SiteHeader />} scrollMode="document" />
}

ArdoMobileNavigation can own its open state when open is omitted. Its nav, actions, logo, title, and themeToggle props customize the panel; label, trigger (button content), and className customize the trigger. The trigger follows Ardo's 1024px mobile breakpoint. Use controlled state, as above, to close navigation before activating search. Both dialogs handle Escape and restore focus. ArdoHeaderSearch accepts variant="field" for the standard responsive field or variant="icon" for an icon at every width.

Customize the Markdown Component Set

Pass components to ArdoRoot to add to the existing component set or replace individual entries. Defaults remain available; a page's explicit MDX components prop takes precedence, and an authored MDX default layout still owns that page's wrapper. Keep the map and its React components outside the root function so their identities stay stable.

import type { ReactNode } from "ardo/react"
import { useArdoPageData } from "ardo/runtime"
import { ArdoDocContent, ArdoRoot } from "ardo/ui"

function Article({ children }: { children: ReactNode }) {
  const page = useArdoPageData()
  return (
    <ArdoDocContent
      header={
        <header className="article-hero">
          <h1>{page?.title}</h1>
        </header>
      }
      tocSource="dom"
    >
      {children}
    </ArdoDocContent>
  )
}

const components = { wrapper: Article }

export default function Root() {
  return <ArdoRoot components={components} />
}

The map can replace entries such as table, a, and CodeBlock, and register custom component names used by Markdown and MDX. A custom a should keep Ardo's base-path behavior for internal links. Replacing CodeBlock also replaces its copy controls and syntax presentation.

Article Slots

ArdoDocContent and ArdoDocPage accept these React slots. Omit a slot to retain its default; pass false or null to omit it.

SlotReplaces
headerThe article title and description. Breadcrumbs and version notices remain.
outlineThe compact, in-article table of contents.
railThe desktop rail with the outline and edit/updated information.
footerThe article's edit/updated information and previous/next links.

ArdoContent exposes the same header, outline, and footer slots. Supplying a custom outline explicitly takes precedence over frontmatter outline: false.

Discover Headings from Components

By default, the outline uses build-time Markdown metadata. Set tocSource="dom" on ArdoDocContent or ArdoDocPage to discover component-rendered headings after hydration. The initial server HTML and first client render keep the static outline. The mounted outline then follows the actual article headings, including additions, removals, and title changes.

For a standalone outline, use <ArdoTOC source="dom" scope="#article-body" />. The optional scope is a CSS selector for the content container. Without it, Ardo reads .ardo-content-body inside #main-content, or #main-content itself for a fully custom page. Both modes use markdown.toc.level for heading levels. Navigation, the outline itself, hidden headings, and subtrees marked data-ardo-toc="exclude" are excluded. Use that attribute on worked examples whose headings should not become article sections.

Keep explicit, unique heading IDs for durable links. DOM mode supplies a collision-free ardo-heading-N ID when a heading has none; those generated IDs depend on document order. The DOM outline is a browser enhancement: it does not add component content to the build-time search index, sitemap, or LLM exports. Supply indexable prose in Markdown when those outputs need to contain it.

Runtime Hooks

Your components read Ardo's state through hooks from ardo/runtime:

HookReturns
useArdoConfig()The site config from ardo.config.ts, such as title, description, and brand.
useArdoSiteConfig()The editLink, lastUpdated, labels, and tocLabel settings passed to ArdoRoot.
useArdoSidebar()The sidebar items of the current section.
useArdoPageData()Inside Markdown and MDX page content: the page's title, frontmatter, table of contents, and source path.
useArdoTOC()The current page's table of contents, or an empty list.
useArdoPath()The current page path without the base path.
useArdoContext()The config, sidebar, and current page together.

An edit link built from the site settings and the current page:

import { useArdoPageData, useArdoSiteConfig } from "ardo/runtime"

function MyEditLink() {
  const { editLink } = useArdoSiteConfig()
  const page = useArdoPageData()
  if (!editLink?.pattern || !page?.relativePath || page.frontmatter.editLink === false) {
    return null
  }
  const sourcePath = page.relativePath.split("/").map(encodeURIComponent).join("/")
  const href = editLink.pattern.replace(":path", sourcePath)
  return <a href={href}>{editLink.text ?? "Edit this page"}</a>
}

findCurrentSidebarItem and getPrevNextLinks help when you replace the previous and next links at the end of a page:

import {
  ArdoLink,
  findCurrentSidebarItem,
  getPrevNextLinks,
  useArdoPageData,
  useArdoPath,
  useArdoSidebar,
} from "ardo/runtime"

function MyPrevNext() {
  const pathname = useArdoPath()
  const page = useArdoPageData()
  const sidebar = useArdoSidebar()
  if (!page) return null

  const current = findCurrentSidebarItem(sidebar, pathname)
  const { prev, next } = getPrevNextLinks(sidebar, pathname)

  return (
    <nav>
      <span>{current?.text}</span>
      {prev?.link && <ArdoLink to={prev.link}>{prev.text}</ArdoLink>}
      {next?.link && <ArdoLink to={next.link}>{next.text}</ArdoLink>}
    </nav>
  )
}

ArdoLink adds the base path and renders a plain <a>, so each click loads the prerendered target page. ArdoProvider and ArdoSiteConfigProvider supply the same context in tests and isolated component previews.

Layout: The HTML Shell

Ardo renders every page inside an HTML shell: <html>, <head>, and <body>. The shell is rendered on the server only, during ardo dev and ardo build; the root component inside it hydrates. By default, Ardo uses ArdoRootLayout from ardo/ui. It adds the charset and viewport tags, the theme bootstrap script, favicons, the page's title and meta tags, stylesheets, and the client script. Most sites do not need a Layout export.

Export Layout from root.tsx to pass options to ArdoRootLayout:

import type { ReactNode } from "ardo/react"
import { ArdoRootLayout } from "ardo/ui"
import logo from "./assets/logo.svg"

export function Layout({ children }: { children: ReactNode }) {
  return <ArdoRootLayout favicon={logo}>{children}</ArdoRootLayout>
}

For a fully custom shell, render ArdoHead in <head> and ArdoScripts at the end of <body>. Both come from ardo/runtime. ArdoHead renders the page's meta descriptors, stylesheets, and module preloads; ArdoScripts loads the client entry that hydrates the page. The shell must render children:

import type { ReactNode } from "ardo/react"
import { ArdoHead, ArdoScripts } from "ardo/runtime"

export function Layout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <ArdoHead />
      </head>
      <body>
        {children}
        <ArdoScripts />
      </body>
    </html>
  )
}

A custom shell does not include ArdoRootLayout's theme bootstrap or favicons. Prefer wrapping ArdoRootLayout unless the site needs full control over the document.

Error Pages

root.tsx can export ErrorBoundary. For a missing page, Ardo renders it inside the root, so the header and sidebar stay. For a render error, it replaces the root, so broken chrome cannot hide the error. Without the export, Ardo uses ArdoErrorBoundary from ardo/ui.

import { ArdoErrorBoundary } from "ardo/ui"

export const ErrorBoundary = ArdoErrorBoundary

RootLayout Theme Policy

By default, ArdoRootLayout uses theme="auto": it applies a stored ardo-theme preference first and falls back to prefers-color-scheme. Set an explicit theme policy when a site should not be user-selectable:

export function Layout({ children }: { children: ReactNode }) {
  return <ArdoRootLayout theme="light">{children}</ArdoRootLayout>
}
Theme policyBehavior
autoStored user preference wins; system preference is the fallback. Default.
lightAlways render light mode and clear stale stored theme preferences.
darkAlways render dark mode and clear stale theme preferences.
systemAlways follow prefers-color-scheme and clear stored preferences.

Document Scrolling with Site-Owned Chrome

Set scrollMode="document" when the site's header or footer lives outside Ardo's layout, or when the page should use normal document scrolling. Ardo keeps the existing viewport shell by default. In document mode, the document scrolls, the sidebar and page rail stick below the configured header height, and an external footer remains reachable after the documentation content. The mode also applies when className replaces Ardo's default layout class. Since that prop still replaces the default class, provide any root display or width rules your custom layout needs.

Place a sticky custom header and footer beside ArdoRoot. When both are external, disable Ardo's header and footer so the page has one of each:

export default function Root() {
  return (
    <>
      <SiteHeader />
      <ArdoRoot scrollMode="document" header={false} footer={false} />
      <SiteFooter />
    </>
  )
}

The existing --ardo-layout-headerHeight variable sets the sticky offsets and anchor spacing. Put its value on ArdoRootLayout through a Layout export so Ardo and the site header share it, then update it at the breakpoint where a mobile header grows:

export function Layout({ children }: { children: ReactNode }) {
  return <ArdoRootLayout className="docs-site">{children}</ArdoRootLayout>
}
.docs-site {
  --ardo-layout-headerHeight: 4rem;
}

@media (max-width: 640px) {
  .docs-site {
    --ardo-layout-headerHeight: 6rem;
  }
}

.site-header {
  position: sticky;
  top: 0;
  height: var(--ardo-layout-headerHeight);
}

A sticky header stays in document flow, so it needs no extra top padding. A fixed header is outside that flow; reserve its height once in the site wrapper with padding-top: var(--ardo-layout-headerHeight). For an MDX route that owns its chrome, export:

export const handle = { chrome: false }

The route's external header and footer follow the same offset rule, while Ardo keeps the sidebar and page rail. Provide a site-owned skip link for routes that suppress Ardo's chrome.

Edit this page on GitHubLast updated: October 23, 2026