Settings Layout
The two-pane Settings frame — a sticky section nav and one 960px column where forms cap at 720px and lists fill; on a phone the nav comes first.
- Status
- Since
0.25.87- Accessibility pattern
- nav landmark; aria-current page; one pane when narrow
Last updated
Profile
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Settings Layout before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify settings-layoutInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/settings-layoutRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
Usage
"use client"; // app/settings/settings-frame.tsx — it reads the path
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
SettingsLayout,
SettingsLayoutContent,
SettingsNav,
} from "@/components/ui/settings-layout";
const SECTIONS = [
{
label: "Account",
items: [
{ href: "/settings/profile", label: "Profile" },
{ href: "/settings/notifications", label: "Notifications" },
],
},
{
label: "Workspace",
items: [{ href: "/settings/members", label: "Members", badge: 3 }],
},
];
// app/settings/layout.tsx renders this around every settings page.
export function SettingsFrame({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<SettingsLayout
view={pathname === "/settings" ? "nav" : "content"}
backHref="/settings"
linkRender={<Link href="" />}
nav={
<SettingsNav
sections={SECTIONS}
activeHref={pathname}
linkRender={<Link href="" />}
/>
}
>
{children}
</SettingsLayout>
);
}
// Each page:
<SettingsLayoutContent kind="form">…</SettingsLayoutContent>;Settings is a place people browse sideways, so it has one frame: the section nav in a 220px
column that sticks below the page's top edge, and every page in ONE column of up to 960px beside
it. A page says what it is with SettingsLayoutContent kind: form caps it at 720px, list
fills the column — and both start at the same x, so moving between Profile and Members never moves
the left edge. Put it inside an AppShellPage at its default width (1280px), which holds both
panes; the layout owns its own geometry inside whatever width it gets.
Scope
- Owns: the two panes, their widths and gap, the sticky nav, the form and list measures, the current-page mark, the narrow one-pane mode and its back link.
- Does not own: routing — links render through
linkRender, and the host passes the current path (activeHref) and which pane a narrow layout shows (view). - Compose with:
PageHeaderandSettingsSection/SettingsRowinside a form page;DataListorMemberListinside a list page.
Anatomy
Examples
Two panes
The nav marks the current page with aria-current="page" and the accent fill — a nested path
(/settings/members/42) marks its section — and an item's badge shows a count. Choose Members to
see a list page fill the column, then a form page cap at 720px on the same left edge.
Profile
On a phone
The layout follows its own width (@container/settings-layout), not the viewport. Below 42rem it
shows one pane at a time, as iOS does: view="nav" makes the nav the screen — its rows grow to
44px touch rows with a chevron — and view="content" shows the page with a "‹ Settings" link to
backHref. The Settings index route passes nav; every page passes content. At 42rem and wider
both panes always show, whatever view says, so /settings can show the first page beside the
nav on a desktop.
Profile
API Reference
SettingsLayout
| Prop | Type | Default | Description |
|---|---|---|---|
nav* | React.ReactNode | — | The section nav — a SettingsNav. |
backHref | string | — | Where the narrow layout's back link goes — the Settings index that shows the nav. Omit to show no back link. |
backLabel | string | "Settings" | The back link's text, after a "‹". |
linkRender | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | <a /> | The element the back link renders as — your router's link (<Link />). |
view | "content" | "nav" | "content" | Which pane shows while the layout is narrower than 42rem (a phone): nav — the section list
is the screen (the Settings index); content — the page, with a back link to the nav. At
42rem and wider both panes always show. |
Data attributes and CSS variables on SettingsLayout
| Attribute | Values |
|---|---|
data-slot | "settings-layout" | "settings-layout-main" | "settings-layout-nav" |
data-view | mirrors a prop or state value |
SettingsLayoutContent
| Prop | Type | Default | Description |
|---|---|---|---|
kind | "form" | "list" | "form" | The page's measure in the 960px column: form — capped at 720px and left-aligned (sections of
settings rows); list — the whole column (a members table, a list of attributes). |
Data attributes and CSS variables on SettingsLayoutContent
| Attribute | Values |
|---|---|
data-kind | mirrors a prop or state value |
data-slot | "settings-layout-content" |
SettingsNav
| Prop | Type | Default | Description |
|---|---|---|---|
sections* | readonly SettingsNavSection[] | — | The groups of pages, in order. |
activeHref | string | — | The current path. The item it names is current, else its nearest ancestor — the longest href
it sits under (/settings/teams/42 marks Teams) — so only one item is ever current. Ignored
when isActive is given. |
aria-label | string | "Settings" | The nav landmark's accessible name. |
isActive | ((item: SettingsNavItem) => boolean) | prefix match on `activeHref` | Decides which item is current, for routing that activeHref cannot express. |
linkRender | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | <a /> | The element each item renders as — your router's link (<Link />). The item's href,
classes, aria-current and children are merged into it. |
Data attributes and CSS variables on SettingsNav
| Attribute | Values |
|---|---|
data-slot | "settings-nav" | "settings-nav-badge" | "settings-nav-section" | "settings-nav-section-label" |
Each section and item:
| Prop | Type | Default | Description |
|---|---|---|---|
items* | readonly SettingsNavItem[] | — | The pages in this group, in order. |
label | React.ReactNode | — | The group's label, shown above its items and naming their list. Omit for an unlabelled group. |
| Prop | Type | Default | Description |
|---|---|---|---|
href* | string | — | Where the item goes; also what activeHref is compared with. |
label* | React.ReactNode | — | The item's name. |
badge | React.ReactNode | — | A count or a short status after the name ("3", "New") — part of the link's accessible name. A number renders as a muted tabular count; any other node renders as given. |
Accessibility
SettingsNavis anavlandmark named "Settings" (aria-label); each group is a list named by its label, and the current page's link carriesaria-current="page".- A count badge is part of its link's name ("Members 3"); pass a word rather than a bare number when the count needs context.
- Below 42rem the hidden pane is
display: none, out of the tab order and the accessibility tree; the back link is a real link to the nav screen. - Section labels are not headings, so the page's own
h1stays the first heading in the outline.
| Key | Action |
|---|---|
| Tab | Moves through the nav links, then the page |
| Enter | Opens the focused settings page |
| Contract | States tested |
|---|---|
| Behaviour | default, current-page, badge, nav-view, content-view, back-link |
| Accessibility | landmark, labeled, current-page, browser-accessibility-test |
| Visual | default, hover, current, narrow, wide |