Skip to content

Layouts ​

The shell around every page is one of four layouts, chosen by config or by a route.

Open in demo

The four ​

LayoutShape
classicFixed sidebar, sticky navbar, footer, as a two-column grid.
modernThe same grid with a floating sidebar and a transparent navbar. The differences are visual only. This is the default.
minimalNavbar only, no sidebar. The hamburger stays at every width, because dropping the sidebar would otherwise leave a desktop with no route to the menu.
horizontalA top strip instead of a sidebar. Its top level is the sidebar's section captions, each opening a dropdown of that section's items.

Two more exist for routes rather than config: auth is a centered card on the app canvas for the sign-in pages, and blank is no chrome at all, for print views and embedded widgets.

Choosing one ​

ts
import { useConfig } from '@core/config/useConfig'

useConfig().setUi('layout', 'classic')

A route can override it for itself:

json
{
  "path": "/invoices/:id/print",
  "name": "invoice-print",
  "meta": { "layout": "blank", "title": "invoice.print" }
}

The resolution order is route.meta.layout, then config.ui.layout, then classic.

ts
import { useLayout } from '@/layouts/useLayout'

const { resolvedLayout, setLayout } = useLayout()

setLayout writes the persisted choice and accepts only the four config layouts; auth and blank are route-meta only.

Layouts are imported eagerly, not lazily. A layout the reader can switch from the settings drawer has to change instantly, and a loading flash on every switch would be worse than the bytes.

Responsive behavior ​

One composable decides what "mobile" means, and everything in the shell reads it.

WidthSidebar
Below 768pxNot in the layout at all. An off-canvas drawer replaces it.
768 to 1023pxPresent, forced to the icon rail.
1024px and upHonors ui.sidebarStyle.

The tablet rail is derived, never written. Writing sidebarStyle: 'collapsed' on a resize would overwrite the reader's stored desktop preference and survive the reload — which is the bug that rule exists to prevent.

ts
import { useResponsiveLayout } from '@/layouts/useResponsiveLayout'

const {
  isMobile,
  isTablet,
  isDesktop,
  mobileNavOpen,
  openMobileNav,
  closeMobileNav,
  railFor,
  toggleSidebar,
} = useResponsiveLayout()
MemberDescription
isMobileBelow 768px.
isTablet768 to 1023px.
isDesktop1024px and up.
mobileNavOpenThe drawer's state, module-scope, so the navbar's hamburger and the drawer are the same switch whichever layout composed them.
openMobileNav / closeMobileNavOpen and close it.
railFor(configuredCollapsed)Whether the sidebar should render as the icon rail right now.
toggleSidebar()On desktop with a sidebar, flips ui.sidebarStyle. Anywhere else, toggles the drawer.

t then s toggles the sidebar from the keyboard.

KeyValuesEffect
ui.sidebarPositionleft, rightWhich side it sits on, independently of reading direction.
ui.sidebarStyleexpanded, collapsed, floatingIts shape at 1024px and up.
ui.navbarStylesticky, staticWhether the navbar scrolls away with the content.

In a right-to-left layout the grid mirrors on its own. sidebarPosition is a separate, direction-independent override, so an Arabic reader who wants the sidebar on the left can have it.

The scroll container ​

The window never scrolls in this shell. Each layout scrolls its own column, marked data-sa-scroll-root, which is what the sticky navbar anchors to. The router's scrollBehavior scrolls that element rather than the window, and caches each route's offset so the browser back button lands where the reader left.

This matters when you write a page: a position: fixed element is positioned against the viewport, not that column, and window.scrollTo does nothing.

Slots ​

classic and modern take the shared set: #sidebar, #navbar, #footer and the default slot. auth takes #brand, the default slot and #footer.

Every layout wraps its content in an ErrorBoundary, so a page that throws shows a recoverable panel instead of a blank shell.

Where to go next ​