Frappe UIFrappe UI

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.

vue
<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 — wraps Dialog; owns open state (v-model), the selected tab (v-model:tab), and the Cmd/Ctrl+Shift+, shortcut. Full-screen on mobile, centered panel on desktop.
  • SettingsSidebar / SettingsNavGroup / SettingsNavItem — the navigation. Give each item a :value; it also takes #prefix and #suffix.
  • SettingsContent / SettingsPanel — the right pane; one panel per tab, each with a :value matching 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.

Notifications

Enable email digests
Send a summary of missed activity.
Digest frequency
Choose how often you receive your digest.
vue
<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
typescript
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]
}
size
= "4xl"
DialogSize

Max-width size of the dialog.

shortcut
= true
boolean

Enable the Cmd/Ctrl+Shift+, shortcut that toggles the dialog.

unmountOnHide
= true
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.

modelValue
= false
boolean

Controls whether the dialog is open.

tab
string | number

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).

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).

update:modelValue
[value: boolean]

Fired when the model value changes.

update:tab
[value: string | number | undefined]

Fired when the tab changes.

SettingsSidebar

default
{}

SettingsNavGroup

Show types
typescript
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]
}
label
string

Group heading shown above its items.

label
{}
default
{}

SettingsNavItem

Show types
typescript
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]
}
value*
string | number

Unique tab id — must match the SettingsPanel `value` it controls.

default

Item label.

prefix

Leading icon or avatar.

suffix

Trailing badge / count.

SettingsContent

default
{}

SettingsPanel

Show types
typescript
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]
}
value*
string | number

Unique tab id — must match the SettingsNavItem `value` that controls it.

default
{}

SettingsHeader

Show types
typescript
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]
}
title
string

Convenience heading. Omit and use the default slot for a custom header.

description
string

Optional sub-heading under the title.

default

Replaces the entire header (title/description/actions) with custom content.

actions

Actions on the right of the default title header.

SettingsBody

default
{}

SettingsRow

Show types
typescript
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]
}
title*
string

Setting name, rendered on the left.

description
string

Optional helper text under the title.

labelFor
string

Explicit id to associate the title label with. Overrides auto-detection.

default
{}