Skip to content

SettingsDialog ​

A settings window: sections on the left, one pane on the right, and an optional search that jumps to the row it found. It lives in its own entry point with the parts a pane is built from.

tsx
import { SettingsDialog } from '@basmilius/react-ui/settings';
tsx
import { useState } from 'react';
import { Bell, Info, Keyboard, Palette, Settings } from 'lucide-react';
import { Button, Icon, Select, Switch } from '@basmilius/react-ui';
import { MasterDetail, MasterItem, SettingsDialog, SettingsRow, SettingsSection, type SettingsSearchResult } from '@basmilius/react-ui/settings';

function AppearancePane() {
    const [theme, setTheme] = useState('system');
    const [compact, setCompact] = useState(false);

    return (
        <SettingsSection title="Window" description="How the app looks on this computer.">
            <SettingsRow
                label="Theme"
                searchId="theme"
                control={
                    <Select
                        label="Theme"
                        value={theme}
                        onValueChange={setTheme}
                        items={[
                            { value: 'system', label: 'Match the system' },
                            { value: 'light', label: 'Light' },
                            { value: 'dark', label: 'Dark' }
                        ]}
                    />
                }
            />
            <SettingsRow
                label="Compact rows"
                description="Fits more on a small screen."
                searchId="compact"
                control={<Switch label="Compact rows" checked={compact} onCheckedChange={setCompact} />}
            />
        </SettingsSection>
    );
}

function NotificationsPane() {
    const [sounds, setSounds] = useState(true);

    return (
        <SettingsSection title="Alerts">
            <SettingsRow label="Play a sound" searchId="sound" control={<Switch label="Play a sound" checked={sounds} onCheckedChange={setSounds} />} />
        </SettingsSection>
    );
}

const SHORTCUT_GROUPS = ['General', 'Editing', 'Navigation'];

function ShortcutsPane() {
    const [picked, setPicked] = useState('General');

    return (
        <MasterDetail
            listWidth={280}
            listLabel="Shortcut groups"
            list={SHORTCUT_GROUPS.map((group) => (
                <MasterItem key={group} selected={picked === group} onSelect={() => setPicked(group)}>
                    {group}
                </MasterItem>
            ))}
            detail={<p className="text-sm text-text-muted">The shortcuts of {picked.toLowerCase()} go here.</p>}
        />
    );
}

function AboutPane() {
    return <p className="text-sm text-text-muted">Version 1.0.0</p>;
}

const GROUPS = [
    {
        label: null,
        sections: [
            { id: 'appearance', icon: Palette, label: 'Appearance', description: 'Theme and density.', pane: AppearancePane },
            { id: 'notifications', icon: Bell, label: 'Notifications', description: 'What asks for your attention.', pane: NotificationsPane }
        ]
    },
    {
        label: 'Advanced',
        sections: [{ id: 'shortcuts', icon: Keyboard, label: 'Shortcuts', description: 'Every key the app knows.', pane: ShortcutsPane, split: true }]
    }
];

const FOOTER = [{ id: 'about', icon: Info, label: 'About', description: 'The version you run.', pane: AboutPane }];

const SEARCHABLE: SettingsSearchResult[] = [
    { section: 'appearance', id: null, label: 'Appearance' },
    { section: 'appearance', id: 'theme', label: 'Theme' },
    { section: 'appearance', id: 'compact', label: 'Compact rows' },
    { section: 'notifications', id: 'sound', label: 'Play a sound' },
    { section: 'shortcuts', id: null, label: 'Shortcuts' }
];

const search = {
    find: (query: string) => SEARCHABLE.filter((result) => result.label.toLowerCase().includes(query.trim().toLowerCase())),
    hint: '⌘F'
};

export default function SettingsDialogDemo() {
    const [open, setOpen] = useState(false);
    const [section, setSection] = useState('appearance');
    const [target, setTarget] = useState<string | null>(null);

    return (
        <>
            <Button variant="secondary" onClick={() => setOpen(true)}>
                <Icon icon={Settings} size={14} />
                Open settings
            </Button>
            <SettingsDialog
                open={open}
                onOpenChange={setOpen}
                section={section}
                onNavigate={(next) => {
                    setSection(next.section);
                    setTarget(next.target ?? null);
                }}
                groups={GROUPS}
                footer={FOOTER}
                search={search}
                target={target}
                onTargetShown={() => setTarget(null)}
            />
        </>
    );
}

Sections and panes ​

You describe the sections, the dialog draws them. groups is the navigation, in order, each with an optional label (the first group usually goes without one). footer sections stand at the foot of the navigation. A section names a pane, a component the dialog renders only while that section is open, inside a Suspense and an ErrorBoundary. A lazy pane loads when its section is first opened.

A pane is a padded column that scrolls, with a fade at its top once something scrolled under the header. A split section gets the whole height instead, for a MasterDetail whose two sides scroll on their own.

You keep which section is open. onNavigate tells you when a person picks another, with the arrow keys in the navigation or a click.

search.find(query) answers the results for what a person typed. It is yours, so it can search your own words in any language. A result names a section and, optionally, the searchId of a SettingsRow in it. Picking one calls onNavigate with the section and the row as target. Pass that back in as target, and the row scrolls into view and lights up for a moment, then calls onTargetShown, where you clear it.

Escape in the search field clears the query first; only an empty field lets Escape close the dialog. Enter jumps to the first result. search.hint prints the shortcut that focuses the field, and search.focusAt focuses and selects it each time the number grows, so your shortcut handler can bump it.

An account at the foot ​

account puts one more section under the footer, past a hairline, whose tab you draw yourself, such as an avatar and a name. The tab is a Base UI Tabs.Tab with the section's id as its value.

Narrow windows ​

The dialog is 1200 by 760 pixels and steps down with the viewport. Under 960 pixels the navigation narrows, and under 640 it turns into a select of sections above the pane.

Props ​

PropType
openbooleanRequired.
onOpenChange(open: boolean) => voidRequired.
sectionstringRequired. The id of the open section.
onNavigate(next: { section: string; target?: string | null }) => voidRequired.
groupsreadonly SettingsGroupEntry[]Required. { label: string | null; sections }
footerreadonly SettingsSectionEntry[]
account{ section: SettingsSectionEntry; tab: ReactNode }
searchSettingsSearch{ find(query), hint?, focusAt? }
targetstring | nullThe row a search result jumped to.
onTargetShown() => void
classNamestringOn the popup.
refRef<HTMLDivElement>

A SettingsSectionEntry is { id, icon, label, description, pane, split? }, where the description is the line under the pane's title. A SettingsSearchResult is { section, id, label }, with id: null for a result that is the pane itself.

SettingsDialogProps, SettingsGroupEntry, SettingsSectionEntry, SettingsSearch and SettingsSearchResult are exported types.