← ui

setting-context

ui

what a value control reads from the setting around it, without its slot and keys

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

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

Latest changes · 2026-10-11
  • New: what a value control reads from the setting around it, the way it reads useField(): useSetting() (inherited, read-only, the id of the words that describe it, the clear, the fallback), settingProps(setting, describedBy) for a control's own props, and the ghost's one spelling, GHOST_EDGE (a dashed edge on ::before, plain) and GLASS_GHOST (--glass-ghost). Its own item, so a control reads its setting without installing the slot and the keys.
Updated 2026-10-11 · release notes

shelf controls

part of setting

used by slider, number, input, checkbox, switch, select, toggle-group, rating

the demo above, verbatim

setting-context.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import { Setting } from "@/registry/base-nova/ui/setting"
import { GHOST_EDGE, useSetting } from "@/registry/base-nova/ui/setting-context"

// #region swatches
const COLOURS = ["sage", "clay", "slate"] as const
type Colour = (typeof COLOURS)[number]
const INK: Record<Colour, string> = {
  sage: "bg-[oklch(63%_0.08_145)]",
  clay: "bg-[oklch(62%_0.12_40)]",
  slate: "bg-[oklch(55%_0.03_250)]",
}

/** A value control of your own: inside a setting it reads where its value comes
 *  from, draws the chosen swatch as a ghost while it is inherited (the dashed edge,
 *  the colour at less strength), is described by the setting's words, and holds
 *  still while the setting is locked. */
function Swatches({
  value,
  onValueChange,
}: {
  value: Colour
  onValueChange: (value: Colour) => void
}) {
  const setting = useSetting()
  const ghost = setting?.inherited ?? false
  return (
    <div
      role="radiogroup"
      aria-label="accent"
      aria-describedby={setting?.says}
      aria-readonly={setting?.readOnly || undefined}
      className="flex gap-2"
    >
      {COLOURS.map((c) => (
        // biome-ignore lint/a11y/useSemanticElements: a swatch is a radio drawn as its colour
        <button
          key={c}
          type="button"
          role="radio"
          aria-checked={value === c}
          aria-label={c}
          onClick={() => {
            if (setting?.readOnly) return
            // Pressing the colour that applies while it is inherited sets it here.
            onValueChange(c)
          }}
          className={cn(
            "relative size-6 rounded-full",
            INK[c],
            value === c
              ? ghost
                ? cn("opacity-55 outline-none", GHOST_EDGE, "before:-inset-1")
                : "ring-2 ring-current ring-offset-2 ring-offset-background"
              : "opacity-35 hover:opacity-70",
          )}
        />
      ))}
    </div>
  )
}
// #endregion

export default function SettingContextDemo() {
  const [accent, setAccent] = React.useState<Colour | undefined>(undefined)
  return (
    <div className="space-y-8">
      <Sample
        name="own"
        glance
        title="a control of your own"
        description="it reads the setting around it: a ghost of the colour that applies while it is inherited, described by where it comes from"
        with="swatches"
      >
        <Setting
          label="accent"
          value={accent}
          fallback={{ value: "sage" as Colour, from: "the workspace" }}
          onValueChange={(next) => setAccent(next)}
        >
          {(s) => <Swatches value={s.value} onValueChange={s.pin} />}
        </Setting>
      </Sample>
    </div>
  )
}

the source, every file an install writes

setting-context.tsx
"use client"

import * as React from "react"

// WHAT A VALUE CONTROL READS FROM THE SETTING AROUND IT, and nothing else: the
// context `setting` provides, its own item so a switch, a number or a select can
// read it without installing the setting's slot, keys and words. The way a control
// reads `useField()` for its label, it reads `useSetting()` for where its value
// comes from.
//
// Inside a setting a value control:
//   inherited  draws its INHERITED face: a ghost of the value that applies (a
//              dashed edge plain, `--glass-ghost` in glass, its mark hollow, the
//              same colour at less strength), `data-inherited` on its root
//   says       is described by it (`aria-describedby`): where the value comes from
//   readOnly   holds still (locked here): takes no change, keeps its focus, is read
//              by a screen reader; never disabled
//   clear      what an empty commit does (a number, a text field) or a list's
//              "inherit" entry; null where there is nothing to clear
//   fallback   what applies when nothing is set here, and who says so
//
// The value itself is never in here: it reaches the control through its own props
// (the setting's render prop hands them over), so a control has one source for it.

/** What applies when nothing is set here, and who says so ("lark", "the fleet"). */
export interface Fallback<T> {
  value: T
  from: string
}

/** What a control inside a setting reads (`useSetting()`). */
export interface SettingContextValue {
  /** Nothing is set here: the fallback applies, drawn as a ghost. */
  inherited: boolean
  /** Locked here (set somewhere else): a readout. Never disabled. */
  readOnly: boolean
  /** The id of the words that say where the value comes from, for the control's
   *  `aria-describedby`; undefined at the root, which has nothing to say. */
  says: string | undefined
  /** Clear back to the fallback; null while inherited, locked, or at the root. */
  clear: (() => void) | null
  /** What applies when nothing is set here; null at the root of the chain. */
  fallback: Fallback<unknown> | null
}

export const SettingContext = React.createContext<SettingContextValue | null>(
  null,
)

/** The setting around a control, or null outside one. */
export const useSetting = () => React.useContext(SettingContext)

/** The setting as a value control's own props, spread after a field's and before
 *  the consumer's: described by where the value comes from (beside what describes
 *  it already, `around`), read-only while locked. */
export function settingProps(
  setting: SettingContextValue | null,
  around?: string,
): { "aria-describedby"?: string; readOnly?: boolean } {
  if (!setting) return {}
  const described = [around, setting.says].filter(Boolean).join(" ")
  return {
    ...(described ? { "aria-describedby": described } : {}),
    ...(setting.readOnly ? { readOnly: true } : {}),
  }
}

/** The plain face's ghost edge: a dashed line round the control in its own ink at
 *  less than half strength, on `::before` (a plain face draws nothing there; glass,
 *  whose edge is its light on both pseudo-elements, wears `--glass-ghost` instead).
 *  On a positioned element, rounded as the control is. One spelling for every
 *  control, so every inherited value reads alike. */
export const GHOST_EDGE =
  "before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:border before:border-dashed before:border-current/45 before:content-['']"

/** The glass face's ghost: the material's own state (thinner body, softer rim,
 *  fainter stain), since a glass rim is light and cannot be dashed. */
export const GLASS_GHOST = "[--glass-ghost:1]"