Skip to content
  1. Page structure
  2. Settings section

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

Also copy Heading, Text.

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

Also copy Heading, Text.

Component API

PropDefaultDescription
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.
disabledfalseGreys the icon and the label. Disable the action yourself.
PropDefaultDescription
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.
disabledfalseGreys 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.

Enforced
  • 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-0093
  • Support 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>