Custom Layouts

The Layout component is the frame around your content. It controls where the header sits, whether there's a sidebar, how the footer is placed, and how all of that adapts on mobile. Every page on every site renders through a Layout — the only question is whether you use the runtime's default or provide your own.

Most foundations don't need a custom Layout. The default renders header, body, and footer in sequence with semantic HTML wrappers, and that covers marketing sites, landing pages, and blogs. You need a custom Layout when the arrangement of areas matters — sidebars, sticky headers, mobile drawers, content that's narrower when there's no right panel.

The docs template ships with a custom Layout that handles all of these. We'll use it as the worked example throughout this guide.


What the Runtime Gives You

When the runtime renders a page, it pre-renders each layout area into React elements and passes them to your Layout as props:

// packages/runtime/src/components/Layout.jsx (simplified)
function DefaultLayout({ header, body, footer }) {
  return (
    <>
      {header && <header>{header}</header>}
      {body && <main>{body}</main>}
      {footer && <footer>{footer}</footer>}
    </>
  )
}
PropSourceWhat it contains
headerthe header area — layout/header.mdPre-rendered React elements (or null)
bodyPage content filesPre-rendered React elements (or null)
footerthe footer area — layout/footer.mdPre-rendered React elements (or null)
leftthe left area — layout/left.mdPre-rendered React elements (or null)
rightthe right area — layout/right.mdPre-rendered React elements (or null)
paramspage.yml layout.params + meta.js defaultsMerged layout parameters
pageRuntimeCurrent Page instance
websiteRuntimeWebsite instance

Area names aren't limited to header, footer, left, right — those are conventions. A layout can declare any area names it wants (see Named Layouts below). The runtime passes each area as a prop with the matching name.

The key insight: by the time your Layout receives these props, the sections are already rendered. Your Layout doesn't render content — it arranges content. The header prop isn't a list of blocks you need to loop through; it's a finished React element tree you place where you want it.


Layout Areas Hold Sections

An area can hold several sections. Most areas have one, written as a single file; an area with several is a folder of section files inside its layout's folder — layout/default/ for the default layout:

site/
├── layout/
│   ├── left.md                 ← Sidebar navigation (one section)
│   └── default/
│       ├── header/
│       │   ├── 1-topbar.md     ← Announcement bar
│       │   └── 2-navbar.md     ← Main navigation
│       └── footer/
│           ├── 1-footer.md     ← Footer links
│           └── 2-copyright.md  ← Copyright bar
├── pages/
│   └── home/
│       ├── page.yml
│       └── hero.md

An area is not a page: it has no page.yml, its sections render in filename order, and each section declares its own data. A folder directly under layout/ is always a named layout (Named Layout Content), which is why the default layout's multi-section areas live in layout/default/.

The runtime renders all sections of the header area into a single React element and passes that as the header prop. Your Layout wraps that element in a <header> tag — that's where the semantic HTML comes from. Section components themselves render <div>s. They don't know whether they'll end up in a header, sidebar, or main content area.

┌─────────────────────────────────────┐
│           header                    │
├──────────┬─────────────┬────────────┤
│          │             │            │
│  left    │   Page      │  right     │
│          │   Content   │            │
│          │             │            │
├──────────┴─────────────┴────────────┤
│           footer                    │
└─────────────────────────────────────┘

When You Need a Custom Layout

Use caseCustom Layout?Why
Marketing site (header, sections, footer)NoDefault handles this
Blog with sidebarMaybeOnly if sidebar needs sticky positioning or responsive behavior
Documentation with navigation sidebarYesSticky sidebar, mobile drawer, conditional widths
Dashboard with persistent left panelYesPanel positioning, responsive collapse
Site where footer is inside the content areaYesDefault puts footer after main, not inside it
Different header height per pageNoUse CSS variables and page-specific classes

The general rule: if header → body → footer in a single column is enough, you don't need one. If you need areas side-by-side, sticky, or conditionally visible based on viewport — you do.


Building a Custom Layout

Where Layouts Live

Layouts are discovered from src/layouts/, parallel to src/sections/ and src/components/:

src/
├── sections/          # Section types
├── components/        # Internal components
├── layouts/           # Layout components
│   └── DocsLayout/
│       ├── index.jsx  # Layout component
│       └── meta.js    # Optional: declares areas, params
└── main.js            # Foundation declarations — set defaultLayout here

Discovery follows the same relaxed rules as src/sections/ — root-level files and folders are addressable by default, even without meta.js. A bare file like MarketingLayout.jsx works too.

The Minimum

A custom Layout equivalent to the default:

// src/layouts/SimpleLayout/index.jsx
export default function SimpleLayout({ header, body, footer }) {
  return (
    <div className="min-h-screen flex flex-col">
      {header && <header>{header}</header>}

      <main className="flex-1">
        {body}
      </main>

      {footer && <footer>{footer}</footer>}
    </div>
  )
}

This is already more than the default — it uses a flex column for full-height pages. From here, you add what your design needs.

Setting the Default Layout

Tell the framework which layout to use by default:

// src/main.js
export default {
  defaultLayout: 'DocsLayout',
}

With no defaultLayout, a layout named Default (or DefaultLayout) is the default. With neither, a page that names no layout gets the built-in one: header, the page, then footer, in a single column — other areas are not drawn. A defaultLayout that names none of your layouts stops the build.

Pages can override this in page.yml:

layout: MarketingLayout
# or with options:
layout:
  name: MarketingLayout
  hide: [left, right]

Hiding Areas Per Page

Pages can hide specific areas:

# site/pages/landing/page.yml
title: Landing Page
layout:
  hide: [header, footer]

The runtime skips creating blocks for hidden areas. Your Layout simply won't receive those props (they'll be null). This replaces the old page.hasHeader() / page.hasFooter() checks — areas are either present or null.

export default function DocsLayout({ header, body, footer, left, right }) {
  return (
    <div className="min-h-screen flex flex-col">
      {header && <header>{header}</header>}

      <div className="flex-1 flex">
        {left && <aside className="w-64">{left}</aside>}
        <main className="flex-1">{body}</main>
        {right && <aside className="w-64">{right}</aside>}
      </div>

      {footer && <footer>{footer}</footer>}
    </div>
  )
}

Check the prop directly ({left && ...}) — if the area has content and isn't hidden, the prop is a React element. Otherwise it's null.

A Sticky Area

A section that should stay in view as the page scrolls says so with sticky: true — its type's default in meta.js, or its author's frontmatter. The runtime makes the area's box sticky when any of its sections is, and a sticky box stays in view only within its parent. So where you put the area decides whether it can:

// The area sits directly in the page column: it sticks, with nothing more.
<div className="min-h-screen flex flex-col">
  {header}
  <main className="flex-1">{body}</main>
</div>

// The area sits in an element of yours, as tall as itself: make that element sticky.
<div className="min-h-screen flex flex-col">
  <header className={page.isSticky('header') ? 'sticky top-0 z-30' : undefined}>{header}</header>
  <main className="flex-1">{body}</main>
</div>

page.isSticky(area) is true when any section in the area is sticky. In uniweb dev, a sticky box that cannot move — an area your element confines — is named in the console. A layout whose page doesn't scroll (only its <main> does, scroll: 'main') keeps its chrome in view anyway.


A Real Layout: Documentation Sidebar

The docs template Layout handles sticky header, responsive sidebars, a mobile drawer, and conditional content width. Here's how it's built.

The Structure

// src/layouts/DocsLayout/index.jsx
export default function DocsLayout({
  page, website, header, body, footer, left, right,
}) {
  const [sidebarOpen, setSidebarOpen] = useState(false)

  // Close sidebar on page navigation
  const activeRoute = page?.route
  useEffect(() => {
    setSidebarOpen(false)
  }, [activeRoute])

  return (
    <div className="min-h-screen flex flex-col bg-white">
      {/* Sticky Header */}
      <header className="sticky top-0 z-30 w-full border-b border-gray-200
        bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80">
        {header}
      </header>

      {/* Mobile Sidebar (see next section) */}
      {left && (
        <MobileSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)}>
          {left}
        </MobileSidebar>
      )}

      {/* Main Content Area */}
      <div className="flex-1 w-full max-w-7xl mx-auto">
        <div className="flex">
          {/* Left Sidebar - Desktop */}
          {left && (
            <aside className="hidden md:block sticky top-16 w-64
              flex-shrink-0 h-[calc(100vh-4rem)] overflow-y-auto border-r">
              {left}
            </aside>
          )}

          {/* Center Content */}
          <main className="flex-1 min-w-0">
            <div className={cn(
              'px-4 py-8 sm:px-6 lg:px-8',
              !right && 'max-w-3xl mx-auto'
            )}>
              <div className="prose prose-slate max-w-none">
                {body}
              </div>

              {/* Footer inside main (design choice — see below) */}
              {footer && (
                <footer className="mt-12 pt-8 border-t">
                  {footer}
                </footer>
              )}
            </div>
          </main>

          {/* Right Sidebar - Desktop */}
          {right && (
            <aside className="hidden xl:block sticky top-16 w-64
              flex-shrink-0 h-[calc(100vh-4rem)] overflow-y-auto border-l">
              {right}
            </aside>
          )}
        </div>
      </div>

      {/* Mobile Menu Button */}
      {left && (
        <MenuButton onClick={() => setSidebarOpen(true)} />
      )}
    </div>
  )
}

A few things to notice:

  • The header is sticky with backdrop-blur for a translucent effect
  • Sidebars use sticky top-16 — they stick below the header (4rem = 16 in Tailwind's spacing)
  • min-w-0 on main prevents flex children from overflowing
  • The whole thing is wrapped in flex flex-col with min-h-screen so the page always fills the viewport

Mobile Sidebar Drawer

The mobile sidebar is a separate component with a backdrop, slide animation, and scroll lock:

function MobileSidebar({ isOpen, onClose, children }) {
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = 'hidden'
      return () => { document.body.style.overflow = '' }
    }
  }, [isOpen])

  return (
    <>
      {/* Backdrop */}
      {isOpen && (
        <div className="md:hidden fixed inset-0 bg-black/50 z-40"
          onClick={onClose} />
      )}
      {/* Drawer */}
      <div className={cn(
        'md:hidden fixed top-16 left-0 w-72 h-[calc(100vh-4rem)] bg-white z-50',
        'transform transition-transform duration-300 ease-in-out',
        isOpen ? 'translate-x-0' : '-translate-x-full'
      )}>
        <button onClick={onClose} className="absolute top-4 right-4 p-1 rounded-md"
          aria-label="Close sidebar">
          {/* close icon SVG */}
        </button>
        <div className="h-full overflow-y-auto">{children}</div>
      </div>
    </>
  )
}

The drawer is positioned at top-16 so it sits below the header. It renders the same left prop that the desktop sidebar gets — same React element, two placements. On md: and wider, the drawer is hidden and the static sidebar takes over.

In the docs template, the footer sits inside the main content area, below the body. This is a deliberate design choice — documentation footers typically show prev/next navigation, which belongs with the content rather than spanning the full page width.

A marketing site would do the opposite: place the footer outside and below the flex container so it spans the full width. There's no right answer — the Layout lets you make this choice.

Conditional Width

When there's no right sidebar, the content area constrains itself:

<div className={cn(
  'px-4 py-8 sm:px-6 lg:px-8',
  !right && 'max-w-3xl mx-auto'
)}>

Without this, documentation text would stretch to fill the space where the right sidebar would be. Constraining to max-w-3xl keeps prose readable. When the right sidebar is present, the content naturally fills the remaining space between the two sidebars.


Named Layouts

When a foundation needs multiple page structures — a docs layout with sidebars, a marketing layout without, a dashboard layout with a toolbar — each is a separate layout component:

src/layouts/
├── DocsLayout/
│   ├── index.jsx
│   └── meta.js
├── MarketingLayout/
│   └── index.jsx
└── DashboardLayout/
    ├── index.jsx
    └── meta.js

Pages select their layout by name:

# page.yml
layout: MarketingLayout

The foundation sets a default in main.js:

export default {
  defaultLayout: 'DocsLayout',
}

Pages without an explicit layout: use the default. The layout name matches the directory name, regardless of case and of a trailing Layout — DocsLayout/ is referenced as DocsLayout, or simply docs.

Layout meta.js

Layouts can declare metadata — which areas they render, parameters they accept, and view transition overrides (areas animate automatically; the map is optional):

// src/layouts/DocsLayout/meta.js
export default {
  title: 'Documentation',
  description: 'Three-column layout with sidebar navigation',

  areas: ['header', 'footer', 'left', 'right'],

  params: {
    sidebarWidth: {
      type: 'select',
      options: ['narrow', 'wide'],
      default: 'narrow',
    },
  },
}

The areas array declares which areas the layout renders. It does not decide how a site's layout/ folder is read — that comes from the folder alone (Named Layout Content). The params work like section type params — defaults are merged with values from page.yml.

Scroll management

By default, the runtime manages scroll restoration on window — saving position per history entry and restoring on back/forward navigation. Layouts can override this with the scroll property in meta.js:

// src/layouts/DashboardLayout/meta.js
export default {
  areas: ['header', 'sidebar'],

  // Layout manages its own scrolling (runtime disables scroll management)
  scroll: 'self',
}
// src/layouts/DocsLayout/meta.js
export default {
  areas: ['header', 'footer', 'left'],

  // Runtime manages scroll on the <main> element instead of window
  scroll: 'main',
}
ValueBehavior
Not setRuntime manages scroll on window (default)
'self'Layout handles its own scrolling; runtime disables
CSS selector (e.g. 'main')Runtime manages scroll on that element

This follows the same pattern as background: 'self' in section types — the layout tells the runtime "I handle this myself."

A foundation-level default can be set in main.js for all layouts that don't declare their own:

// src/main.js
export default {
  defaultLayout: 'DocsLayout',
  scroll: 'self',
}
# page.yml — setting layout params
layout:
  name: DocsLayout
  params:
    sidebarWidth: wide

The Layout component receives params as a prop:

function DocsLayout({ body, header, footer, left, right, params }) {
  const sidebarClass = params.sidebarWidth === 'wide' ? 'w-80' : 'w-64'
  // ...
}

General Named Areas

Area names aren't restricted to header, footer, left, right. A layout can declare any areas:

// src/layouts/DashboardLayout/meta.js
export default {
  title: 'Dashboard',
  areas: ['topbar', 'sidebar', 'statusbar'],
}

The site provides content with matching filenames, in the layout's folder:

layout/DashboardLayout/
├── topbar.md
├── sidebar.md
└── statusbar.md

The Layout receives each area as a prop:

function DashboardLayout({ topbar, sidebar, body, statusbar }) {
  return (
    <div className="grid grid-cols-[auto_1fr] grid-rows-[auto_1fr_auto]">
      <div className="col-span-2">{topbar}</div>
      <aside className="w-64">{sidebar}</aside>
      <main>{body}</main>
      {statusbar && <div className="col-span-2">{statusbar}</div>}
    </div>
  )
}

body is the only special name — it's the page's own sections, always passed to every layout. It doesn't appear in areas.

Named Layout Content

When a site uses named layouts, each layout's area content lives in a folder of layout/ named after the layout:

site/layout/
├── header.md            ← default layout areas (bare files)
├── footer.md
├── left.md
├── MarketingLayout/     ← areas for the MarketingLayout layout
│   ├── header.md
│   └── footer.md
└── DashboardLayout/     ← areas for the DashboardLayout layout
    ├── topbar.md
    ├── sidebar/         ← an area with several sections
    │   ├── 1-nav.md
    │   └── 2-filters.md
    └── statusbar.md

The structure alone decides what each entry is — no foundation is consulted: a file at the top of layout/ is a default-layout area, a folder directly under layout/ is a named layout, and a folder inside a layout's folder is an area with several sections. The folder name is the layout name, matched regardless of case and with or without a trailing Layout — so a foundation layout named MarketingLayout gets its areas from layout/marketing/, which reads best, as well as from layout/MarketingLayout/. Two folders that name one layout (layout/marketing/ and layout/MarketingLayout/) stop the build.

A named layout's areas are its own. A page on MarketingLayout gets no left area from the default layout's left.md; if the layout should have one, give it layout/MarketingLayout/left.md.


Semantic HTML

The Layout is responsible for wrapping areas in semantic HTML elements. This is a convention, not a technical requirement — but it matters for accessibility and SEO.

AreaSemantic wrapperWhy
Header sections<header>Landmark for screen readers, contains site navigation
Body sections<main>The primary content of the page
Footer sections<footer>Site-wide footer information
Sidebars<aside>Complementary content (navigation, table of contents)

Section components render <div>s. They don't add <header> or <main> wrappers because they don't know where they'll be placed. A navigation component might end up in the header, a sidebar, or the main content area — the Layout decides placement, so the Layout provides the semantic wrappers.


Tips

  • Start without a custom Layout. The default covers most cases. Add one when you have a specific arrangement need, not preemptively.

  • Keep layout logic in Layout, rendering logic in components. The Layout controls where things go. Components control what things look like. If you're styling content inside the Layout, it probably belongs in a component or in CSS.

  • Use CSS variables for dimensions. Declare header-height and sidebar-width in main.js so sites can tune them. Then reference var(--header-height) in your Layout. The docs template does this — sticky positioning, sidebar heights, and mobile drawer offsets all reference the same variable.

  • Close mobile drawers on route change. SPA navigation doesn't trigger a page reload, so drawers stay open unless you close them explicitly. The docs template watches page.route in a useEffect.

  • Test with and without areas. Some pages may not have left or right area content. Your Layout should handle null gracefully — check before rendering, and consider adjusting the main content width when areas are absent.


See Also