"use client";
import {Installation
$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/settings-dialog.json
Usage
The rail takes data; the panel takes anything. Give the rail an id, label and
optionally an icon per destination, then render the panel for the active id — pass a
function as children and it receives that id, so the modal can own the selection
without giving up control of the panel.
import { Building2, Cpu } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
SettingsDialog,
SettingsRow,
SettingsSection,
} from "@/components/settings-dialog";
const NAV = [
{ items: [{ id: "workspace", label: "Workspace", icon: Building2 }] },
{
id: "agent",
label: "Agent",
items: [{ id: "models", label: "Models", icon: Cpu }],
},
];
export function Preferences() {
return (
<SettingsDialog nav={NAV} title="Settings">
{(id) =>
id === "workspace" ? (
<SettingsSection label="Identity">
<SettingsRow title="Slug" description="Members sign in here.">
<Button size="sm" variant="outline">
Change
</Button>
</SettingsRow>
</SettingsSection>
) : (
<Models />
)
}
</SettingsDialog>
);
}activeId and onActiveChange make the selection controlled when something outside the
modal owns it — a deep link, a command palette entry, a first-run step. On its own the
modal keeps the selection and the first destination opens.
The rail is a tablist
Switching a panel is what tabs do, so the rail is one: role="tablist",
aria-orientation="vertical", one tab per destination, and the panel wears
role="tabpanel" with the active tab as its label. The rail is a single tab stop — the
selected destination holds tabIndex={0} and the rest sit at -1 — so Tab
walks past the whole rail instead of through every word in it.
↑ ↓ walk the destinations, ← → do
the same below md where the rail turns into a horizontal strip, and
Home /End jump to the ends. Selection follows focus, which
is what a settings rail should do: the panel is the answer to the tab you just
landed on, and waiting for
Enter would show a rail pointing one way and a panel reading another.
The rail is a plain list of buttons with two backgrounds and no shared-layout indicator
sliding between them. The selected destination wears bg-muted; every other one answers
the pointer with a lighter bg-muted/50 and full-strength text.
An animated pill is the tempting default for a rail, and it was the first thing this component did — it glided from row to row on a spring. It earns its keep in a segmented control, where the stops are adjacent and the point is that the choice moved. A rail is the opposite case: the travel crosses the labels of the rows in between, and the distance is a couple of rows, so the glide is mostly a way of making a static answer look busy.
Hover, on the other hand, is asked a question — "am I about to click this?" — and a background answers it better than a colour change, because the rail is already using colour for selection. Two greys, two jobs: the darker one is where you are, the lighter one is where you are pointing.
One panel at a time
Only the active panel is mounted. Settings panels hold text fields, uploads and test connections — mounting nine of them so eight can hide is work nobody asked for, and it makes stale form state the caller's problem. The cost is that a panel loses its local state when you walk away from it, which is the behaviour most settings screens want.
Panels fade in and the new one starts at the top: a panel is a place you arrive at, not a
direction you travel in, and landing halfway down the last panel's scroll is
disorienting. There is no exit animation to wait on — only the incoming panel moves, so
the click lands on the next panel in one beat instead of two. The fade is skipped
entirely under prefers-reduced-motion.
Rows, sections and cards
The panel is not a mystery box. SettingsSection is a muted label above a SettingsCard;
SettingsRow is a title, an optional description, and a control at the trailing edge.
Both are exported, so a surface that outgrows the modal — an inline preferences page, a
drawer, a full-screen wizard — reuses the same rows and stays visually identical.
Anatomy of a row
"use client";
import {A row's title is a name, not a subtitle: it stays on one line and reads on its own, with
the description under it. A row with no control is a legitimate way to explain something
without asking for anything, and a section with no label continues the section above
it — use that when the heading would only repeat the destination you are already on.
Cards are divide-y containers, so rows separate themselves, and the card owns the
horizontal padding: separators stop at the text column instead of running to the card's
edge and slicing the card into one slab per row. A plain block in a SettingsCard becomes
a summary block instead of a row, on that same column — which is how the run history card
in the demo is built.
That column is the whole panel's, not the card's alone. The section label above a card and
the panel's own title draw the same px-5, so every line of text in a panel — title,
section name, row title, description — is set on one axis, and the cards are the only
thing at the panel's gutter. A heading belongs with the words it introduces rather than
with the surface it sits on; two axes inside one panel read as two panels.
The rail's heading works the same way: the dialog's title is the opening group's
heading, so it wears what every other group heading wears — the same muted line at the
same left edge as Agent and Files. A rail is a list of labelled groups, and a title
set apart from them is a third kind of thing in a column that has room for two.
Narrow screens
Below md the rail stops being a column: it becomes a horizontally scrollable strip
above the panel, and the destinations that fit stay reachable without a hamburger. The
active destination is scrolled into view, so the rail never opens showing someone else's
selection.
Props
| Prop | Default | Meaning |
|---|---|---|
nav | — | Destinations in rail order; { id, label, icon? } inside optional labelled groups |
children | — | The panel, or a function that receives the active id |
title | "Settings" | Names the rail, and is the dialog's accessible name |
description | — | Dialog description for assistive tech, never drawn |
activeId | — | Controlled active destination |
defaultActiveId | first item | Where an uncontrolled modal opens |
onActiveChange | — | Called with the id the user lands on |
open | — | Controlled visibility |
defaultOpen | false | Opens on mount |
onOpenChange | — | Called when the dialog opens or dismisses |
panelClassName | — | Extra classes for the scrolling panel body |
SettingsSection takes label and cardClassName; SettingsRow takes title,
description, and align ("center" or "start") for controls taller than one line.
Component source
"use client";
import { XIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import type { KeyboardEvent } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import type {
SettingsCardProps,
SettingsDialogProps,
SettingsNavGroup,
SettingsNavItem,
SettingsRowProps,
SettingsSectionProps,
} from "./types";
export type {
SettingsCardProps,
SettingsDialogProps,
SettingsNavGroup,
SettingsNavItem,
SettingsRowProps,
SettingsSectionProps,
} from "./types";
/** The rail is a tablist, so arrows are handled by hand. Left and right are accepted
* alongside up and down: below `md` the rail turns into a horizontal strip, and a key
* that moves the way the eye moves costs nothing. */
const NEXT_KEYS = new Set(["ArrowDown", "ArrowRight"]);
const PREVIOUS_KEYS = new Set(["ArrowUp", "ArrowLeft"]);
/** A panel fades in rather than sliding: a section is a place, not a direction. Only the
* incoming panel moves — running an exit first would put half of this duration between
* the click and the answer. */
const PANEL_FADE = { duration: 0.14, ease: EASE_OUT } as const;
/** The one column the panel's text is set on. Inside the panel's own gutter this padding
* lands a heading on the same axis as the rows it introduces: the card draws it for its
* rows, the section label above a card draws it, and so does the panel's title. The cards
* keep their edges — a heading names what is under it rather than the surface it sits on,
* so it belongs with the words, and a panel of text on two axes reads as two panels. */
const CONTENT_COLUMN = "px-5";
interface SettingsTabProps {
item: SettingsNavItem;
selected: boolean;
tabId: string;
panelId: string;
onSelect: (id: string) => void;
registerTab: (id: string, node: HTMLButtonElement | null) => void;
}
const SettingsTab = ({
item,
selected,
tabId,
panelId,
onSelect,
registerTab,
}: SettingsTabProps) => {
const Icon = item.icon;
return (
<button
aria-controls={selected ? panelId : undefined}
aria-selected={selected}
className={cn(
"focus-visible:ring-ring/50 flex shrink-0 items-center gap-3 rounded-lg px-3 py-2 text-left text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px]",
selected
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
id={tabId}
onClick={() => onSelect(item.id)}
onFocus={() => onSelect(item.id)}
ref={(node) => registerTab(item.id, node)}
role="tab"
tabIndex={selected ? 0 : -1}
type="button"
>
{Icon ? <Icon className="size-4 shrink-0" /> : null}
<span>{item.label}</span>
</button>
);
};
/**
* A settings surface: destinations down the left, one panel on the right.
*
* The rail owns the selection and the panel is whatever you put there, so the component
* never has to know what a "setting" is. Rows, cards and sections are exported alongside
* it — the same pieces the panel is built from — so a surface that outgrows the modal can
* reuse the layout without the dialog.
*/
export const SettingsDialog = ({
nav,
children,
className,
open,
defaultOpen,
onOpenChange,
activeId,
defaultActiveId,
onActiveChange,
title = "Settings",
description,
panelClassName,
}: SettingsDialogProps) => {
const baseId = useId();
const reduceMotion = useReducedMotion() ?? false;
const scrollRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const items = useMemo(() => nav.flatMap((group) => group.items), [nav]);
const [uncontrolled, setUncontrolled] = useState(defaultActiveId ?? "");
const requested = activeId ?? uncontrolled;
const current = items.find((item) => item.id === requested) ?? items[0];
const currentId = current?.id ?? "";
const select = useCallback(
(id: string) => {
if (activeId === undefined) {
setUncontrolled(id);
}
onActiveChange?.(id);
},
[activeId, onActiveChange]
);
const registerTab = useCallback(
(id: string, node: HTMLButtonElement | null) => {
if (node) {
tabRefs.current.set(id, node);
return;
}
tabRefs.current.delete(id);
},
[]
);
/** A panel is read from the top; landing halfway down the last one is disorienting. */
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 });
}, [currentId]);
/** Keeps the active destination on screen when the rail is a long column or a strip. */
useEffect(() => {
tabRefs.current
.get(currentId)
?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [currentId]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const ids = items.map((item) => item.id);
if (ids.length === 0) {
return;
}
let step = 0;
if (NEXT_KEYS.has(event.key)) {
step = 1;
} else if (PREVIOUS_KEYS.has(event.key)) {
step = -1;
}
const index = ids.indexOf(currentId);
let next: number | undefined;
if (step !== 0) {
next = (index + step + ids.length) % ids.length;
} else if (event.key === "Home") {
next = 0;
} else if (event.key === "End") {
next = ids.length - 1;
}
if (next === undefined) {
return;
}
event.preventDefault();
const id = ids[next];
select(id);
tabRefs.current.get(id)?.focus();
},
[currentId, items, select]
);
const panel = typeof children === "function" ? children(currentId) : children;
return (
<Dialog defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>
<DialogContent
className={cn(
"flex h-[min(46rem,88dvh)] w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl md:grid md:grid-cols-[16rem_1fr] md:grid-rows-[minmax(0,1fr)]",
className
)}
showCloseButton={false}
{...(description ? {} : { "aria-describedby": undefined })}
>
<aside className="border-border/60 flex shrink-0 flex-col border-b md:h-full md:min-h-0 md:border-r md:border-b-0">
{/* The dialog's name is also the opening group's heading, so it wears what
every other group heading in the rail wears: same muted line, same left
edge. A title over a list of labelled groups reads as a fourth kind of
thing in a rail that already has two. */}
<DialogTitle className="max-md:sr-only text-muted-foreground shrink-0 px-3 pt-6 pb-1 text-xs font-medium">
{title}
</DialogTitle>
<div
aria-label={title}
aria-orientation="vertical"
className="flex gap-1 overflow-x-auto p-3 md:min-h-0 md:flex-1 md:flex-col md:overflow-x-hidden md:overflow-y-auto"
onKeyDown={handleKeyDown}
role="tablist"
>
{nav.map((group, index) => (
<div
className="flex items-center gap-1 md:flex-col md:items-stretch"
key={group.id ?? group.label ?? index}
>
{group.label ? (
<div className="text-muted-foreground hidden px-3 pt-6 pb-1 text-xs font-medium md:block">
{group.label}
</div>
) : null}
{group.items.map((item) => (
<SettingsTab
item={item}
key={item.id}
onSelect={select}
panelId={`${baseId}-panel`}
registerTab={registerTab}
selected={item.id === currentId}
tabId={`${baseId}-tab-${item.id}`}
/>
))}
</div>
))}
</div>
</aside>
<div
aria-labelledby={`${baseId}-tab-${currentId}`}
className="flex min-h-0 flex-1 flex-col outline-none"
id={`${baseId}-panel`}
role="tabpanel"
tabIndex={-1}
>
<header className="flex shrink-0 items-center justify-between gap-4 px-5 pt-5 pb-3 sm:px-6 md:pt-6 md:pb-5">
<h2
className={cn("truncate text-lg font-semibold", CONTENT_COLUMN)}
>
{current?.label}
</h2>
<DialogClose className="bg-muted text-muted-foreground hover:bg-muted/70 hover:text-foreground focus-visible:ring-ring/50 -mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded-full outline-none transition-colors focus-visible:ring-[3px]">
<XIcon className="size-4" />
<span className="sr-only">Close {title}</span>
</DialogClose>
</header>
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-8 sm:px-6",
panelClassName
)}
ref={scrollRef}
>
{reduceMotion ? (
<div className="flex flex-col gap-6">{panel}</div>
) : (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="flex flex-col gap-6"
initial={{ opacity: 0, y: 3 }}
key={currentId}
transition={PANEL_FADE}
>
{panel}
</motion.div>
)}
</div>
</div>
{description ? (
<DialogDescription className="sr-only">
{description}
</DialogDescription>
) : null}
</DialogContent>
</Dialog>
);
};
/**
* The surface a run of rows sits on. The horizontal padding belongs to the card rather
* than to the row: a `divide-y` line is drawn at the box of the element it separates, so
* padding on the row runs every separator to the card's edge and cuts one card into a
* slab per row. Held here, the lines stop where the text does and the card stays whole.
*/
export const SettingsCard = ({ className, children }: SettingsCardProps) => (
<div
className={cn(
"divide-border/60 bg-muted/50 divide-y rounded-2xl",
CONTENT_COLUMN,
className
)}
>
{children}
</div>
);
/** A card with the section's name above it, on the card's content column. */
export const SettingsSection = ({
children,
className,
label,
cardClassName,
}: SettingsSectionProps) => (
<section className={cn("flex flex-col gap-2", className)}>
{label ? (
<h3
className={cn(
"text-muted-foreground text-sm font-medium",
CONTENT_COLUMN
)}
>
{label}
</h3>
) : null}
<SettingsCard className={cardClassName}>{children}</SettingsCard>
</section>
);
/**
* One setting: what it is on the left, what changes it on the right. The title stays
* legible on its own because the description is a second line, not a subtitle — a row
* read at a glance should be the setting's name. It draws no horizontal padding of its
* own, so its box is the card's content box and the separator above it lands on the same
* column; `SettingsCard` holds the padding for every child, row or not.
*/
export const SettingsRow = ({
title,
description,
children,
className,
align = "center",
}: SettingsRowProps) => (
<div
className={cn(
"flex justify-between gap-6 py-4",
align === "start" ? "items-start" : "items-center",
className
)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">{title}</span>
{description ? (
<span className="text-muted-foreground text-sm text-balance">
{description}
</span>
) : null}
</div>
{children ? (
<div className="flex shrink-0 items-center">{children}</div>
) : null}
</div>
);import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
/** One destination in the rail. `id` is what the panel is keyed by. */
export interface SettingsNavItem {
id: string;
label: string;
icon?: LucideIcon;
}
/** A labelled run of destinations. Leave `label` off for the opening block. */
export interface SettingsNavGroup {
id?: string;
label?: string;
items: SettingsNavItem[];
}
export interface SettingsDialogProps {
/** The rail, top to bottom. The first item is active until told otherwise. */
nav: SettingsNavGroup[];
/** The panel for the active destination — a node, or a function that receives its id. */
children: ReactNode | ((id: string) => ReactNode);
className?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Controlled active destination. Pair with `onActiveChange`. */
activeId?: string;
defaultActiveId?: string;
onActiveChange?: (id: string) => void;
/** Names the rail, and is the dialog's accessible name. */
title?: string;
/** Read by assistive tech, never drawn. */
description?: string;
/** Extra classes for the scrolling panel body. */
panelClassName?: string;
}
export interface SettingsCardProps {
className?: string;
children: ReactNode;
}
export interface SettingsSectionProps {
children: ReactNode;
className?: string;
/** Sits above the card; sections without one read as a continuation of the last. */
label?: string;
cardClassName?: string;
}
export interface SettingsRowProps {
title: ReactNode;
description?: ReactNode;
/** The control, pinned to the trailing edge. */
children?: ReactNode;
className?: string;
/** Top-align the control with the title, for controls taller than one line. */
align?: "center" | "start";
}