User settingspage
Profile, account and notifications in the app shell, with a settings nav on the left.
Pages- Preview
- /preview/settings
- Guidance
- draft · v0.1.0
Preview
The real page, full screen, in a frame. Switch width here and mode at the foot of the side nav.
Guidance
Rules
- A settings nav on the left lists the sections – Profile, Account, Notifications … – each its own route (
/settings/profile). It takes more sections than tabs would, and the current one hasaria-current="page". - Each section opens with its name and one line on what's in it.
- Each group follows the Settings section pattern: title and description on the left, fields on the right.
- Say how it saves: text fields save per group with a button; switches save at once.
- Changes that need confirming (a new email) say so under the field.
- Delete account is last, in Account, behind an Alert dialog that says what will be lost.
Layout
- Inside the app shell, with Settings active in the side nav.
- From
mdthe settings nav is a column beside the content; belowmdit's a row above it that scrolls sideways. - Groups stack on narrow screens: title above fields.
Built from
Code
Copy it into a product and change the words; the layout and tokens carry over.
app/preview/settings/page.tsx
import type * as React from "react";
import { Text } from "@/registry/jsp/text";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/registry/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/registry/ui/avatar";
import { Badge } from "@/registry/ui/badge";
import { Button } from "@/registry/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/registry/ui/field";
import { Input } from "@/registry/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/ui/select";
import { Switch } from "@/registry/ui/switch";
import { Bell, ShieldCheck, UserRound } from "lucide-react";
import { AppShell } from "../_shell";
import { SettingsLayout } from "./settings-layout";
export const metadata = { title: "Settings preview – jsp-ds" };
/** A settings group: what it is on the left, its fields on the right (the Settings section pattern). */
function Group({
id,
title,
description,
children,
}: {
id: string;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<section
aria-labelledby={id}
className="grid grid-cols-1 gap-component-xl border-b border-border-subtle py-layout-sm first:pt-0 last:border-b-0 xl:grid-cols-3"
>
<div className="flex flex-col gap-component-xs">
<Text as="h3" id={id} textStyle="heading-5">
{title}
</Text>
<Text textStyle="body-sm" color="subtle">
{description}
</Text>
</div>
<div className="flex flex-col gap-component-lg xl:col-span-2">
{children}
</div>
</section>
);
}
/**
* User settings inside the app shell, with a left settings nav: Profile, Account, Notifications. Text fields save
* with a button per group; switches save as they change; the destructive action is last, behind an Alert dialog.
*/
export default function SettingsPage() {
return (
<AppShell current="Settings">
<div className="flex flex-col gap-component-xs">
<Text as="h1" textStyle="heading-2">
Settings
</Text>
<Text color="subtle">Your profile, sign-in and notifications.</Text>
</div>
<SettingsLayout
sections={[
{
id: "profile",
label: "Profile",
icon: <UserRound />,
description: "How you appear to people you invite.",
content: (
<>
<form>
<Group
id="settings-photo"
title="Photo"
description="Shown beside your comments."
>
<div className="flex items-center gap-component-md">
<Avatar className="size-control-lg">
<AvatarFallback>JP</AvatarFallback>
</Avatar>
<Button variant="outline" type="button">
Upload photo
</Button>
<Button variant="ghost" type="button">
Remove
</Button>
</div>
</Group>
<Group
id="settings-profile"
title="Details"
description="Shown to people you invite to review."
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="settings-name">Name</FieldLabel>
<Input
id="settings-name"
autoComplete="name"
defaultValue="Jay Price"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-role">Role</FieldLabel>
<Input
id="settings-role"
defaultValue="Service designer"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-tz">Time zone</FieldLabel>
<Select defaultValue="kl">
<SelectTrigger id="settings-tz" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="london">London (GMT)</SelectItem>
<SelectItem value="kl">
Kuala Lumpur (GMT+8)
</SelectItem>
</SelectContent>
</Select>
</Field>
</FieldGroup>
<div className="flex gap-component-xs">
<Button type="submit">Save profile</Button>
</div>
</Group>
</form>
</>
),
},
{
id: "account",
label: "Account",
icon: <ShieldCheck />,
description: "Email, password and where you're signed in.",
content: (
<>
<Group
id="settings-email"
title="Email"
description="Where sign-in links and notifications go."
>
<Field>
<FieldLabel htmlFor="settings-email-input">
Email
</FieldLabel>
<Input
id="settings-email-input"
type="email"
autoComplete="email"
defaultValue="jay@example.com"
/>
<FieldDescription>
We’ll send a link to confirm a new address.
</FieldDescription>
</Field>
<div>
<Button>Change email</Button>
</div>
</Group>
<Group
id="settings-password"
title="Password"
description="At least 12 characters."
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="settings-current">
Current password
</FieldLabel>
<Input
id="settings-current"
type="password"
autoComplete="current-password"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-new">
New password
</FieldLabel>
<Input
id="settings-new"
type="password"
autoComplete="new-password"
/>
</Field>
</FieldGroup>
<div>
<Button>Update password</Button>
</div>
</Group>
<Group
id="settings-sessions"
title="Signed in"
description="Devices signed in to your account."
>
<ul className="flex flex-col">
{[
["MacBook Pro · Chrome", "Kuala Lumpur · now", true],
["iPhone · Safari", "Kuala Lumpur · 2 hours ago", false],
].map(([device, where, current]) => (
<li
key={String(device)}
className="flex items-center justify-between gap-component-md border-b border-border-subtle py-component-sm last:border-b-0"
>
<div className="flex flex-col">
<Text as="span" textStyle="label">
{device}
</Text>
<Text as="span" textStyle="caption" color="subtle">
{where}
</Text>
</div>
{current ? (
<Badge variant="secondary">This device</Badge>
) : (
<Button variant="outline" size="sm">
Sign out
</Button>
)}
</li>
))}
</ul>
</Group>
<Group
id="settings-delete"
title="Delete account"
description="Deletes your account and the reviews you own. This can't be undone."
>
<div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete account</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete your account?
</AlertDialogTitle>
<AlertDialogDescription>
Your 3 projects and their comments will be deleted
for everyone. You can’t undo this.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep account</AlertDialogCancel>
<AlertDialogAction variant="destructive">
Delete account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</Group>
</>
),
},
{
id: "notifications",
label: "Notifications",
icon: <Bell />,
description: "What we email you about.",
content: (
<>
<Group
id="settings-notify"
title="Email notifications"
description="Changes save as you make them."
>
<FieldGroup>
{[
[
"notify-comments",
"New comments",
"On screens you own.",
true,
],
[
"notify-signoff",
"Sign-off requests",
"When a client asks you to sign off.",
true,
],
[
"notify-digest",
"Weekly summary",
"Every Monday at 9:00.",
false,
],
].map(([id, label, help, on]) => (
<Field
key={String(id)}
orientation="horizontal"
className="justify-between"
>
<FieldContent>
<FieldLabel htmlFor={String(id)}>{label}</FieldLabel>
<FieldDescription>{help}</FieldDescription>
</FieldContent>
<Switch id={String(id)} defaultChecked={Boolean(on)} />
</Field>
))}
</FieldGroup>
</Group>
</>
),
},
]}
/>
</AppShell>
);
}app/preview/settings/settings-layout.tsx
"use client"
import * as React from "react"
import { cn } from "cn"
import { Text } from "@/registry/jsp/text"
export interface SettingsSection {
id: string
label: string
/** A rendered lucide icon, e.g. <UserRound />; sized here. */
icon: React.ReactNode
description: string
content: React.ReactNode
}
/**
* Settings with a left nav: one link per section, the current one marked with aria-current="page".
* In a product each link is its own route (/settings/profile …); here they switch in place so the preview
* works on its own. From md the nav is a column beside the content; below md it's a row that scrolls.
*/
export function SettingsLayout({ sections }: { sections: SettingsSection[] }) {
const [current, setCurrent] = React.useState(sections[0].id)
const heading = React.useRef<HTMLHeadingElement>(null)
const section = sections.find((s) => s.id === current) ?? sections[0]
return (
<div className="flex flex-col gap-layout-sm md:flex-row md:gap-layout-lg">
<nav aria-label="Settings" className="shrink-0 md:w-site-xs md:max-w-[30%]">
<ul className="-mx-component-xs flex gap-component-3xs overflow-x-auto px-component-xs pb-component-xs md:mx-0 md:flex-col md:overflow-visible md:px-0 md:pb-0">
{sections.map((s) => {
const active = s.id === current
return (
<li key={s.id} className="shrink-0">
<a
href={`#${s.id}`}
aria-current={active ? "page" : undefined}
onClick={(e) => {
e.preventDefault()
setCurrent(s.id)
requestAnimationFrame(() => heading.current?.focus())
}}
className={cn(
"flex h-control-sm items-center gap-component-xs rounded-control px-component-sm whitespace-nowrap text-text-subtle hover:bg-surface-muted hover:text-text-default focus-visible:outline-2 focus-visible:outline-focus-ring",
active && "bg-surface-muted text-text-default",
)}
>
<span aria-hidden className="flex [&_svg]:size-icon-sm">
{s.icon}
</span>
<Text as="span" textStyle="body-sm" color={active ? "default" : "subtle"} className={active ? "font-medium" : undefined}>
{s.label}
</Text>
</a>
</li>
)
})}
</ul>
</nav>
<div className="flex min-w-0 flex-1 flex-col gap-component-lg">
<div className="flex flex-col gap-component-3xs border-b border-border-subtle pb-component-lg">
<Text asChild textStyle="heading-4">
<h2 ref={heading} tabIndex={-1} className="outline-none">
{section.label}
</h2>
</Text>
<Text textStyle="body-sm" color="subtle">
{section.description}
</Text>
</div>
<div key={section.id}>{section.content}</div>
</div>
</div>
)
}