Layout Presets
Six shell arrangements, all of them CSS. How they work, and how to add a seventh.
The presets
| Preset | Rail | Notes |
|---|---|---|
| sidebar | 260px | The default. Collapsible to 68px; the collapsed state persists. |
| sidebar-mini | 68px | Icon rail that expands over the content on hover and on keyboard focus. |
| sidebar-compact | 88px | Label stacked under the icon. |
| sidebar-float | 260px | Inset from the viewport edge, rounded and elevated. Collapsible. |
| topnav | none | Groups become dropdowns in a bar; the brand moves into the header. |
| topnav-hybrid | 68px | The bar plus a permanent icon rail. |
Compare them side by side at /layouts. Below thelgbreakpoint every preset falls back to the same off-canvas drawer, so these are desktop distinctions.
How a preset is applied
Choosing one writes a single class to <html>. Nothing in the shell branches on the layout mode, so switching re-renders nothing and loses no form state.
import { useSidebar } from "@dashboardpack/core/providers/sidebar-context";
const { layout, setLayout } = useSidebar();
setLayout("sidebar-mini"); // adds class="layout-sidebar-mini" to <html>That is not a stylistic preference. This template is statically exported, so every page is prerendered with the default preset while the client reads the saved one from localStorage. Deriving classNames from React state was a genuine hydration mismatch — and suppressHydrationWarning sits on <html>, so it never covered the shell.
The two width variables
One preset needs them to disagree, which is why there are two.
--app-sidebar-rail-w /* the width the rail RENDERS at */
--app-sidebar-w /* the width it RESERVES — the content's offset */They derive from each other by default, so a preset that only changes the rail width states one thing. sidebar-mini breaks the link deliberately: hovering overrides the rail width on the aside, where the content element cannot inherit it, so the rail expands over the content without shifting it. sidebar-float breaks it the other way — the reserved space also has to cover the gutter on both sides.
Adding a preset
Three steps, two of which the compiler enforces.
// 1. Core: add the mode. This widens LayoutMode and gives it a class.
// node_modules/@dashboardpack/core/lib/layout/modes.ts
export const LAYOUT_MODES = [..., "sidebar-dual"] as const;
// 2. This template: name and describe it. src/lib/layouts.ts is a
// Record<LayoutMode, LayoutPreset>, so omitting this is a COMPILE ERROR.
"sidebar-dual": {
mode: "sidebar-dual",
label: "Dual",
description: "An icon column driving a second panel.",
detail: "…",
rail: "narrow",
topbar: false,
floating: false,
},
// 3. globals.css: the appearance.
html.layout-sidebar-dual {
--app-sidebar-rail-w: var(--app-sidebar-rail-narrow);
}Step 3 is the one a type cannot check — a preset with no CSS is not a build failure, just a picker entry that does nothing. So src/lib/layouts.test.ts parses globals.css and asserts a rule exists for every preset the union allows. It also asserts no .sidebar-collapsed rule is left unscoped, because that flag outlives the preset that set it: unscoped, it shrinks sidebar-compact to 68px for anyone who has ever collapsed their sidebar.
Watch out for
- Match class names exactly.
layout-sidebaris a prefix oflayout-sidebar-mini, andlayout-topnavoflayout-topnav-hybrid. AstartsWithor a[class^="layout-sidebar"]selector resolves all four rail presets to the plain one. - Validate the saved value in your pre-paint script. The common shape —
l === "topnav" ? … : "sidebar"— coerces a savedsidebar-minito the default and flashes on every load. InterpolateLAYOUT_MODESrather than retyping the list.