For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Settings Dialog

Destinations down the left, one panel on the right: a rail you can walk with the arrow keys, a panel that fades in, and the row, section and card the panel is built from.

"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

Title and descriptionThe description is a second line, so the title reads alone.
No controlThe control is optional — a row can just explain something.
A fieldAny control fits: a field, a button, a menu, a switch.
"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

PropDefaultMeaning
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
defaultActiveIdfirst itemWhere an uncontrolled modal opens
onActiveChange—Called with the id the user lands on
open—Controlled visibility
defaultOpenfalseOpens 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

settings-dialog.tsx
"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>
);
types.ts
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";
}