what a value control reads from the setting around it, without its slot and keys
npx shadcn add @ag/setting-contextAlready installed? Preview an update
npx shadcn add @ag/setting-context --dry-runYour 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) andGLASS_GHOST(--glass-ghost). Its own item, so a control reads its setting without installing the slot and the keys.
shelf controls
part of setting
used by slider, number, input, checkbox, switch, select, toggle-group, rating
the demo above, verbatim
"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
"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]"