Settings List
An iOS-style grouped settings list mixing switches, value rows with chevrons, inline selects and a destructive row.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/settings-list.json'use client'
import { useState } from 'react'
import './settings-list.css'
export type SettingsRow =
| { type: 'switch'; id: string; label: string; value: boolean }
| { type: 'link'; id: string; label: string; value?: string }
| { type: 'select'; id: string; label: string; value: string; options: string[] }
| { type: 'danger'; id: string; label: string; confirm: string }
export interface SettingsSection { title?: string; note?: string; rows: SettingsRow[] }
export interface SettingsListProps {
sections: SettingsSection[]
onChange?: (id: string, value: unknown) => void
showNotes?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Grouped settings rows of several kinds. */
export function SettingsList({ sections, onChange, showNotes = true, fill = 'dark', className }: SettingsListProps) {
const init: Record<string, unknown> = {}
sections.forEach((s) => s.rows.forEach((r) => { if ('value' in r) init[r.id] = r.value }))
const [vals, setVals] = useState(init)
const [arm, setArm] = useState<string | null>(null)
const [msg, setMsg] = useState('')
const set = (id: string, v: unknown) => { setVals((x) => ({ ...x, [id]: v })); onChange?.(id, v) }
const chev = <svg className="sd-stl__chev" viewBox="0 0 12 12" aria-hidden><path d="M4.5 2.5 8 6 4.5 9.5" /></svg>
return (
<div className={['sd-stl', className].filter(Boolean).join(' ')} data-fill={fill}>
{sections.map((s, si) => (
<section key={si} className="sd-stl__sec" aria-label={s.title}>
{s.title && <h3>{s.title}</h3>}
<div className="sd-stl__card">
{s.rows.map((r) => {
if (r.type === 'switch') {
const on = Boolean(vals[r.id])
return (
<button key={r.id} type="button" role="switch" aria-checked={on} className="sd-stl__row" onClick={() => set(r.id, !on)}>
<span className="sd-stl__l">{r.label}</span><span className="sd-stl__sw" aria-checked={on} aria-hidden />
</button>
)
}
if (r.type === 'link') return <button key={r.id} type="button" className="sd-stl__row" aria-label={`${r.label}${r.value ? `, ${r.value}` : ''}`} onClick={() => onChange?.(r.id, 'open')}><span className="sd-stl__l">{r.label}</span>{r.value && <span className="sd-stl__v">{r.value}</span>}{chev}</button>
if (r.type === 'select') return (
<label key={r.id} className="sd-stl__row"><span className="sd-stl__l">{r.label}</span>
<select className="sd-stl__sel" value={String(vals[r.id])} onChange={(e) => set(r.id, e.target.value)}>{r.options.map((o) => <option key={o}>{o}</option>)}</select>
</label>
)
return (
<button key={r.id} type="button" className="sd-stl__row sd-stl__row--danger" onClick={() => { if (arm === r.id) { setArm(null); setMsg(`${r.label}: done`); onChange?.(r.id, true) } else setArm(r.id) }} onBlur={() => setArm(null)}>
{arm === r.id ? r.confirm : r.label}
</button>
)
})}
</div>
{showNotes && s.note && <p className="sd-stl__note">{s.note}</p>}
</section>
))}
{msg && <div className="sd-stl__toast" role="status">{msg}</div>}
</div>
)
}.sd-stl { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-stl[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-stl button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-stl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-stl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-stl { width: min(100%, 330px); display: grid; gap: 18px; }
.sd-stl__sec h3 { margin: 0 0 6px 14px; font-size: 11.5px; font-weight: 500; color: var(--mute); text-transform: uppercase; letter-spacing: 0.06em; }
.sd-stl__card { border-radius: 14px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-stl__row { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 46px; padding: 0 14px; font-size: 14px; text-align: left; position: relative; }
.sd-stl__row + .sd-stl__row::before { content: ''; position: absolute; top: 0; left: 14px; right: 0; height: 1px; background: var(--line); }
button.sd-stl__row:hover, label.sd-stl__row:hover { background: var(--soft) !important; }
.sd-stl__l { flex: 1; }
.sd-stl__v { color: var(--mute); font-size: 13.5px; }
.sd-stl__chev { width: 12px; height: 12px; fill: none; stroke: var(--mute); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.sd-stl__sw { position: relative; width: 44px; height: 26px; border-radius: 99px; background: var(--soft) !important; box-shadow: inset 0 0 0 1px var(--line); flex: none; transition: background-color 220ms; }
.sd-stl__sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 99px; background: var(--mute); transition: transform 280ms cubic-bezier(0.3, 1.5, 0.4, 1), background-color 200ms; }
.sd-stl__sw[aria-checked='true'] { background: var(--strong) !important; box-shadow: none; }
.sd-stl__sw[aria-checked='true']::after { transform: translateX(18px); background: var(--on); }
.sd-stl__sel { height: 30px; padding: 0 6px; border: 0; border-radius: 8px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; }
.sd-stl__row--danger { justify-content: center; font-weight: 600; }
.sd-stl__note { margin: 6px 14px 0; font-size: 12px; color: var(--mute); line-height: 1.45; }
.sd-stl__toast { justify-self: center; font-size: 12.5px; color: var(--mute); animation: sd-stl-in 200ms ease; }
@keyframes sd-stl-in { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .sd-stl *, .sd-stl *::before, .sd-stl *::after { animation: none !important; transition: none !important; } }Overview
One component for the common row types: a switch, a row that shows its current value and navigates, a compact inline select, and a red-flag destructive action that asks once.
Rows group into inset cards with section headers and footnotes, and every row is a full-width hit target.
When to use
- Mobile settings, account preferences, app configuration.
When not to use
- Dense admin forms with many inputs per row.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/settings-list.json$
npx shadcn@latest add https://sundaeui.dev/r/settings-list.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/settings-list.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/settings-list.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy settings-list.tsx and settings-list.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SettingsList } from '@/components/sundae/settings-list'
<SettingsList />Props
| Prop | Type | Default |
|---|---|---|
sections*Sections with title, footnote and rows (switch | link | select | danger). | SettingsSection[] | — |
onChangeCalled when a row changes. | (id: string, value: unknown) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Switch rows use role="switch"; link rows are buttons that name their current value; selects are native.
- The destructive row asks for confirmation inline and announces the result.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-stl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Settings List free?
- Yes. Settings List is free for personal and commercial projects, with full source on this page.
- How do I install Settings List?
- Run npx shadcn@latest add https://sundaeui.dev/r/settings-list.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Settings List support keyboard and screen readers?
- Switch rows use role="switch"; link rows are buttons that name their current value; selects are native. The destructive row asks for confirmation inline and announces the result.
- Does Settings List respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →- Pro
Wheel Picker
Forms · Refined Notification Matrix
Forms · RefinedAsync Switch
Forms · RefinedDisplay Settings
Forms · RefinedOTP Field
Forms · RefinedJelly Switch
Forms · Playful