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 '@adecore/ui/settings';
tsx
import { useState } from 'react';
import { Bell, Info, Keyboard, Palette, Settings, Sparkles } from 'lucide-react';
import { Button, Icon, Select, Switch } from '@adecore/ui';
import { MasterDetail, MasterItem, SettingsDialog, SettingsRow, SettingsSection, type SettingsSearchResult } from '@adecore/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 AboutHero() {
    return (
        <div className="flex flex-col items-center gap-1.5 bg-surface-sunken px-8 pt-28 pb-12 text-center">
            <Icon icon={Sparkles} size={20} className="mb-2 text-accent" />
            <h3 className="text-lg font-semibold text-text">Example</h3>
            <p className="text-xs text-text-muted">Version 1.0.0</p>
        </div>
    );
}

const DETAILS = [
    { label: 'Electron', value: '38.2.0' },
    { label: 'Chromium', value: '140.0.7339' },
    { label: 'Node', value: '22.19.0' },
    { label: 'Platform', value: 'macOS' }
];

const LINKS = ['Website', 'Source code', 'Report a problem'];

function AboutPane() {
    const [automatic, setAutomatic] = useState(true);

    return (
        <>
            <SettingsSection title="Updates">
                <SettingsRow
                    label="Download updates by themselves"
                    control={<Switch label="Download updates by themselves" checked={automatic} onCheckedChange={setAutomatic} />}
                />
            </SettingsSection>
            <SettingsSection title="Details">
                {DETAILS.map((row) => (
                    <SettingsRow key={row.label} label={row.label} control={<span className="text-xs text-text-muted">{row.value}</span>} />
                ))}
            </SettingsSection>
            <SettingsSection title="Links">
                {LINKS.map((link) => (
                    <SettingsRow
                        key={link}
                        label={link}
                        control={
                            <Button variant="secondary" size="sm">
                                Open
                            </Button>
                        }
                    />
                ))}
            </SettingsSection>
        </>
    );
}

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, hero: AboutHero }];

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.

A section with a hero opens on that block instead, run to the edges of the pane, with the title bar floating over its top. The bar is clear at first and fills in with the dialog's surface and a hairline over the first 70 pixels of scroll. The bar covers the top 86 pixels of the hero, more when its description wraps. The pane follows under the hero, one gap below it, in the same padded column as any other pane.

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?, hero? }, 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.