SettingsDialog
A dialog for app settings, with a sidebar of grouped tabs on the left and the selected tab's content on the right.
<script setup lang="ts">
import { markRaw, ref } from 'vue'
import {
Avatar,
Button,
SettingsContent,
SettingsDialog,
SettingsNavGroup,
SettingsNavItem,
SettingsPanel,
SettingsSidebar,
} from 'frappe-ui'
import ProfilePanel from './panels/ProfilePanel.vue'
import PreferencesPanel from './panels/PreferencesPanel.vue'
import NotificationsPanel from './panels/NotificationsPanel.vue'
import UsersPanel from './panels/UsersPanel.vue'
import GeneralPanel from './panels/GeneralPanel.vue'
import IntegrationsPanel from './panels/IntegrationsPanel.vue'
import PlanPanel from './panels/PlanPanel.vue'
import InvoicesPanel from './panels/InvoicesPanel.vue'
const open = ref(false)
// Modeled on Gameplan's settings: grouped tabs, an avatar on the Profile tab,
// and panels that range from simple forms to a long, searchable user table.
const groups = [
{
label: 'User settings',
tabs: [
{
label: 'Profile',
value: 'profile',
avatar: true,
component: markRaw(ProfilePanel),
},
{
label: 'Preferences',
value: 'preferences',
icon: 'lucide-sliders-horizontal',
component: markRaw(PreferencesPanel),
},
{
label: 'Notifications',
value: 'notifications',
icon: 'lucide-bell',
component: markRaw(NotificationsPanel),
},
],
},
{
label: 'Workspace',
tabs: [
{
label: 'General',
value: 'general',
icon: 'lucide-building-2',
component: markRaw(GeneralPanel),
},
{
label: 'Users',
value: 'users',
icon: 'lucide-users',
component: markRaw(UsersPanel),
},
{
label: 'Integrations',
value: 'integrations',
icon: 'lucide-blocks',
component: markRaw(IntegrationsPanel),
},
],
},
{
label: 'Billing',
tabs: [
{
label: 'Plan',
value: 'plan',
icon: 'lucide-credit-card',
component: markRaw(PlanPanel),
},
{
label: 'Invoices',
value: 'invoices',
icon: 'lucide-receipt',
component: markRaw(InvoicesPanel),
},
],
},
]
const tabs = groups.flatMap((group) => group.tabs)
const activeTab = ref(tabs[0].value)
</script>
<template>
<Button @click="open = true">Open settings</Button>
<SettingsDialog v-model:open="open" v-model:tab="activeTab">
<SettingsSidebar>
<SettingsNavGroup
v-for="group in groups"
:key="group.label"
:label="group.label"
>
<SettingsNavItem
v-for="tab in group.tabs"
:key="tab.value"
:value="tab.value"
>
<template #prefix>
<Avatar
v-if="tab.avatar"
size="xs"
label="Alex Rivera"
class="shrink-0"
/>
<span
v-else
:class="[tab.icon, 'size-4 shrink-0 text-ink-gray-6']"
/>
</template>
{{ tab.label }}
</SettingsNavItem>
</SettingsNavGroup>
</SettingsSidebar>
<SettingsContent>
<SettingsPanel v-for="tab in tabs" :key="tab.value" :value="tab.value">
<component :is="tab.component" />
</SettingsPanel>
</SettingsContent>
</SettingsDialog>
</template>Anatomy
SettingsDialog is built on Dialog and is made of small parts rather than a config object. Give each nav item and its panel the same value.
<SettingsDialog v-model:open="open" v-model:tab="tab">
<SettingsSidebar>
<SettingsNavGroup label="User settings">
<SettingsNavItem value="profile">
<template #prefix><Avatar size="xs" label="Alex Rivera" /></template>
Profile
</SettingsNavItem>
<SettingsNavItem value="notifications">Notifications</SettingsNavItem>
</SettingsNavGroup>
</SettingsSidebar>
<SettingsContent>
<SettingsPanel value="profile">…</SettingsPanel>
<SettingsPanel value="notifications">
<SettingsHeader title="Notifications" />
<SettingsBody>
<SettingsRow title="Email digests" description="A summary of missed activity.">
<Switch v-model="digest" />
</SettingsRow>
</SettingsBody>
</SettingsPanel>
</SettingsContent>
</SettingsDialog>SettingsDialogholds the open state (v-model:open), the selected tab (v-model:tab) and the keyboard shortcut.SettingsSidebar,SettingsNavGroupandSettingsNavItemare the navigation. A nav item takes#prefixand#suffix.SettingsContentandSettingsPanelare the right side, with one panel per tab.SettingsHeader,SettingsBodyandSettingsRowlay out a panel.
Examples
Notification settings
A panel with a SettingsHeader that stays in place, a SettingsBody that scrolls, and one SettingsRow per setting.
<script setup lang="ts">
import { ref } from 'vue'
import { TabsRoot } from 'reka-ui'
import {
Select,
SettingsBody,
SettingsHeader,
SettingsPanel,
SettingsRow,
Switch,
} from 'frappe-ui'
const digestEnabled = ref(true)
const frequency = ref('Weekly')
const frequencyOptions = [
{ label: 'Weekly', value: 'Weekly' },
{ label: 'Fortnightly', value: 'Fortnightly' },
{ label: 'Monthly', value: 'Monthly' },
]
</script>
<template>
<!--
SettingsPanel is a reka-ui tabpanel, so it needs a TabsRoot ancestor. In the
full dialog SettingsDialog provides it; here we add a minimal one to show a
single panel in isolation.
-->
<div class="h-96 w-full overflow-hidden rounded-4 border border-outline-gray-2">
<TabsRoot default-value="notifications" class="flex h-full">
<SettingsPanel value="notifications">
<SettingsHeader title="Notifications" />
<SettingsBody>
<div class="divide-y divide-outline-gray-1">
<SettingsRow
title="Enable email digests"
description="Send a summary of missed activity."
>
<Switch v-model="digestEnabled" />
</SettingsRow>
<SettingsRow
v-if="digestEnabled"
title="Digest frequency"
description="Choose how often you receive your digest."
>
<Select :options="frequencyOptions" v-model="frequency" />
</SettingsRow>
</div>
</SettingsBody>
</SettingsPanel>
</TabsRoot>
</div>
</template>Behavior
Selected tab
The selected tab is the value of a nav item, not an active flag. v-model:tab is optional. Bind it to drive the selection yourself, for example from a /settings/:tab URL. Left unbound, the dialog tracks the selection itself.
Keeping panels mounted
A panel's content is unmounted when its tab is not selected. Set :unmount-on-hide="false" to keep visited panels mounted, so they keep their state and scroll position across tab switches.
Keyboard shortcut
Cmd+Shift+, (Ctrl+Shift+, on Windows and Linux) opens and closes the dialog, even while focus is in an input. Pass another combo to keyboardShortcut to change it, or false to turn it off.
Size and mobile layout
The dialog fills the screen on mobile, with the sidebar above the content. On desktop it is a centered panel. size sets its maximum width and takes the same values as Dialog's size.
Panel header and body
Put a SettingsHeader and a SettingsBody in each SettingsPanel. The header stays in place and the body scrolls, so titles, search inputs and column headers never scroll away. SettingsHeader takes title, description and an #actions slot, or any content in its default slot.
Setting rows
SettingsRow shows a setting's title and description on the left and its control on the right. When the control is a frappe-ui control such as Switch, the title becomes its <label>, with no label-for needed. Set labelFor to point the label at another element's id.
Accessibility
The dialog is a tab set: the sidebar has the tablist role, each nav item the tab role, and each panel the tabpanel role.
| Keys | Action |
|---|---|
ArrowDown / ArrowUp | Move focus to the next or previous tab |
Enter / Space | Select the focused tab |
Cmd+Shift+, / Ctrl+Shift+, | Open or close the dialog |
Arrow keys move focus without selecting a tab, so a dialog that drives its tab from the route does not navigate on every key press.
The dialog has a visually hidden title, "Settings", and a hidden description. Replace them with the #title and #description slots.
API Reference
SettingsDialog
Show types
import type { DialogSize } from '../Dialog/types'
import type { KeyboardShortcutCombo } from '../../composables/useKeyboardShortcut'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Shortcut that toggles the dialog. Pass `false` to disable it. */
keyboardShortcut?: KeyboardShortcutCombo | 'Mod+Shift+,' | false
/**
* Unmount a panel's content when its tab is inactive (reka-ui default: true).
* Set false to keep visited panels mounted (hidden) — preserves their state
* and scroll position across tab switches at the cost of memory.
*/
unmountOnHide?: boolean
}
export interface SettingsDialogEmits {
/** Fired when the dialog is opened or closed. */
'update:open': [value: boolean]
}
/**
* Shared with `ScrollArea` (imperative-api.md §2.5) once that component's own
* sweep types its `defineExpose`. Kept local to this family until then.
*/
export interface SettingsBodyExposed {
/** The element that actually scrolls. `null` before mount. */
viewportElement: HTMLElement | null
}Max-width size of the dialog.
Shortcut that toggles the dialog. Pass `false` to disable it.
Unmount a panel's content when its tab is inactive (reka-ui default: true). Set false to keep visited panels mounted (hidden) — preserves their state and scroll position across tab switches at the cost of memory.
Controls whether the dialog is open.
The selected tab — pairs with SettingsNavItem `:value` and SettingsPanel `:value`. Optional: leave unbound to let reka-ui manage selection internally, or bind `v-model:tab` to drive it (e.g. from the route).
| Slot | Payload |
|---|---|
title | — Accessible dialog title (visually hidden). Defaults to "Settings". |
description | — Accessible dialog description (visually hidden, for aria-describedby). |
default | — Sidebar + content panes (compose SettingsSidebar and SettingsContent). |
Accessible dialog title (visually hidden). Defaults to "Settings".
Accessible dialog description (visually hidden, for aria-describedby).
Sidebar + content panes (compose SettingsSidebar and SettingsContent).
| Event | Payload |
|---|---|
update:open | [value: boolean] Fired when the open state changes. |
update:tab | [value: string | number | undefined] Fired when the tab changes. |
Fired when the open state changes.
Fired when the tab changes.
SettingsSidebar
| Slot | Payload |
|---|---|
default | {} |
SettingsNavGroup
Show types
import type { DialogSize } from '../Dialog/types'
import type { KeyboardShortcutCombo } from '../../composables/useKeyboardShortcut'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Shortcut that toggles the dialog. Pass `false` to disable it. */
keyboardShortcut?: KeyboardShortcutCombo | 'Mod+Shift+,' | false
/**
* Unmount a panel's content when its tab is inactive (reka-ui default: true).
* Set false to keep visited panels mounted (hidden) — preserves their state
* and scroll position across tab switches at the cost of memory.
*/
unmountOnHide?: boolean
}
export interface SettingsDialogEmits {
/** Fired when the dialog is opened or closed. */
'update:open': [value: boolean]
}
/**
* Shared with `ScrollArea` (imperative-api.md §2.5) once that component's own
* sweep types its `defineExpose`. Kept local to this family until then.
*/
export interface SettingsBodyExposed {
/** The element that actually scrolls. `null` before mount. */
viewportElement: HTMLElement | null
}Group heading shown above its items.
| Slot | Payload |
|---|---|
label | {} |
default | {} |
SettingsNavItem
Show types
import type { DialogSize } from '../Dialog/types'
import type { KeyboardShortcutCombo } from '../../composables/useKeyboardShortcut'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Shortcut that toggles the dialog. Pass `false` to disable it. */
keyboardShortcut?: KeyboardShortcutCombo | 'Mod+Shift+,' | false
/**
* Unmount a panel's content when its tab is inactive (reka-ui default: true).
* Set false to keep visited panels mounted (hidden) — preserves their state
* and scroll position across tab switches at the cost of memory.
*/
unmountOnHide?: boolean
}
export interface SettingsDialogEmits {
/** Fired when the dialog is opened or closed. */
'update:open': [value: boolean]
}
/**
* Shared with `ScrollArea` (imperative-api.md §2.5) once that component's own
* sweep types its `defineExpose`. Kept local to this family until then.
*/
export interface SettingsBodyExposed {
/** The element that actually scrolls. `null` before mount. */
viewportElement: HTMLElement | null
}Unique tab id — must match the SettingsPanel `value` it controls.
| Slot | Payload |
|---|---|
default | — Item label. |
prefix | — Leading icon or avatar. |
suffix | — Trailing badge / count. |
Item label.
Leading icon or avatar.
Trailing badge / count.
SettingsContent
| Slot | Payload |
|---|---|
default | {} |
SettingsPanel
Show types
import type { DialogSize } from '../Dialog/types'
import type { KeyboardShortcutCombo } from '../../composables/useKeyboardShortcut'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Shortcut that toggles the dialog. Pass `false` to disable it. */
keyboardShortcut?: KeyboardShortcutCombo | 'Mod+Shift+,' | false
/**
* Unmount a panel's content when its tab is inactive (reka-ui default: true).
* Set false to keep visited panels mounted (hidden) — preserves their state
* and scroll position across tab switches at the cost of memory.
*/
unmountOnHide?: boolean
}
export interface SettingsDialogEmits {
/** Fired when the dialog is opened or closed. */
'update:open': [value: boolean]
}
/**
* Shared with `ScrollArea` (imperative-api.md §2.5) once that component's own
* sweep types its `defineExpose`. Kept local to this family until then.
*/
export interface SettingsBodyExposed {
/** The element that actually scrolls. `null` before mount. */
viewportElement: HTMLElement | null
}Unique tab id — must match the SettingsNavItem `value` that controls it.
| Slot | Payload |
|---|---|
default | {} |
SettingsHeader
Show types
import type { DialogSize } from '../Dialog/types'
import type { KeyboardShortcutCombo } from '../../composables/useKeyboardShortcut'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Shortcut that toggles the dialog. Pass `false` to disable it. */
keyboardShortcut?: KeyboardShortcutCombo | 'Mod+Shift+,' | false
/**
* Unmount a panel's content when its tab is inactive (reka-ui default: true).
* Set false to keep visited panels mounted (hidden) — preserves their state
* and scroll position across tab switches at the cost of memory.
*/
unmountOnHide?: boolean
}
export interface SettingsDialogEmits {
/** Fired when the dialog is opened or closed. */
'update:open': [value: boolean]
}
/**
* Shared with `ScrollArea` (imperative-api.md §2.5) once that component's own
* sweep types its `defineExpose`. Kept local to this family until then.
*/
export interface SettingsBodyExposed {
/** The element that actually scrolls. `null` before mount. */
viewportElement: HTMLElement | null
}Convenience heading. Omit and use the default slot for a custom header.
Optional sub-heading under the title.
| Slot | Payload |
|---|---|
default | — Replaces the entire header (title/description/actions) with custom content. |
actions | — Actions on the right of the default title header. |
Replaces the entire header (title/description/actions) with custom content.
Actions on the right of the default title header.
SettingsBody
| Slot | Payload |
|---|---|
default | {} |
| Template ref | Type |
|---|---|
viewportElement | HTMLElement | null The element that actually scrolls. `null` before mount. |
The element that actually scrolls. `null` before mount.
SettingsRow
Show types
import type { DialogSize } from '../Dialog/types'
import type { KeyboardShortcutCombo } from '../../composables/useKeyboardShortcut'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Shortcut that toggles the dialog. Pass `false` to disable it. */
keyboardShortcut?: KeyboardShortcutCombo | 'Mod+Shift+,' | false
/**
* Unmount a panel's content when its tab is inactive (reka-ui default: true).
* Set false to keep visited panels mounted (hidden) — preserves their state
* and scroll position across tab switches at the cost of memory.
*/
unmountOnHide?: boolean
}
export interface SettingsDialogEmits {
/** Fired when the dialog is opened or closed. */
'update:open': [value: boolean]
}
/**
* Shared with `ScrollArea` (imperative-api.md §2.5) once that component's own
* sweep types its `defineExpose`. Kept local to this family until then.
*/
export interface SettingsBodyExposed {
/** The element that actually scrolls. `null` before mount. */
viewportElement: HTMLElement | null
}Setting name, rendered on the left.
Optional helper text under the title.
Explicit id to associate the title label with. Overrides auto-detection.
| Slot | Payload |
|---|---|
default | {} |