Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The wiring SettingsList injects into a `control` that is a single React element.
 * The row's label names the control and its description describes it, so consumers
 * never have to invent ids for a switch sitting in a settings row.
 */
export interface SettingsControlProps {
  "aria-labelledby"?: string
  "aria-describedby"?: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/settings-list.json

Prompt

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 ::after overlay 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 clickable div, and not an anchor wrapping a button.
  • Control island — the control's column is pointer-events-none and only the control itself opts back in, sitting on z-10 above 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 injectioncloneElement pushes aria-labelledby, aria-describedby and disabled from 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 own aria-label keeps 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 chainrole="group" labelled by the group heading, then role="list", then <li> rows with nothing role-less wedged in between. A plain wrapper div between 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.

On This Page