SettingsDialog
A modal for app settings: a sidebar of grouped tabs on the left, the active tab's content on the right. Built on Dialog, composed from small building blocks rather than a config object.
<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'
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: 'Administration',
tabs: [
{
label: 'Users',
value: 'users',
icon: 'lucide-users',
component: markRaw(UsersPanel),
},
],
},
]
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" 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
It's a reka-ui Tabs set, so you get tablist/tab/tabpanel roles, aria-selected, and arrow-key focus for free. Instead of tracking an active flag, give each nav item and panel a matching value.
SettingsDialog— wrapsDialog; owns open state (v-model), the selected tab (v-model:tab), and theCmd/Ctrl+Shift+,shortcut. Full-screen on mobile, centered panel on desktop.SettingsSidebar/SettingsNavGroup/SettingsNavItem— the navigation. Give each item a:value; it also takes#prefixand#suffix.SettingsContent/SettingsPanel— the right pane; one panel per tab, each with a:valuematching its nav item.
v-model:tab is optional — bind it to drive selection yourself (Gameplan uses it for deep-linkable /settings/:tab URLs). Set :unmount-on-hide="false" to keep visited panels mounted across switches.
Panels: fixed header, scrolling body
A SettingsPanel holds a SettingsHeader (pinned) and a SettingsBody (scrolls), so titles, search inputs, and column headers never scroll away. SettingsHeader takes title + description (+ #actions), or arbitrary content via its default slot.
<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 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>SettingsRow
Lays out a setting as label + description on the left, control on the right. A slotted frappe-ui control (e.g. Switch) is auto-wired to the title <label> — no label-for needed.
API Reference
SettingsDialog
Show types
import type { DialogSize } from '../Dialog/types'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Enable the Cmd/Ctrl+Shift+, shortcut that toggles the dialog. */
shortcut?: boolean
/**
* 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:modelValue': [value: boolean]
}Max-width size of the dialog.
Enable the Cmd/Ctrl+Shift+, shortcut that toggles the dialog.
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:modelValue | [value: boolean] Fired when the model value changes. |
update:tab | [value: string | number | undefined] Fired when the tab changes. |
Fired when the model value changes.
Fired when the tab changes.
SettingsSidebar
| Slot | Payload |
|---|---|
default | {} |
SettingsNavGroup
Show types
import type { DialogSize } from '../Dialog/types'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Enable the Cmd/Ctrl+Shift+, shortcut that toggles the dialog. */
shortcut?: boolean
/**
* 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:modelValue': [value: boolean]
}Group heading shown above its items.
| Slot | Payload |
|---|---|
label | {} |
default | {} |
SettingsNavItem
Show types
import type { DialogSize } from '../Dialog/types'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Enable the Cmd/Ctrl+Shift+, shortcut that toggles the dialog. */
shortcut?: boolean
/**
* 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:modelValue': [value: boolean]
}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'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Enable the Cmd/Ctrl+Shift+, shortcut that toggles the dialog. */
shortcut?: boolean
/**
* 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:modelValue': [value: boolean]
}Unique tab id — must match the SettingsNavItem `value` that controls it.
| Slot | Payload |
|---|---|
default | {} |
SettingsHeader
Show types
import type { DialogSize } from '../Dialog/types'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Enable the Cmd/Ctrl+Shift+, shortcut that toggles the dialog. */
shortcut?: boolean
/**
* 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:modelValue': [value: boolean]
}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 | {} |
SettingsRow
Show types
import type { DialogSize } from '../Dialog/types'
export interface SettingsDialogProps {
/** Max-width size of the dialog. */
size?: DialogSize
/** Enable the Cmd/Ctrl+Shift+, shortcut that toggles the dialog. */
shortcut?: boolean
/**
* 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:modelValue': [value: boolean]
}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 | {} |