Layouts & Theming

The four built-in shells, the registry that loads them lazily, adding your own, and the OKLCH theme tokens that belong to you.

The Four Built-in Layouts

Selected by VITE_APP_LAYOUT:

ValueShell
navbar (default)collapsible icon sidebar + top header with breadcrumb and actions
sidebarclassic full sidebar grouped by route group
mobilesticky header with back/logo/actions and floating bottom tabs
nativeone adaptive shell: native app on a phone, sidebar on a desktop

All four read the same route tree, so navigation stays consistent no matter which one you pick.

native

The one to reach for when the app is going to be installed. It is a single shell that changes shape at the md breakpoint, rather than two layouts behind a branch:

What you get
PhoneA floating translucent tab bar (the first four routes plus a More sheet), a large title on each tab's own screen that becomes a compact centred title with a back button once you drill in, and an edge swipe that goes back with 1:1 tracking and momentum projection.
DesktopThe same destinations as a persistent sidebar, a navigable breadcrumb in the toolbar, and the account actions in a side sheet.

Which half renders is decided by md: variants, not by a measured window width - so the first painted frame is already the right size class, with no flash of the wrong shell.

Safe-area insets, RTL, dark mode and prefers-reduced-motion are all handled for you. See Mobile for the Capacitor side of a hybrid build.

The platform baseline

This lives in src/core/styles/index.css and index.html, and applies to every layout, not just native:

  • env(safe-area-inset-*) exposed as the safe-t / safe-b / safe-l / safe-r spacing tokens, so you can write pt-safe-t, pb-safe-b, mb-safe-b and so on.
  • A glass utility for translucent chrome, which falls back to opaque without backdrop-filter or under prefers-reduced-transparency.
  • No tap highlight, no pull-to-refresh, no input-focus zoom.
  • viewport-fit=cover, without which the insets report nothing at all.

How a Layout Is Selected

There are two registries:

RegistryOwnerReplaced on sync
src/core/layouts/registry.tscoreyes
src/overrides/layouts.tsyouno

VITE_APP_LAYOUT is looked up in yours first, then in the built-ins. So a layout of your own named navbar takes over the built-in one without you renaming it across your env files.

Both registries map a name onto a function that loads a layout, rather than onto the layout itself:

src/core/layouts/registry.ts
export const coreLayouts = {
  navbar: () => import("./navbar"),
  sidebar: () => import("./sidebar"),
  mobile: () => import("./mobile"),
  native: () => import("./native"),
}

Only the selected layout is ever requested, so every layout after the first costs nothing in the initial bundle.

The loader body has to be a literal import("...") with a static path. A bundler cannot split a path it has to compute at runtime.

An unknown VITE_APP_LAYOUT is not a white screen: the app comes up on navbar and logs which names are registered.

Adding Your Own Layout

A layout is a component that renders children. Put the file anywhere outside src/core/ and export it as default or as a named Layout - either is picked up.

src/layouts/compact.tsx
export default function CompactLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex h-dvh">
      <aside className="w-56 border-e">{/* your nav */}</aside>
      <main className="flex-1 overflow-auto p-4">{children}</main>
    </div>
  )
}
src/overrides/layouts.ts
import type { TLayoutsOverride } from "@/core/types"

export const layouts: TLayoutsOverride = {
  compact: () => import("@/layouts/compact"),
  brand: () => import("@/layouts/brand"),
}
.env
VITE_APP_LAYOUT=compact

That is the whole registration. Inside the layout, read the route tree with getNavRoutes(useLayout().router.routes).

Copy src/core/layouts/{navbar,sidebar,mobile,native}/index.tsx as starting points. native/ is the one to copy for a hybrid build - it already has the gesture, safe-area and size-class handling worked out. Copy them out of src/core/; don't edit them in place.

Choosing in code instead

LayoutProvider still takes a layout prop, and src/App.tsx is yours. Passing it skips the registry and the lazy load entirely, which is what you want when the decision is made in code rather than in an env file:

src/App.tsx
<LayoutProvider router={router} layout={CompactLayout}>
  <Outlet />
</LayoutProvider>

Theme Tokens

The ownership boundary repeats itself in the stylesheets:

FileOwnerContents
src/index.cssYouOKLCH colour variables under :root and .dark, radii, font stacks (--font-sans, --font-heading)
src/core/styles/index.cssCoreMechanics only: @property declarations, keyframes, scroll-mask utilities, safe-area spacing tokens, the glass material and the hybrid platform baseline. No colour tokens.

Restyling means editing tokens in src/index.css. Core still ships new utilities on sync, and neither side loses work.

src/index.css contains a deliberate @source inline(...) block that force-generates grid and gap utilities, because className and groupClassName can arrive from a backend schema at runtime, where Tailwind's scanner cannot see them. Do not remove it - extend the ranges if a schema needs a value outside them.

Dark Mode

Dark mode is the dark class on <html>, driven by ThemeProvider with light, dark and system, persisted under the localStorage key theme.

Theme and direction are applied before first paint by an inline script in index.html, so there is no flash on load.

Fonts

src/core/styles/fonts.css bundles three families:

FamilyRole
Interbody
Geistheadings
CairoArabic

Each @font-face carries a unicode-range, so Latin resolves to Inter/Geist and Arabic resolves to Cairo per character - no manual switching for mixed-script content.

Fonts are bundled, not fetched, so they work offline on mobile.


On this page