← ui

setting

ui

a value that falls back to a default: set here, inherited, or locked here

npx shadcn add @ag/setting
Already installed? Preview an update
npx shadcn add @ag/setting --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Changed: its words (the hover tip and what a screen reader hears) say what each state does over time, never "inherited", which read as a value saved here once: "follows lark: changes when lark does", "set here; lark says 16", and for a value set here that equals the one above, "set here, the same as lark: stays if lark changes", the one case that looks like following and is not. The × reads "follow lark again"; a clear is said as "workers: follows lark again, 16".
  • Fixed: Delete or Backspace on the × (or an ⌥-click) clears and hands the focus back to the control, as a press on the × did; it fell to the page when the × went.
Updated 2026-10-12 · release notes

also called inherited value, override

shelf controls

comes with setting-context

uses button, key-router, tooltip

used by components

the demo above, verbatim

setting.demo.tsx
"use client"

import "@/registry/base-nova/ui/edge-fade"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Checkbox } from "@/registry/base-nova/ui/checkbox"
import { Input } from "@/registry/base-nova/ui/input"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { NumberField } from "@/registry/base-nova/ui/number"
import {
  NO_SCROLLBAR,
  ScrollThumbs,
} from "@/registry/base-nova/ui/scroll-thumb"
import { Select } from "@/registry/base-nova/ui/select"
import { type Fallback, Setting } from "@/registry/base-nova/ui/setting"
import { Slider } from "@/registry/base-nova/ui/slider"
import { Switch } from "@/registry/base-nova/ui/switch"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"

// #region layers
/** Three layers: the fleet holds every value, a model falls back to the fleet, a
 *  machine to its model. */
const LAYERS: readonly {
  id: Layer
  kind: "fleet" | "model" | "machine"
  parent?: Layer
}[] = [
  { id: "fleet", kind: "fleet" },
  { id: "lark", kind: "model", parent: "fleet" },
  { id: "lark-01", kind: "machine", parent: "lark" },
  { id: "lark-02", kind: "machine", parent: "lark" },
  { id: "wren", kind: "model", parent: "fleet" },
  { id: "wren-01", kind: "machine", parent: "wren" },
]
type Layer = "fleet" | "lark" | "lark-01" | "lark-02" | "wren" | "wren-01"

interface Values {
  alerts: boolean
  backup: boolean
  channel: string
  level: string
  workers: number
  zone: string
  fan: number
  port: number
}
type Key = keyof Values
/** What each layer sets itself; anything absent is inherited. */
type Store = { [K in Key]: Partial<Record<Layer, Values[K]>> }

const START: Store = {
  alerts: { fleet: true, wren: false, "lark-02": false },
  backup: { fleet: true, "wren-01": false },
  channel: { fleet: "stable", lark: "beta", "lark-01": "stable" },
  level: { fleet: "normal", wren: "verbose" },
  workers: { fleet: 8, lark: 16, "lark-02": 24 },
  zone: { fleet: "eu-west", "wren-01": "eu-north" },
  fan: { fleet: 0.4, wren: 0.7 },
  port: { fleet: 8080, "lark-01": 8081 },
}

/** The value that applies on a layer, and the layer that set it. */
function resolve<K extends Key>(
  store: Store,
  key: K,
  id: Layer,
): Fallback<Values[K]> {
  const own = store[key][id]
  if (own !== undefined) return { value: own, from: id }
  const parent = LAYERS.find((l) => l.id === id)?.parent
  if (!parent) throw new Error(`${key}: ${id} holds no value of its own`)
  return resolve(store, key, parent)
}

const LEVELS = [
  { value: "quiet", label: "quiet" },
  { value: "normal", label: "normal" },
  { value: "verbose", label: "verbose" },
]

/** Each setting: its name, the control that sets it, how its value reads, and
 *  where it is locked. */
const ROWS: {
  [K in Key]: {
    name: string
    format?: (value: Values[K]) => string
    lockedOn?: "machine"
    control: (s: {
      value: Values[K]
      pin: (next: Values[K]) => void
    }) => React.ReactNode
  }
} = {
  alerts: {
    name: "alerts",
    control: (s) => (
      <Switch
        size="sm"
        aria-label="alerts"
        checked={s.value}
        onCheckedChange={s.pin}
      />
    ),
  },
  backup: {
    name: "nightly backup",
    control: (s) => (
      <Checkbox
        aria-label="nightly backup"
        checked={s.value}
        onCheckedChange={s.pin}
      />
    ),
  },
  channel: {
    name: "channel",
    control: (s) => (
      <ToggleGroup
        size="sm"
        aria-label="channel"
        value={s.value}
        onValueChange={s.pin}
      >
        <ToggleGroupItem value="stable">stable</ToggleGroupItem>
        <ToggleGroupItem value="beta">beta</ToggleGroupItem>
      </ToggleGroup>
    ),
  },
  level: {
    name: "log level",
    control: (s) => (
      <Select
        size="sm"
        aria-label="log level"
        options={LEVELS}
        value={s.value}
        onValueChange={(v) => v && s.pin(v)}
        className="min-w-28"
      />
    ),
  },
  workers: {
    name: "workers",
    control: (s) => (
      <NumberField
        size="sm"
        label="workers"
        value={s.value}
        onValueChange={s.pin}
        min={1}
        max={64}
        whole
        bar={false}
        chevrons={false}
        className="w-20"
      />
    ),
  },
  zone: {
    name: "zone",
    format: (v) => `“${v}”`,
    control: (s) => (
      <Input
        size="sm"
        aria-label="zone"
        value={s.value}
        onChange={(e) => s.pin(e.target.value)}
        className="w-24"
      />
    ),
  },
  fan: {
    name: "fan",
    format: (v) => `${Math.round(v * 100)}%`,
    control: (s) => (
      <Slider
        label="fan"
        value={s.value}
        onValueChange={s.pin}
        format={(v) => `${Math.round(v * 100)}%`}
        className="w-24"
      />
    ),
  },
  port: {
    name: "port",
    lockedOn: "machine",
    control: (s) => (
      <NumberField
        size="sm"
        label="port"
        value={s.value}
        onValueChange={s.pin}
        min={1}
        max={65535}
        whole
        bar={false}
        chevrons={false}
        format={String}
        className="w-20"
      />
    ),
  },
}
const KEYS = Object.keys(ROWS) as Key[]

/** A value as the captions say it: on and off for a switch, else as written. */
const words = (v: unknown) =>
  typeof v === "boolean" ? (v ? "on" : "off") : String(v)

/** One cell: the layer's own value, or what applies from above, and where from. */
function Cell<K extends Key>({
  k,
  layer,
  store,
  set,
}: {
  k: K
  layer: (typeof LAYERS)[number]
  store: Store
  set: (k: K, id: Layer, v: Values[K] | undefined) => void
}) {
  const row = ROWS[k]
  const own = store[k][layer.id]
  const above = layer.parent ? resolve(store, k, layer.parent) : undefined
  return (
    <div className="flex flex-col items-start gap-1">
      <Setting
        label={`${row.name} on ${layer.id}`}
        value={own}
        fallback={above}
        onValueChange={(v) => set(k, layer.id, v)}
        locked={row.lockedOn === layer.kind ? "provisioning" : undefined}
        format={row.format}
      >
        {row.control}
      </Setting>
      {/* What the cell does when the layer above changes: follows it, or keeps its
          own value; set here, what the layer above says (the same, which looks
          like following and is not, or its own). Two lines whatever they say, and
          never wider than the control (`w-0 min-w-full`): a changing caption moved
          the grid. */}
      <span className="flex w-0 min-w-full flex-col font-mono text-[10px] text-current/45">
        <span>{own === undefined ? `follows ${above?.from}` : "set here"}</span>
        <span className="min-h-[1lh] truncate">
          {own === undefined || !above
            ? ""
            : Object.is(own, above.value)
              ? `same as ${above.from}`
              : `${above.from} says ${(row.format ?? words)(above.value)}`}
        </span>
      </span>
    </div>
  )
}

/** Every setting on every layer. Change the fleet or a model and what inherits
 *  follows; the port is set by provisioning on every machine. */
function ConfigGrid() {
  const [store, setStore] = React.useState(START)
  const scroller = React.useRef<HTMLDivElement>(null)
  const set = <K extends Key>(k: K, id: Layer, v: Values[K] | undefined) =>
    setStore((s) => ({ ...s, [k]: { ...s[k], [id]: v } }))
  return (
    <div className="space-y-3">
      <div
        ref={scroller}
        className={`ag-edge-fade relative pb-2 ${NO_SCROLLBAR}`}
      >
        <ScrollThumbs scroller={scroller} />
        <div className="grid w-max grid-cols-[6rem_repeat(6,max-content)] items-start gap-x-5 gap-y-3">
          <span />
          {LAYERS.map((l) => (
            <span key={l.id} className="flex flex-col font-mono">
              <span className="text-xs">{l.id}</span>
              <span className="text-[10px] text-current/45">
                {l.kind === "machine" ? `machine of ${l.parent}` : l.kind}
              </span>
            </span>
          ))}
          {KEYS.map((k) => (
            <React.Fragment key={k}>
              <span className="pt-1 font-mono text-[11px] text-current/60">
                {ROWS[k].name}
              </span>
              {LAYERS.map((l) => (
                <Cell key={l.id} k={k} layer={l} store={store} set={set} />
              ))}
            </React.Fragment>
          ))}
        </div>
      </div>
      <Button variant="ghost" size="sm" onClick={() => setStore(START)}>
        start over
      </Button>
    </div>
  )
}
// #endregion

// #region machine
/** One machine's settings, in glass over a picture: the same rule, the glass's own
 *  ghost (thinner, its rim softer) where a value is inherited. */
function Machine() {
  const [alerts, setAlerts] = React.useState<boolean | undefined>(undefined)
  const [workers, setWorkers] = React.useState<number | undefined>(24)
  const [level, setLevel] = React.useState<string | undefined>(undefined)
  return (
    <div className="flex flex-col items-start gap-3">
      <Setting
        label="alerts"
        value={alerts}
        fallback={{ value: true, from: "lark" }}
        onValueChange={setAlerts}
      >
        {(s) => (
          <Switch checked={s.value} onCheckedChange={s.pin}>
            alerts
          </Switch>
        )}
      </Setting>
      <Setting
        label="workers"
        value={workers}
        fallback={{ value: 16, from: "lark" }}
        onValueChange={setWorkers}
      >
        {(s) => (
          <NumberField
            label="workers"
            handle="workers"
            value={s.value}
            onValueChange={s.pin}
            min={1}
            max={64}
            whole
            bar={false}
            chevrons={false}
          />
        )}
      </Setting>
      <Setting
        label="log level"
        value={level}
        fallback={{ value: "verbose", from: "wren" }}
        onValueChange={setLevel}
      >
        {(s) => (
          <Select
            aria-label="log level"
            options={LEVELS}
            value={s.value}
            onValueChange={(v) => v && s.pin(v)}
          />
        )}
      </Setting>
      <Setting
        label="port"
        value={undefined}
        fallback={{ value: 8080, from: "the fleet" }}
        onValueChange={() => {}}
        locked="provisioning"
      >
        {(s) => (
          <NumberField
            label="port"
            handle="port"
            value={s.value}
            onValueChange={s.pin}
            format={String}
            bar={false}
            chevrons={false}
          />
        )}
      </Setting>
    </div>
  )
}
// #endregion

export default function SettingDemo() {
  const [threads, setThreads] = React.useState<number | undefined>(undefined)
  return (
    <div className="space-y-8">
      <Sample
        name="layers"
        title="fleet, model, machine"
        description="each cell is a setting that follows the layer above (a ghost: it changes when that does) or holds its own value (× to follow again; also ⌥-click, or Delete on the control; an emptied number or text field). a value set here equal to the one above still stays when that one changes, and says so. hover or focus a cell to read the same."
        with="layers"
      >
        <ConfigGrid />
      </Sample>
      <Sample name="glass" title="in glass" with="machine">
        <MaterialProvider material="glass" scheme="dark">
          <div className="rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-6 text-white">
            <Machine />
          </div>
        </MaterialProvider>
      </Sample>
      <Sample name="one" glance title="one setting">
        <Setting
          label="threads"
          value={threads}
          fallback={{ value: 8, from: "the model" }}
          onValueChange={setThreads}
        >
          {(s) => (
            <NumberField
              label="threads"
              handle="threads"
              value={s.value}
              onValueChange={s.pin}
              min={1}
              max={64}
              whole
              bar={false}
              chevrons={false}
            />
          )}
        </Setting>
      </Sample>
    </div>
  )
}

the source, every file an install writes

setting.tsx
"use client"

import { Lock, X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
  type Fallback,
  SettingContext,
  type SettingContextValue,
} from "@/registry/base-nova/ui/setting-context"
import { Tip } from "@/registry/base-nova/ui/tooltip"

export type { Fallback } from "@/registry/base-nova/ui/setting-context"

// A SETTING THAT FALLS BACK TO A DEFAULT: a machine's value over its model's over the
// fleet's, a project's over the workspace's. It is in one of three states:
//
//   following    `value` is undefined; `fallback.value` applies and changes when it
//                does, said to come from `fallback.from` ("inherited" in the code,
//                never in the words a person reads: it read as a value saved here)
//   set here     `value` holds it, and keeps it whatever the fallback does: a value
//                set here equal to the fallback's is a real choice (it stays when
//                the fallback moves) and is said as one ("the same as lark")
//   locked here  `locked` names who sets it; it is a readout (read-only, never
//                disabled: it keeps its place in the Tab order and is read out)
//
// One rule for every value control. The control inside reads the setting
// (`useSetting()`, setting-context) and draws the value that applies, as a GHOST
// while it is inherited (a dashed edge, a hollow mark, the same colour at less
// strength; in glass the material's own ghost). Where the value comes from is named
// on hover and focus, and to a screen reader as the control's description.
//
// The slot after the control holds × while the value is set here (shown under the
// hand and on focus, reachable by Tab; focus comes back to the control after it),
// a padlock while it is locked, nothing while it is inherited. Clearing is also
// ⌥-click on the control, and Delete or Backspace on a control that does not edit
// text with them (`ownedHere` decides, as for every key in the registry); a number
// or a text field clears on an empty commit. A clear is said in a polite live
// region.
//
//   <Setting label="workers" value={own} fallback={{ value: 16, from: "lark" }}
//            onValueChange={setOwn}>
//     {(s) => <NumberField label="workers" value={s.value} onValueChange={s.pin} />}
//   </Setting>

export interface SettingProps<T> {
  /** Its name, said when it is cleared ("workers: follows lark again, 16"). */
  label: string
  /** Set here; undefined, it is inherited. */
  value: T | undefined
  /** What applies when nothing is set here. Absent: the root of the chain, which
   *  holds every value itself. */
  fallback?: Fallback<T>
  /** A value sets it here; undefined clears it back to the fallback. */
  onValueChange: (next: T | undefined) => void
  /** Who sets it, when it cannot be changed here ("provisioning"). */
  locked?: string
  /** A value as a person reads it. Default: on and off for a yes or no, else as
   *  written. */
  format?: (value: T) => string
  className?: string
  /** The control, given the value that applies and how to set it. */
  children: (setting: { value: T; pin: (next: T) => void }) => React.ReactNode
}

const asWords = (v: unknown) =>
  typeof v === "boolean" ? (v ? "on" : "off") : String(v)

/** The value that applies: set here, else the fallback's. A value set nowhere is a
 *  chain with no root, which screams. */
function applies<T>(
  label: string,
  value: T | undefined,
  fallback: Fallback<T> | undefined,
): T {
  if (value !== undefined) return value
  if (fallback === undefined)
    throw new Error(
      `setting "${label}": nothing set here and nothing above it to inherit`,
    )
  return fallback.value
}

/** The control a clear hands focus back to: the one in its Tab stop (a choice's
 *  pressed option), else its first button or field. */
function focusIn(el: HTMLElement | null) {
  const target =
    el?.querySelector<HTMLElement>('[tabindex="0"]') ??
    el?.querySelector<HTMLElement>(
      'button, input:not([type=hidden]):not([aria-hidden="true"]), [role=slider]',
    )
  target?.focus()
}

export function Setting<T>({
  label,
  value,
  fallback,
  onValueChange,
  locked,
  format = asWords,
  className,
  children,
}: SettingProps<T>) {
  const id = React.useId()
  const says = `${id}-says`
  const control = React.useRef<HTMLSpanElement>(null)
  const [said, setSaid] = React.useState("")
  const inherited = value === undefined
  const effective = applies(label, value, fallback)
  const readOnly = locked !== undefined
  const clearable = !inherited && !readOnly && fallback !== undefined

  // The × hides once it has cleared, so the focus it held goes back to the control
  // after the render that shows the fallback: a field focused before it would take
  // up the value it is leaving, and set it here again when it lost the focus.
  const refocus = React.useRef(false)
  React.useEffect(() => {
    if (!refocus.current || !inherited) return
    refocus.current = false
    focusIn(control.current)
  })

  const clear = React.useCallback(() => {
    if (!fallback) return
    onValueChange(undefined)
    setSaid(
      `${label}: follows ${fallback.from} again, ${format(fallback.value)}`,
    )
  }, [fallback, onValueChange, label, format])

  /** A clear asked for from `from`: one from outside the control (the × itself, by a
   *  press, a Delete or an ⌥-click) takes the × away with the focus it may hold, so the
   *  focus goes back to the control. */
  const clearFrom = (from: EventTarget) => {
    refocus.current = !control.current?.contains(from as Node)
    clear()
  }

  const pin = (next: T) => {
    if (readOnly) return
    onValueChange(next)
    if (inherited) setSaid(`${label}: set here, ${format(next)}`)
  }

  // Said as what each state does over time, never "inherited", which reads as a value
  // saved here once (Adrian, 2026-10-12). Following, it changes when the fallback
  // does; set here, it stays, and a value set here that equals the fallback's says
  // so, since it looks the same as following and is not.
  const words = readOnly
    ? `locked here, set by ${locked}`
    : !fallback
      ? undefined
      : inherited
        ? `follows ${fallback.from}: changes when ${fallback.from} does`
        : Object.is(value, fallback.value)
          ? `set here, the same as ${fallback.from}: stays if ${fallback.from} changes`
          : `set here; ${fallback.from} says ${format(fallback.value)}`

  const described = words ? says : undefined
  const context = React.useMemo<SettingContextValue>(
    () => ({
      inherited,
      readOnly,
      says: described,
      clear: clearable ? clear : null,
      fallback: fallback ?? null,
    }),
    [inherited, readOnly, described, clearable, clear, fallback],
  )

  const root = (
    // One group: the control, its slot and the words about it.
    <fieldset
      data-slot="setting"
      data-inherited={inherited ? "" : undefined}
      data-locked={readOnly ? "" : undefined}
      className={cn(
        "group/setting inline-flex min-w-0 items-center gap-1",
        className,
      )}
      onKeyDown={(e) => {
        if (!clearable || (e.key !== "Delete" && e.key !== "Backspace")) return
        if (ownedHere(e.nativeEvent, e.currentTarget)) return
        e.preventDefault()
        clearFrom(e.target)
      }}
      // ⌥-click clears what is set here, taken before the control sees the press,
      // so a switch does not also flip and a select does not also open.
      onPointerDownCapture={(e) => {
        if (!clearable || !e.altKey) return
        e.preventDefault()
        e.stopPropagation()
      }}
      onClickCapture={(e) => {
        if (!clearable || !e.altKey) return
        e.preventDefault()
        e.stopPropagation()
        clearFrom(e.target)
      }}
    >
      <span ref={control} data-slot="setting-control" className="contents">
        <SettingContext.Provider value={context}>
          {children({ value: effective, pin })}
        </SettingContext.Provider>
      </span>
      {/* The slot: kept whatever it holds, so setting or clearing moves nothing. */}
      <span
        data-slot="setting-slot"
        className="grid size-5 shrink-0 place-items-center"
      >
        {readOnly ? (
          <Lock aria-hidden className="size-3 text-current/50" />
        ) : clearable ? (
          <Button
            variant="ghost"
            size="icon-sm"
            data-slot="setting-clear"
            aria-label={`follow ${fallback.from} again, ${format(fallback.value)}`}
            onClick={(e) => clearFrom(e.currentTarget)}
            className="size-5 opacity-0 group-focus-within/setting:opacity-100 group-hover/setting:opacity-100 focus-visible:opacity-100 [&_svg]:size-3"
          >
            <X aria-hidden />
          </Button>
        ) : null}
      </span>
      {words && (
        <span id={says} className="sr-only">
          {words}
        </span>
      )}
      <span aria-live="polite" className="sr-only">
        {said}
      </span>
    </fieldset>
  )
  // Where the value comes from, named under the hand and on focus. The root holds
  // every value itself and has nothing to name.
  return words ? (
    <Tip content={words} delay={300}>
      {root}
    </Tip>
  ) : (
    root
  )
}