Settings layout
The two-column settings page: a section list beside the section you are editing.
A 220px nav column beside the content from 768px up, and the same sections as a labelled select below it. The nav is a real nav landmark named "Settings", and the section being shown is the current page rather than a highlighted div. Pass renderLink to route through your framework: renderLink={(item, children, className) => <Link href={item.href} className={className} aria-current={item.href === activeHref ? "page" : undefined}>{children}</Link>}. The layout also clones aria-current="page" onto the element it gets back for the active section, so a plain link is announced even without that attribute; set it yourself when renderLink returns something that is not a single element. onNavigate drives the small-screen select, and, when there is no renderLink, the default anchors too, so a single prop is enough to make both breakpoints move without a page load.
Install
npx shadcn@latest add @vibra/settings-layoutNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { BellIcon, CreditCardIcon, KeyRoundIcon, SettingsIcon, UsersIcon } from "lucide-react"
import { SettingsLayout, type SettingsNavItem } from "@/components/ui/settings-layout"
const NAV: SettingsNavItem[] = [
{ title: "General", href: "/settings", icon: <SettingsIcon /> },
{ title: "Members", href: "/settings/members", icon: <UsersIcon />, description: "12 seats" },
{ title: "Billing", href: "/settings/billing", icon: <CreditCardIcon /> },
{ title: "API keys", href: "/settings/api-keys", icon: <KeyRoundIcon /> },
{ title: "Notifications", href: "/settings/notifications", icon: <BellIcon /> },
]
const PANELS: Record<string, { heading: string; body: string }> = {
"/settings": {
heading: "Workspace",
body: "The name, region, and default currency every report in Northwind is written against.",
},
"/settings/members": {
heading: "Members",
body: "Twelve people can sign in. Owners can invite, remove, and change what everyone else may see.",
},
"/settings/billing": {
heading: "Billing",
body: "Pro, billed yearly. The next invoice of $8,900 is due 1 October 2026.",
},
"/settings/api-keys": {
heading: "API keys",
body: "Two live keys and one restricted key. Rotating a key takes effect immediately.",
},
"/settings/notifications": {
heading: "Notifications",
body: "Where deploy, usage, and billing alerts are delivered, and who receives them.",
},
}
export default function SettingsLayoutDemo() {
const [activeHref, setActiveHref] = React.useState("/settings/members")
const panel = PANELS[activeHref]
return (
<SettingsLayout
className="max-w-3xl"
nav={NAV}
activeHref={activeHref}
onNavigate={setActiveHref}
title="Workspace settings"
description="Everyone in Northwind Analytics works from these."
>
<section className="flex flex-col gap-2 rounded-lg border p-4">
<h3 className="text-sm font-medium">{panel.heading}</h3>
<p className="text-sm text-muted-foreground">{panel.body}</p>
</section>
</SettingsLayout>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| nav | SettingsNavItem[] | — | The sections, in the order to show them. |
| activeHref | string | — | The section being shown; the matching link is the current page. |
| title | React.ReactNode | — | A heading above both columns. |
| description | React.ReactNode | — | A quiet line under the heading. |
| children | React.ReactNode | — | The section itself, in the right-hand column. |
| renderLink | (item: SettingsNavItem, children: React.ReactNode, className: string) => React.ReactNode | a plain anchor | Swaps the anchor for a router link; it owns aria-current on its own element. |
| onNavigate | (href: string) => void | — | Called by the small-screen select, and by the default links when there is no renderLink. |
| SettingsNavItem.title | string | — | The section name, in both the nav and the select. |
| SettingsNavItem.href | string | — | Unique; compared against activeHref. |
| SettingsNavItem.icon | React.ReactNode | — | Sits before the title; sized to 4 unless it sets its own size. |
| SettingsNavItem.description | string | — | A quiet second line under the title in the nav column. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export type SettingsNavItem = {
title: string
href: string
/** Sits before the title; sized to 4 unless it sets its own size. */
icon?: React.ReactNode
/** A quiet second line under the title, for sections whose name is not enough. */
description?: string
}
export type SettingsLayoutProps = React.ComponentProps<"div"> & {
nav: SettingsNavItem[]
/** The section being shown; the matching link is the current page. */
activeHref: string
title?: React.ReactNode
description?: React.ReactNode
/**
* Swaps the plain anchor for a router link; it receives the item, its
* content, and its classes. The layout stamps aria-current="page" onto the
* element it gets back for the active section.
*/
renderLink?: (
item: SettingsNavItem,
children: React.ReactNode,
className: string
) => React.ReactNode
/** Called by the small-screen select, and by the default links when there is no renderLink. */
onNavigate?: (href: string) => void
/**
* True when the shell around this page already lists the same sections in
* its sidebar. The 220px column is then the same six links a second time,
* eight pixels from the first — so it is dropped and the page gets the width
* back. The small-screen Select stays either way: below md the sidebar is
* off-canvas, so it is the only section switcher on the page.
*/
sectionsInSidebar?: boolean
}
/**
* Stamps the current page onto a link the caller rendered. A router link that
* only took `className` would otherwise be highlighted but not announced, so
* the layout puts `aria-current` on it rather than trusting every caller to;
* a `renderLink` that returns something other than a single element — a
* Fragment included, since React drops every prop but `key` on one — has to set
* it itself.
*/
function markCurrent(node: React.ReactNode, active: boolean): React.ReactNode {
if (!active || !React.isValidElement(node) || node.type === React.Fragment) return node
type CurrentProps = {
"aria-current"?: React.AriaAttributes["aria-current"]
"data-active"?: string
}
return React.cloneElement(node as React.ReactElement<CurrentProps>, {
"aria-current": "page",
"data-active": "true",
})
}
const NAV_ITEM =
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground transition-colors duration-(--duration-fast) ease-(--ease-standard) focus-ring hover:bg-accent hover:text-foreground [&_svg]:size-4 [&_svg]:shrink-0"
/** The two-column settings page: a section list beside the section you are editing. */
function SettingsLayout({
className,
nav,
activeHref,
title,
description,
renderLink,
onNavigate,
sectionsInSidebar = false,
children,
...props
}: SettingsLayoutProps) {
return (
<div
data-slot="settings-layout"
className={cn("flex w-full flex-col gap-6", className)}
{...props}
>
{title || description ? (
<div data-slot="settings-layout-header" className="flex flex-col gap-1">
{title ? (
<h2 className="type-display text-xl text-foreground">{title}</h2>
) : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
) : null}
<div
data-sections-in-sidebar={sectionsInSidebar || undefined}
className={cn("grid gap-6 md:gap-8", !sectionsInSidebar && "md:grid-cols-[220px_1fr]")}
>
{/* The same sections as the nav, in the shape a phone can hold. */}
<Select value={activeHref} onValueChange={(value) => onNavigate?.(String(value))}>
<SelectTrigger aria-label="Settings section" className="w-full md:hidden">
<SelectValue>
{(value: string) => nav.find((item) => item.href === value)?.title ?? "Settings"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{nav.map((item) => (
<SelectItem key={item.href} value={item.href}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
<nav
aria-label="Settings"
data-slot="settings-layout-nav"
className={cn("hidden flex-col gap-0.5", !sectionsInSidebar && "md:flex")}
>
{nav.map((item) => {
const active = item.href === activeHref
const itemClassName = cn(
NAV_ITEM,
active && "bg-accent font-medium text-accent-foreground"
)
const content = (
<>
{item.icon}
<span className="min-w-0 flex-1">
<span className="block truncate">{item.title}</span>
{item.description ? (
<span className="block truncate text-xs font-normal text-muted-foreground">
{item.description}
</span>
) : null}
</span>
</>
)
if (renderLink) {
return (
<React.Fragment key={item.href}>
{markCurrent(renderLink(item, content, itemClassName), active)}
</React.Fragment>
)
}
return (
<a
key={item.href}
href={item.href}
data-slot="settings-layout-nav-item"
data-active={active || undefined}
aria-current={active ? "page" : undefined}
className={itemClassName}
// Without a renderLink these are plain anchors, so a caller
// that only passed onNavigate still gets client-side moves
// rather than a full page load.
onClick={
onNavigate
? (event) => {
event.preventDefault()
onNavigate(item.href)
}
: undefined
}
>
{content}
</a>
)
})}
</nav>
<div data-slot="settings-layout-content" className="min-w-0">
{children}
</div>
</div>
</div>
)
}
export { SettingsLayout }