SettingsModal
Settings dialog with a persistent nav rail on the left and a titled, scrolling pane on the right. Used for account, workspace, and project settings.
Preview
How it works
The nav is data-driven from a SettingsSection[]: grouped headings, icons, badges, and per-section header actions all live in that array. The active section's body is children, which the caller swaps on activeSection (controllable via activeSection / onSectionChange, or uncontrolled via defaultSection). Every pane gets the same header, scroll region, and footer bar.
Compose the body from SettingsGroup and SettingsRow so rows line up consistently across apps. The overlay mechanics (portal, backdrop, Escape, scroll lock, focus trap) come from the shared Dialog primitive, as in Modal and Drawer.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controls visibility. |
onClose | () => void | — | Escape / backdrop / close-button handler. |
sections | SettingsSection[] | — | The nav model. Order is preserved; group collapses runs into headings. |
activeSection | string | — | Controlled active id. Pair with onSectionChange. |
defaultSection | string | — | Uncontrolled initial id. Defaults to the first enabled section. |
onSectionChange | (id: string) => void | — | Called when the active section changes. |
title | ReactNode | "Settings" | Heading above the settings navigation. |
sub | ReactNode | — | Dim line under the sidebar heading (the org / account name). |
brand | ReactNode | — | Logo mark beside the sidebar heading. |
search | boolean | — | Adds a filter box over the nav. |
searchPlaceholder | string | "Search settings…" | Placeholder for the nav filter. |
navFooter | ReactNode | — | Pinned to the bottom of the sidebar (a 'sign out' row, a plan chip). |
footer | ReactNode | — | Footer content below the active pane. Omit for auto-saving panes. |
width | number | 780 | Panel width in px. |
closeLabel | string | "Close settings" | Accessible label for the pane's close button. |
children | ReactNode | — | The active section's body. Swap it on activeSection. |
SettingsSection
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable section ID used by activeSection and onSectionChange. |
label | ReactNode | — | Nav label; also the pane header title unless title overrides it. |
icon | ReactNode | — | Leading nav icon. |
group | string | — | Uppercase divider heading; consecutive sections with the same group share one heading. |
title | ReactNode | — | Pane header title, when it should differ from the nav label. |
description | ReactNode | — | Dim line under the pane title. |
badge | ReactNode | — | Content after the navigation label. |
headerActions | ReactNode | — | Right-aligned actions in this section's pane header. |
disabled | boolean | — | Greys out and disables the nav item. |
keywords | string | — | Extra words the nav search should match beyond the label text. |
Usage
import {
SettingsModal,
SettingsGroup,
SettingsRow,
Switch,
Select,
Input,
Field,
Button,
} from "@solarius/onset";
import type { SettingsSection } from "@solarius/onset";
import { IconUserCircle, IconBell, IconLock } from "@tabler/icons-react";
import { useState } from "react";
const sections: SettingsSection[] = [
{
id: "profile",
label: "Profile",
icon: <IconUserCircle size={15} />,
group: "Account",
description: "Profile settings displayed throughout the application.",
},
{
id: "notifications",
label: "Notifications",
icon: <IconBell size={15} />,
group: "Account",
},
{
id: "security",
label: "Security",
icon: <IconLock size={15} />,
group: "Account",
},
];
function Settings() {
const [open, setOpen] = useState(false);
const [section, setSection] = useState("profile");
return (
<>
<Button onClick={() => setOpen(true)}>Settings</Button>
<SettingsModal
open={open}
onClose={() => setOpen(false)}
sections={sections}
activeSection={section}
onSectionChange={setSection}
title="Settings"
sub="Acme Commerce"
search
footer={
<>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={() => setOpen(false)}>
Save changes
</Button>
</>
}
>
{section === "profile" && (
<SettingsGroup
title="Profile"
description="Shown on comments and audit logs."
>
<SettingsRow label="Display name" htmlFor="name" stacked>
<Input id="name" defaultValue="Malaika Okafor" />
</SettingsRow>
<SettingsRow label="Role">
<Select
defaultValue="ops"
options={[{ value: "ops", label: "Finance operations" }]}
/>
</SettingsRow>
</SettingsGroup>
)}
{section === "notifications" && (
<SettingsGroup title="Alerts">
<SettingsRow
label="Disputes opened"
description="A cardholder opened a chargeback."
>
<Switch checked onChange={() => {}} />
</SettingsRow>
</SettingsGroup>
)}
</SettingsModal>
</>
);
}Content helpers
Two helpers keep every pane's rows aligned. SettingsGroup is a titled, framed block; SettingsRow is one setting: label and description on the left, control on the right (or stacked underneath for wide inputs).
SettingsGroup
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Group heading, above the framed rows. |
description | ReactNode | — | Dim line under the heading. |
action | ReactNode | — | Right-aligned slot next to the heading (a group-level action). |
plain | boolean | — | Removes the outer border and row borders. |
children | ReactNode | — | SettingsRows, or arbitrary content when plain. |
SettingsRow
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | The setting's name. |
description | ReactNode | — | Dim explanatory line under the label. |
htmlFor | string | — | Renders the label as a <label> bound to this control id. |
stacked | boolean | — | Stacks the control under the label (for wide inputs and textareas). |
children | ReactNode | — | The control(s), right-aligned unless stacked. |