App screens
Settings panel
The settings screen pattern, done the way real SaaS settings pages are actually built: horizontal Tabs across the top, each panel made of stacked two-column sections — a short label and description on the left, real FormFieldSet-driven controls on the right — separated by a Divider. Billing ends in a genuine sortable, multi-select Table for invoice history, not a static list.
How it's built
Every section follows the same shape — a `SettingsSection` helper renders a short label + description on the left (`@2xl:grid-cols-[13rem_1fr]`) and the real controls on the right, the layout Stripe- and Vercel-style settings pages use instead of a single long column of fields.
Billing history is a genuine `Table`, not a styled list: `selectionMode="multiple"` for the row checkboxes, `sortable: true` per column for the built-in client-side sort (no `sortDescriptor`/`onSortChange` needed for that), and a shared `RowMenu` Dropdown for the per-row kebab.
The "Send to the existing email" radio option is one `RadioOption` whose `label` is a two-line `ReactNode` (bold line + muted email below) — `RadioOption` only has `label`/`value`, no separate description field, so a composite label is the real way to get that look.
Confirm-password uses the built-in `matchField` validation rule (`validation={{ rules: { matchField: "password" } }}`) instead of a hand-rolled equality check.
Notifications default state comes from `Form`'s `initialValues`, not `defaultChecked` on each switch — inside a Form, switches are value-driven like any other field.
The sidebar and header are the same bounded-height app shell as analytics-dashboard: `overflow-hidden` on the outer wrapper, independent scroll on the sidebar, `shrink-0` on the header, so only the settings content itself scrolls — and every direct child of that scrollable body (including this Tabs block) carries `shrink-0` too, or a flex child's default `flex-shrink: 1` would silently compress it to fit instead of letting it overflow.
Source
One file · copy & pasteimport { type ReactNode, useState } from "react";
import { Avatar } from "oks-ui/avatar";
import { Button } from "oks-ui/button";
import { Chip } from "oks-ui/chip";
import { Divider } from "oks-ui/divider";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "oks-ui/dropdown";
import { Form, FormFieldSet } from "oks-ui/form-field-set";
import type { NavItemData } from "oks-ui/nav";
import { PageTitle } from "oks-ui/page-title";
import { Table } from "oks-ui/table";
import { Tab, Tabs } from "oks-ui/tabs";
import { PatternAppShell } from "../PatternAppShell";
import {
ArrowsRightLeftIcon,
ClockIcon,
CreditCardIcon,
GridIcon,
HelpCircleIcon,
MailIcon,
MessageCircleIcon,
MoreIcon,
PieChartIcon,
PlusIcon,
ReceiptIcon,
SearchIcon,
TrashIcon,
} from "../../showcase/icons";
const NAV_ITEMS: NavItemData[] = [
{
key: "main",
label: "Main menu",
isSection: true,
children: [
{ key: "dashboard", label: "Dashboard", icon: <GridIcon size={18} /> },
{ key: "analytics", label: "Analytics", icon: <PieChartIcon size={18} /> },
{ key: "transactions", label: "Transactions", icon: <ArrowsRightLeftIcon size={18} /> },
{ key: "invoices", label: "Invoices", icon: <ReceiptIcon size={18} /> },
],
},
{
key: "features",
label: "Features",
isSection: true,
children: [
{ key: "recurring", label: "Recurring", icon: <ClockIcon size={18} /> },
{ key: "feedback", label: "Feedback", icon: <MessageCircleIcon size={18} /> },
],
},
// Settings / Help desk / Log out live in the account menu at the bottom
// of the sidebar (opened from the profile footer), not as permanent nav
// rows — this page is reached from there, so nothing here is "active".
];
type InvoiceRow = {
id: string;
description: string;
date: string;
amount: string;
status: "Paid" | "Pending" | "Failed";
} & Record<string, unknown>;
const invoices: InvoiceRow[] = [
{ id: "INV-0042", description: "Pro plan — June", date: "Jun 1, 2026", amount: "$49.00", status: "Paid" },
{ id: "INV-0031", description: "Pro plan — May", date: "May 1, 2026", amount: "$49.00", status: "Paid" },
{ id: "INV-0025", description: "2 additional seats", date: "Apr 18, 2026", amount: "$18.00", status: "Pending" },
{ id: "INV-0020", description: "Pro plan — April", date: "Apr 1, 2026", amount: "$49.00", status: "Paid" },
{ id: "INV-0009", description: "Pro plan — March", date: "Mar 1, 2026", amount: "$49.00", status: "Failed" },
];
const statusTone: Record<InvoiceRow["status"], "success" | "warning" | "danger"> = {
Paid: "success",
Pending: "warning",
Failed: "danger",
};
// A shared kebab menu, reused on every invoice row.
function RowMenu({ label }: { label: string }) {
return (
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button isIconOnly aria-label={label} variant="ghost" radius="full" size="sm">
<MoreIcon size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label={label}>
<DropdownItem key="view">View invoice</DropdownItem>
<DropdownItem key="download">Download PDF</DropdownItem>
</DropdownMenu>
</Dropdown>
);
}
// The recurring shape of every settings section: a short label + description
// on the left, the actual controls on the right — the same layout Stripe,
// Vercel, and most real SaaS settings pages use, so a two-column section
// isn't just decorative here.
function SettingsSection({
title,
description,
children,
stacked = false,
}: {
title: string;
description: string;
children: ReactNode;
stacked?: boolean;
}) {
return (
<div className={stacked ? "flex flex-col gap-5" : "grid gap-6 @2xl:grid-cols-[13rem_1fr]"}>
<div>
<h3 className="text-[0.95rem] font-semibold text-ink">{title}</h3>
<p className="mt-1 text-[0.85rem] text-ink-muted">{description}</p>
</div>
<div className="min-w-0">{children}</div>
</div>
);
}
export function SettingsPanel() {
const [selected, setSelected] = useState<Set<string>>(new Set());
return (
<PatternAppShell
navItems={NAV_ITEMS}
headerLeft={
<FormFieldSet
type="search"
name="search"
label="Search"
placeholder="Search"
startIcon={<SearchIcon size={16} />}
endIcon={
<span className="flex items-center gap-1" aria-hidden="true">
<kbd className="rounded-[var(--r-sm)] bg-paper px-1.5 py-0.5 text-[0.68rem] font-medium text-ink-faint">⌘</kbd>
<kbd className="rounded-[var(--r-sm)] bg-paper px-1.5 py-0.5 text-[0.68rem] font-medium text-ink-faint">K</kbd>
</span>
}
classNames={{ base: "w-full max-w-xs", label: "sr-only", control: "border-transparent bg-paper-sunken" }}
/>
}
headerExtra={
<>
<Button isIconOnly aria-label="Help" variant="ghost" radius="full" size="sm">
<HelpCircleIcon size={18} />
</Button>
<Button isIconOnly aria-label="Messages" variant="ghost" radius="full" size="sm">
<MailIcon size={18} />
</Button>
</>
}
contentClassName="flex flex-1 flex-col gap-6 overflow-y-auto p-5 @3xl:p-8"
>
<PageTitle
as="h2"
title="Settings"
subtitle="Manage your account settings and preferences."
classNames={{
base: "flex-col shrink-0 items-start",
title: "text-2xl font-bold tracking-tight text-ink",
subtitle: "text-[0.9rem] text-ink-muted",
}}
/>
{/* Horizontal Tabs across the top (the classic settings-page
layout), each panel built from stacked two-column sections —
label + description on the left, real controls on the right
— separated by a Divider, the same pattern Stripe/Vercel-style
settings pages use. shrink-0 keeps this block at its natural
height inside the flex-col scroll body — without it, a flex
child defaults to flex-shrink:1 and gets silently compressed
to fit instead of overflowing, so the parent's overflow-y-auto
never sees anything to scroll. */}
<div className="shrink-0">
<Tabs aria-label="Settings sections" variant="underlined" classNames={{ tabList: "overflow-x-auto" }}>
<Tab key="general" title="General">
<div className="flex flex-col gap-8 py-6">
<SettingsSection title="Profile" description="Your name, photo, and how others see you.">
<Form onSubmit={(data) => console.log(data)} className="flex max-w-lg flex-col gap-5">
<div className="flex items-center gap-4">
<Avatar src="/avatar-omkar.jpg" name="Omkar Sahu" size="lg" radius="full" />
<FormFieldSet type="file" name="avatar" label="Profile photo" accept="image/*" classNames={{ base: "flex-1" }} />
</div>
<div className="grid gap-4 @xl:grid-cols-2">
<FormFieldSet type="text" name="name" label="Display name" defaultValue="Omkar Sahu" />
<FormFieldSet type="email" name="email" label="Email address" defaultValue="me@oks-ui.com" />
</div>
<FormFieldSet type="textarea" name="bio" label="Bio" rows={3} placeholder="A short introduction…" />
<div>
<Button type="submit" color="primary" radius="full">
Save changes
</Button>
</div>
</Form>
</SettingsSection>
<Divider />
<SettingsSection title="Preferences" description="Language and timezone for dates across the app.">
<Form onSubmit={(data) => console.log(data)} className="flex max-w-lg flex-col gap-5">
<div className="grid gap-4 @xl:grid-cols-2">
<FormFieldSet
type="select"
name="timezone"
label="Timezone"
defaultValue="ist"
options={[
{ label: "India Standard Time", value: "ist" },
{ label: "UTC", value: "utc" },
{ label: "US / Pacific", value: "pt" },
{ label: "Europe / London", value: "lon" },
]}
/>
<FormFieldSet
type="select"
name="language"
label="Language"
defaultValue="en"
options={[
{ label: "English", value: "en" },
{ label: "Hindi", value: "hi" },
{ label: "Spanish", value: "es" },
]}
/>
</div>
<div>
<Button type="submit" color="primary" radius="full">
Save changes
</Button>
</div>
</Form>
</SettingsSection>
</div>
</Tab>
<Tab key="notifications" title="Notifications">
<div className="flex flex-col gap-8 py-6">
<Form
onSubmit={(data) => console.log(data)}
initialValues={{ product: true, security: true, mentions: true }}
>
<SettingsSection title="Email" description="What we send to your inbox.">
<div className="flex max-w-lg flex-col gap-4">
<FormFieldSet type="switch" name="product" label="Product updates" description="New features and improvements." />
<FormFieldSet type="switch" name="digest" label="Weekly digest" description="A summary of your account activity." />
<FormFieldSet type="switch" name="security" label="Security alerts" description="Sign-ins from a new device or location." />
</div>
</SettingsSection>
<Divider className="my-8" />
<SettingsSection title="Push" description="Real-time alerts on your devices.">
<div className="flex max-w-lg flex-col gap-4">
<FormFieldSet type="switch" name="mentions" label="Mentions" description="Someone mentions you in a comment." />
<FormFieldSet type="switch" name="messages" label="Direct messages" description="New messages land in your inbox." />
</div>
</SettingsSection>
<div className="mt-8">
<Button type="submit" color="primary" radius="full">
Save changes
</Button>
</div>
</Form>
</div>
</Tab>
<Tab key="security" title="Security">
<div className="flex flex-col gap-8 py-6">
<SettingsSection title="Password" description="Change the password used to sign in.">
<Form onSubmit={(data) => console.log(data)} className="flex max-w-lg flex-col gap-4">
<FormFieldSet type="password" name="currentPassword" label="Current password" validation={{ rules: { required: true } }} />
<div className="grid gap-4 @xl:grid-cols-2">
<FormFieldSet type="password" name="password" label="New password" validation={{ rules: { required: true, minLength: 8 } }} />
<FormFieldSet
type="password"
name="confirmPassword"
label="Confirm new password"
validation={{ rules: { required: true, matchField: "password" } }}
/>
</div>
<div>
<Button type="submit" color="primary" radius="full">
Update password
</Button>
</div>
</Form>
</SettingsSection>
<Divider />
<SettingsSection title="Two-factor authentication" description="Require a one-time code from your phone when signing in.">
<FormFieldSet type="switch" name="twoFactor" label="Enabled" />
</SettingsSection>
<Divider />
<SettingsSection title="Danger zone" description="Permanently remove your account and all of its data.">
<div className="flex flex-col gap-3 rounded-[var(--r-md)] border border-[color:var(--oks-color-danger-200)] bg-[color:var(--oks-color-danger-50)] p-4">
<p className="text-[0.85rem] text-[color:var(--oks-color-danger-700)]">
This can't be undone. All of your data will be permanently deleted.
</p>
<div>
<Button color="danger" variant="bordered" size="sm" startContent={<TrashIcon size={14} />}>
Delete account
</Button>
</div>
</div>
</SettingsSection>
</div>
</Tab>
<Tab key="billing" title="Billing">
<div className="flex flex-col gap-8 py-6">
<div>
<h3 className="text-[0.95rem] font-semibold text-ink">Payment method</h3>
<p className="mt-1 text-[0.85rem] text-ink-muted">Update your billing details and card on file.</p>
</div>
<Divider />
<SettingsSection title="Card details" description="Update your billing details and address.">
<div className="flex max-w-lg flex-col gap-5">
<Form onSubmit={(data) => console.log(data)} className="flex flex-col gap-4">
<div className="grid gap-4 @xl:grid-cols-2">
<FormFieldSet type="text" name="cardName" label="Name on card" defaultValue="Omkar Sahu" />
<FormFieldSet type="text" name="expiry" label="Expiry" placeholder="MM / YYYY" defaultValue="02 / 2028" />
</div>
<div className="grid gap-4 @xl:grid-cols-2">
<FormFieldSet
type="text"
name="cardNumber"
label="Card number"
defaultValue="4242 4242 4242 4242"
startIcon={<CreditCardIcon size={16} />}
/>
<FormFieldSet type="password" name="cvv" label="CVV" defaultValue="123" />
</div>
</Form>
<div>
<Button variant="bordered" radius="full" size="sm" startContent={<PlusIcon size={14} />}>
Add another card
</Button>
</div>
</div>
</SettingsSection>
<Divider />
<SettingsSection title="Contact email" description="Where should invoices be sent?">
<FormFieldSet
type="radio"
name="invoiceEmail"
label="Invoice recipient"
classNames={{ label: "sr-only" }}
defaultValue="existing"
options={[
{
value: "existing",
label: (
<span className="flex flex-col">
<span className="font-medium text-ink">Send to the existing email</span>
<span className="text-[0.8rem] text-ink-muted">me@oks-ui.com</span>
</span>
),
},
{ value: "new", label: <span className="font-medium text-ink">Add another email address</span> },
]}
/>
</SettingsSection>
<Divider />
<div className="flex flex-col gap-4">
<div>
<h3 className="text-[0.95rem] font-semibold text-ink">Billing history</h3>
<p className="mt-1 text-[0.85rem] text-ink-muted">See every transaction on your account.</p>
</div>
<Table
aria-label="Billing history"
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={(keys) => setSelected(keys as Set<string>)}
classNames={{ cell: "whitespace-nowrap" }}
columns={[
{ key: "description", header: "Invoice", sortable: true },
{ key: "date", header: "Date", sortable: true, sortValue: (r) => new Date(r.date) },
{ key: "amount", header: "Amount", sortable: true, align: "end" },
{
key: "status",
header: "Status",
render: (r) => (
<Chip size="sm" variant="soft" color={statusTone[r.status]}>
{r.status}
</Chip>
),
},
{
key: "actions",
header: "",
align: "end",
render: (r) => <RowMenu label={`${r.description} options`} />,
},
]}
rows={invoices}
getRowKey={(r) => r.id}
/>
</div>
</div>
</Tab>
</Tabs>
</div>
</PatternAppShell>
);
}