Settings List
Grouped settings rows — label and description on the left, your own control on the right, with whole-row links, badges, disabled rows and a danger zone.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/settings-list.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SettingsList" component — grouped settings rows
that host somebody else's controls. Deps: lucide-react (one chevron icon) + cn().
Contract
- export const SettingsList = forwardRef<HTMLDivElement, SettingsListProps>, spreading
the remaining HTMLAttributes onto the root <div>.
- Props: groups: SettingsGroup[] (required), variant?: "card" | "flush" ("card"),
density?: "comfortable" | "compact" ("comfortable"), className.
- interface SettingsGroup { id: string; title?: ReactNode; description?: ReactNode;
rows: SettingsRow[] }
- interface SettingsRow {
id: string // unique inside its group; React key + id stem
label: ReactNode // names the row AND the control inside it
description?: ReactNode
control?: ReactNode // the interactive part: switch, select, button…
href?: string // makes the whole row a real <a>
onActivate?: () => void // makes the whole row a real <button> when there is no href
badge?: ReactNode // a plain string renders as a pill; an element renders as-is
danger?: boolean
disabled?: boolean
}
- Also export the shape the row injects, so a custom control can be typed:
interface SettingsControlProps {
"aria-labelledby"?: string
"aria-describedby"?: string
disabled?: boolean
}
- The component owns no values and runs no logic. Every control is passed in already
wired to the consumer's state; SettingsList only lays rows out and connects labels.
Behavior — whole-row activation (stretched link)
- href wins over onActivate for the element type: with href the row action is a real
<a> (onActivate, if also given, becomes its onClick); with only onActivate it is a
real <button type="button">. Never a clickable <div>, never <a> wrapping <button>.
- The action element renders INSIDE the label and grows an ::after pseudo-element with
absolute inset-0 over the row (the <li> is position:relative). That is what makes the
whole row clickable while keeping exactly one interactive element in the tree — no
nesting, no duplicated tab stop, and the label text stays the accessible name.
- The focus ring is drawn on that same overlay (focus-visible:outline-none on the anchor
plus outline on ::after) with a NEGATIVE outline offset, so the ring reads as a full
row and is not eaten by the card's overflow-hidden.
- An activatable row gets a hover tint (destructive when danger, accent otherwise) and a
trailing chevron. Both are absent on rows that do nothing, so "looks clickable" and
"is clickable" can never drift apart.
- A DISABLED row drops the action element entirely instead of rendering a dead one: a
disabled-looking <a href> would still be focusable and announced as a link. The row
keeps its text, dims to opacity-60 and exposes data-disabled for styling hooks.
Behavior — the control slot
- The control lives in a sibling column, never inside the row action, so clicking a
switch can never also fire the row's navigation.
- Wrapping column is pointer-events-none and the control itself opts back in with
pointer-events-auto + relative z-10 (above the ::after overlay). Net effect: the
padding AROUND the control still belongs to the row action, the control itself does
not. Disabled rows never opt back in.
- If `control` is a single React element, cloneElement injects the wiring:
aria-labelledby → the row label's id, aria-describedby → the row description's id
(appended to any the control already had), and disabled: true when the row is
disabled. The injection is ADDITIVE: a control that already carries aria-label or
aria-labelledby keeps its own name. Anything that is not a single element (a string,
a number, an array) is rendered untouched — wrap multi-element controls in one
element so there is somewhere to inject into.
- The chevron sits in the same pointer-events-none column so it is never a hit target;
it is aria-hidden decoration for the row action.
Behavior — structure and edge cases
- One useId() seeds everything and ids are scoped by group as well as by row
(`${uid}-${group.id}-${row.id}-label`), so two groups may each own a row called
"email" without colliding into one duplicated DOM id.
- Each group is a <div role="group"> labelled by its own <h3> via aria-labelledby (the
attribute is omitted when the group has no title, rather than pointing at nothing).
- Rows are <li> children of a <ul role="list"> with NOTHING role-less in between —
an unlabelled wrapper div there makes screen readers announce an empty list.
- A group with zero rows renders no <ul> at all and its header keeps no bottom border;
a group with neither title nor description renders no header block; groups={[]}
renders an empty container. No empty <ul>, no dangling dividers.
Rendering & styling
- Semantic tokens only: bg-card + border + rounded-xl for the card variant, divide-border
for row dividers, text-muted-foreground for descriptions and the chevron,
text-destructive for danger labels, hover:bg-accent/50 (hover:bg-destructive/5 when
danger), outline-ring for focus. No hardcoded colors, no palette classes.
- variant="card" wraps each group in a bordered, overflow-hidden card with padded rows;
variant="flush" drops the card and runs edge-to-edge dividers (border-y on the list) —
use it inside a panel that already has its own frame.
- density only changes padding and gaps. Keep the two variants × two densities in ONE
lookup table of class strings so the structure is provably identical across them.
- The root is a container query context (@container/settings). A row that HAS a control
is flex-col by default and becomes flex-row @md, so in a narrow drawer the control
drops under the description instead of squeezing the text into a column of single
words; a row with no control stays on one line at every width. Long labels wrap via
break-words; the text column is min-w-0 flex-1 so it can actually shrink.
- The only transition is the hover background on activatable rows, and it carries
motion-reduce:transition-none. Nothing about the component depends on animation.
Customization levers
- Density and spacing: the SPACING lookup (root gap / header padding / row padding per
variant × density) is the single knob for rhythm — edit those four class strings
rather than sprinkling padding through the JSX.
- Reflow point: swap @md/settings for @sm or @lg to decide how early a row with a
control goes two-column. On Tailwind v3 (no container queries) replace the @md/settings
prefix with sm: and it degrades to a viewport rule.
- Which sub-blocks exist is prop-driven: no title, no description, no badge, no control
emits no markup. A leading icon is just a ReactNode inside `label`; a trailing helper
link is just another element inside `control`.
- Badge presentation: strings get the built-in outline pill; pass your own <Badge> for
status colours. Swap the ChevronRight for ExternalLink on rows that leave the app.
- Danger treatment: `danger` currently tints the label and the hover background — add a
destructive left border or a tinted group header if your product wants it louder.
- Controls are entirely yours: drop in shadcn Switch / Select / Button (or the toggle-switch
and combobox components from this library). They only need to accept aria-labelledby,
aria-describedby and disabled to inherit the row's wiring.
- Sectioning: for a settings page with a left rail, render one SettingsList per rail
section rather than one giant list — each list owns its own useId scope.Concepts
- Stretched-link row — the row action is one real
<a>or<button>around the label whose::afteroverlay covers the whole<li>. The hit area is the entire row, but the accessibility tree still sees a single link named by the label — not a clickablediv, and not an anchor wrapping a button. - Control island — the control's column is
pointer-events-noneand only the control itself opts back in, sitting onz-10above the overlay. So the gutter around a switch belongs to the row action while the switch belongs to itself; one row can navigate on click and still host a toggle that doesn't navigate. - Additive wiring injection —
cloneElementpushesaria-labelledby,aria-describedbyanddisabledfrom the row into a single-element control, which is why a bare<input type="checkbox">in a row is already named and described. A control that brought its ownaria-labelkeeps it; anything that isn't a single element is left alone. - Disabled means no action element — instead of rendering a greyed-out link, a disabled row omits the action entirely. A visually disabled anchor is still tabbable and still announced as a link, which is the usual way a "locked by your admin" row lies to a screen reader.
- Unbroken ownership chain —
role="group"labelled by the group heading, thenrole="list", then<li>rows with nothing role-less wedged in between. A plain wrapperdivbetween the list and its items is enough to make a screen reader announce an empty list. - Container-query reflow — the row measures the list's own width, not the viewport, so the same settings row is two columns in a wide page and stacked in a 360px drawer without the consumer choosing a breakpoint. Only rows that actually carry a control reflow.
Split Pane
A dependency-free resizable split layout — percentage flex-basis panes, per-pane percent/px minimums, double-click collapse, full keyboard resizing, and an optional layout that survives reloads.
Scroll Area
A themed scroll container that keeps native scrolling untouched — the platform bar is hidden and a draggable thumb is drawn from scrollTop/scrollHeight, with optional edge fades and an end-reached callback.