Settings section
The building blocks of a settings page: a titled section, a divided list, and rows with a label, a description and a control on the right.
Sign-in and recovery
Your password and second factors at a glance.
Password
Last changed 3 months ago.
Authenticator appRecommended
Codes from an app on your phone.
EmailAvailable
A code sent to your address at each sign-in.
import { Button } from '@/components/button'
import { SettingsList, SettingsRow, SettingsSection } from '@/components/settings'
import { KeyIcon } from '@heroicons/react/16/solid'
export default function Example() {
return (
<SettingsSection title="Sign-in and recovery" description="Your password and second factors at a glance.">
<SettingsList>
<SettingsRow
icon={KeyIcon}
label="Password"
description="Last changed 3 months ago."
action={<Button outline>Change password</Button>}
/>
</SettingsList>
</SettingsSection>
)
}<script setup lang="ts">
import Button from '@/components/button.vue'
import SettingsList from '@/components/settings-list.vue'
import SettingsRow from '@/components/settings-row.vue'
import SettingsSection from '@/components/settings-section.vue'
import { KeyIcon } from '@heroicons/vue/16/solid'
</script>
<template>
<SettingsSection title="Sign-in and recovery" description="Your password and second factors at a glance.">
<SettingsList>
<SettingsRow :icon="KeyIcon" label="Password" description="Last changed 3 months ago.">
<template #action>
<Button outline>Change password</Button>
</template>
</SettingsRow>
</SettingsList>
</SettingsSection>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import clsx from 'clsx'
import type React from 'react'
import { useId } from 'react'
import { Subheading } from './heading'
import { Text } from './text'
export function SettingsSection({
title,
description,
actions,
className,
children,
...props
}: {
title: React.ReactNode
description?: React.ReactNode
actions?: React.ReactNode
} & Omit<React.ComponentPropsWithoutRef<'section'>, 'title'>) {
let titleId = useId()
let heading = (
<>
<Subheading id={titleId}>{title}</Subheading>
{description ? <Text className="mt-1">{description}</Text> : null}
</>
)
return (
<section aria-labelledby={titleId} {...props} className={className}>
{actions ? (
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-4">
<div className="min-w-0">{heading}</div>
<div className="flex shrink-0 items-center gap-3">{actions}</div>
</div>
) : (
heading
)}
<div className="mt-4">{children}</div>
</section>
)
}
export function SettingsList({ className, ...props }: React.ComponentPropsWithoutRef<'ul'>) {
return <ul role="list" {...props} className={clsx(className, 'divide-y divide-zinc-950/5 dark:divide-white/5')} />
}
export function SettingsRow({
icon: Icon,
label,
description,
badge,
action,
disabled = false,
className,
...props
}: {
icon?: React.ComponentType<{ className?: string }>
label: React.ReactNode
description?: React.ReactNode
badge?: React.ReactNode
action?: React.ReactNode
disabled?: boolean
} & React.ComponentPropsWithoutRef<'li'>) {
let text = Icon ? 'col-start-2' : 'col-start-1'
return (
<li
{...props}
className={clsx(
className,
'grid gap-x-3 gap-y-1 py-4',
Icon ? 'grid-cols-[--spacing(4)_1fr_auto]' : 'grid-cols-[1fr_auto]'
)}
>
{Icon ? (
<Icon
className={clsx(
'col-start-1 row-start-1 size-4 shrink-0 self-center',
disabled ? 'text-zinc-300 dark:text-zinc-600' : 'text-zinc-500 dark:text-zinc-400'
)}
/>
) : null}
<p
className={clsx(
text,
'row-start-1 flex flex-wrap items-center gap-2 text-base/6 font-medium sm:text-sm/6',
disabled ? 'text-zinc-400 dark:text-zinc-500' : 'text-zinc-950 dark:text-white'
)}
>
{label}
{badge}
</p>
{description ? (
<p className={clsx(text, 'row-start-2 text-base/6 sm:text-sm/6 text-zinc-500 dark:text-zinc-400')}>
{description}
</p>
) : null}
{action ? (
<div className={clsx(Icon ? 'col-start-3' : 'col-start-2', 'row-span-2 row-start-1 self-center')}>{action}</div>
) : null}
</li>
)
}<script setup lang="ts">
import { useId } from 'vue'
import Subheading from './subheading.vue'
import Text from './text.vue'
defineProps<{ title: string; description?: string }>()
const titleId = useId()
</script>
<template>
<section :aria-labelledby="titleId">
<div v-if="$slots.actions" class="flex flex-wrap items-start justify-between gap-x-6 gap-y-4">
<div class="min-w-0">
<Subheading :id="titleId">{{ title }}</Subheading>
<Text v-if="description" class="mt-1">{{ description }}</Text>
</div>
<div class="flex shrink-0 items-center gap-3">
<slot name="actions" />
</div>
</div>
<template v-else>
<Subheading :id="titleId">{{ title }}</Subheading>
<Text v-if="description" class="mt-1">{{ description }}</Text>
</template>
<div class="mt-4">
<slot />
</div>
</section>
</template>Component API
| Prop | Default | Description |
|---|---|---|
SettingsSectionrenders a <section> element | ||
title | - | Subheading of the section, also its accessible name. |
description | - | One line under the title. |
actions | - | Beside the title: a button, a switch, a save bar. |
SettingsListrenders a <ul> element | ||
| This component does not expose any component-specific props. | ||
SettingsRowrenders a <li> element | ||
icon | - | Icon component drawn before the label. |
label | - | Name of the setting. |
description | - | Its state or what it does, under the label. |
badge | - | Beside the label: a Badge or a StatusBadge. |
action | - | The control on the right, centered on the two lines. |
disabled | false | Greys the icon and the label. Disable the action yourself. |
| Prop | Default | Description |
|---|---|---|
SettingsSectionrenders a <section> element | ||
title | - | Subheading of the section, also its accessible name. |
description | - | One line under the title. |
#actions | - | Beside the title: a button, a switch, a save bar. |
SettingsListrenders a <ul> element | ||
| This component does not expose any component-specific props. | ||
SettingsRowrenders a <li> element | ||
icon | - | Icon component drawn before the label. |
label | - | Name of the setting. |
description | - | Its state or what it does, under the label. |
#badge | - | Beside the label: a Badge or a StatusBadge. |
#action | - | The control on the right, centered on the two lines. |
disabled | false | Greys the icon and the label. Disable the action yourself. |
Examples
Basic example
A section holds a list, and each row holds one setting. The control on the right is yours: a button, a switch, a menu:
Sign-in and recovery
Your password and second factors at a glance.
Password
Last changed 3 months ago.
import { Button } from '@/components/button'
import { SettingsList, SettingsRow, SettingsSection } from '@/components/settings'
import { KeyIcon } from '@heroicons/react/16/solid'
export default function Example() {
return (
<SettingsSection title="Sign-in and recovery" description="Your password and second factors at a glance.">
<SettingsList>
<SettingsRow
icon={KeyIcon}
label="Password"
description="Last changed 3 months ago."
action={<Button outline>Change password</Button>}
/>
</SettingsList>
</SettingsSection>
)
}<script setup lang="ts">
import Button from '@/components/button.vue'
import SettingsList from '@/components/settings-list.vue'
import SettingsRow from '@/components/settings-row.vue'
import SettingsSection from '@/components/settings-section.vue'
import { KeyIcon } from '@heroicons/vue/16/solid'
</script>
<template>
<SettingsSection title="Sign-in and recovery" description="Your password and second factors at a glance.">
<SettingsList>
<SettingsRow :icon="KeyIcon" label="Password" description="Last changed 3 months ago.">
<template #action>
<Button outline>Change password</Button>
</template>
</SettingsRow>
</SettingsList>
</SettingsSection>
</template>With badges and a disabled row
A badge sits beside the label. disabled greys the icon and the label only, so disable the control as well:
Authenticator appRecommended
Codes from an app on your phone.
EmailAvailable
A code sent to your address at each sign-in.
SMSUnavailable
Not offered in your country yet.
PasskeysAvailable
Face, fingerprint or device PIN.
{methods.map((method) => (
<SettingsRow
key={method.label}
icon={method.icon}
label={method.label}
description={method.description}
disabled={method.disabled}
badge={<Badge color={method.disabled ? 'zinc' : 'amber'}>{method.status}</Badge>}
action={<Button outline disabled={method.disabled}>{method.action}</Button>}
/>
))}<SettingsRow
v-for="method in methods"
:key="method.label"
:icon="method.icon"
:label="method.label"
:description="method.description"
:disabled="method.disabled"
>
<template #badge>
<Badge :color="method.disabled ? 'zinc' : 'amber'">{{ method.status }}</Badge>
</template>
<template #action>
<Button outline :disabled="method.disabled">{{ method.action }}</Button>
</template>
</SettingsRow>With switches
Preferences are rows with a Switch. Label the switch after the row, the row text is not linked to it:
Payment received
Every time a payment succeeds.
Weekly summary
Volume and top customers, on Monday morning.
<SettingsRow
icon={BellIcon}
label="Payment received"
description="Every time a payment succeeds."
action={<Switch defaultChecked aria-label="Payment received" />}
/><SettingsRow :icon="BellIcon" label="Payment received" description="Every time a payment succeeds.">
<template #action>
<Switch v-model="prefs.paymentReceived" aria-label="Payment received" />
</template>
</SettingsRow>With actions beside the title
Something that applies to the whole section goes in actions, aligned with the title:
Dashboard IP allowlist
Only these addresses can open the dashboard.
41.82.6.14
Office · added Mar 3, 2026
196.207.14.8
Home · added Sep 1, 2026
<SettingsSection
title="Dashboard IP allowlist"
description="Only these addresses can open the dashboard."
actions={
<>
<span className="text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">Enforced</span>
<Switch defaultChecked aria-label="Enforce the allowlist" />
</>
}
>
…
</SettingsSection><SettingsSection title="Dashboard IP allowlist" description="Only these addresses can open the dashboard.">
<template #actions>
<span class="text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">Enforced</span>
<Switch v-model="enforced" aria-label="Enforce the allowlist" />
</template>
…
</SettingsSection>Without icons
Leave icon out and the text takes the first column:
Account number
Use this number for inter-account transfers.
MA-4821-0093Support ID
Give this to support when you write in.
SUP-77A19
<SettingsRow
label="Account number"
description="Use this number for inter-account transfers."
action={
<span className="flex items-center gap-2">
<Code>MA-4821-0093</Code>
<CopyButton text="MA-4821-0093" label="Copy account number" />
</span>
}
/><SettingsRow label="Account number" description="Use this number for inter-account transfers.">
<template #action>
<span class="flex items-center gap-2">
<Code>MA-4821-0093</Code>
<CopyButton text="MA-4821-0093" label="Copy account number" />
</span>
</template>
</SettingsRow>Stacked sections
Separate sections with a soft Divider and my-10. Cap the width of the page, not of each section:
Sign-in and recovery
Your password and second factors at a glance.
Password
Last changed 3 months ago.
Active sessions
Devices currently signed in to your account.
MacBook ProThis device
Dakar (41.82.6.14) · Active now
iPhone 15
Dakar (41.82.6.14) · 2 hours ago
<div className="max-w-2xl">
<SettingsSection title="Sign-in and recovery" description="…">…</SettingsSection>
<Divider soft className="my-10" />
<SettingsSection title="Active sessions" description="…">…</SettingsSection>
</div><div class="max-w-2xl">
<SettingsSection title="Sign-in and recovery" description="…">…</SettingsSection>
<Divider soft class="my-10" />
<SettingsSection title="Active sessions" description="…">…</SettingsSection>
</div>