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:
| Value | Shell |
|---|---|
navbar (default) | collapsible icon sidebar + top header with breadcrumb and actions |
sidebar | classic full sidebar grouped by route group |
mobile | sticky header with back/logo/actions and floating bottom tabs |
native | one 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 | |
|---|---|
| Phone | A 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. |
| Desktop | The 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 thesafe-t/safe-b/safe-l/safe-rspacing tokens, so you can writept-safe-t,pb-safe-b,mb-safe-band so on.- A
glassutility for translucent chrome, which falls back to opaque withoutbackdrop-filteror underprefers-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:
| Registry | Owner | Replaced on sync |
|---|---|---|
src/core/layouts/registry.ts | core | yes |
src/overrides/layouts.ts | you | no |
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:
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.
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>
)
}import type { TLayoutsOverride } from "@/core/types"
export const layouts: TLayoutsOverride = {
compact: () => import("@/layouts/compact"),
brand: () => import("@/layouts/brand"),
}VITE_APP_LAYOUT=compactThat 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:
<LayoutProvider router={router} layout={CompactLayout}>
<Outlet />
</LayoutProvider>Theme Tokens
The ownership boundary repeats itself in the stylesheets:
| File | Owner | Contents |
|---|---|---|
src/index.css | You | OKLCH colour variables under :root and .dark, radii, font stacks (--font-sans, --font-heading) |
src/core/styles/index.css | Core | Mechanics 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:
| Family | Role |
|---|---|
| Inter | body |
| Geist | headings |
| Cairo | Arabic |
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.