In a bulk edit, shows "Mixed (3 values)" with the spread instead of a blank, so saving doesn't silently overwrite every record.
StopsBulk edit overwrites every record
"use client"
import * as React from "react"
import { countChanged, MixedValueField, type BulkChange } from "@/components/ui/mixed-value-field"
const repeat = <T,>(value: T, times: number) => Array<T>(times).fill(value)
// 40 selected tasks.
const status = [...repeat("todo", 18), ...repeat("doing", 10), ...repeat("done", 12)]
const assignee = [...repeat("Priya", 20), ...repeat("Rahul", 15), ...repeat(null, 5)]
const statusOptions = [
{ value: "todo", label: "To do" },
{ value: "doing", label: "In progress" },
{ value: "done", label: "Done" },
]
const tasks = (count: number) => `${count} ${count === 1 ? "task" : "tasks"}`
export function Demo() {
const [statusChange, setStatusChange] = React.useState<BulkChange>({
set: "done",
})
const [assigneeChange, setAssigneeChange] = React.useState<BulkChange>(null)
const [saved, setSaved] = React.useState("")
const edits = [
statusChange && countChanged(status, statusChange),
assigneeChange && countChanged(assignee, assigneeChange),
].filter(Boolean).length
return (
<form
className="grid w-full max-w-sm gap-4"
onSubmit={(event) => {
event.preventDefault()
setSaved(`Saved ${edits} ${edits === 1 ? "field" : "fields"}. Untouched fields kept their own values.`)
}}
>
<p className="text-sm font-medium">Edit 40 tasks</p>
<MixedValueField
label="Status"
values={status}
options={statusOptions}
value={statusChange}
onValueChange={setStatusChange}
formatItems={tasks}
/>
<MixedValueField
label="Assignee"
values={assignee}
value={assigneeChange}
onValueChange={setAssigneeChange}
formatItems={tasks}
allowClear
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground" aria-live="polite">
{saved}
</p>
<button
type="submit"
disabled={!edits}
className="inline-flex h-9 shrink-0 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-xs transition-colors outline-none hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
>
Apply {edits === 1 ? "1 change" : `${edits} changes`}
</button>
</div>
</form>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uimixed-value-field.tsx248 lines"use client" // Bollard UI — mixed-value-field · MIT licence · https://bollard-ui.vercel.app/components/mixed-value-field import * as React from "react" import { Eraser, PencilLine, Undo2 } from "lucide-react" import { cn } from "@/lib/utils" /** `null` keeps every record's own value; `{ set }` writes one value to all (`set: null` clears it). */ export type BulkChange = { set: string | null } | null /** Each distinct value and how many records hold it, most common first. `null` is "empty". */ export function summarizeValues(values: (string | null | undefined)[]) { const counts = new Map<string | null, number>() for (const value of values) counts.set(value || null, (counts.get(value || null) ?? 0) + 1) return [...counts].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count) } /** How many records a change would actually alter: records that already hold the value are skipped. */ export function countChanged(values: (string | null | undefined)[], change: BulkChange) { if (!change) return 0 return values.filter((value) => (value || null) !== (change.set || null)).length } const swatches = ["bg-sky-500", "bg-violet-500", "bg-emerald-500", "bg-amber-500", "bg-rose-500"] const KEEP = "\u0000keep" const CLEAR = "\u0000clear" const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match)) const fieldClass = "h-9 w-full min-w-0 rounded-md border bg-transparent px-3 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 data-changed:border-sky-500 dark:data-changed:border-sky-400" const ghost = "inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-muted-foreground transition-colors outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-accent aria-pressed:text-foreground" export interface MixedValueFieldProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "children"> { /** Field name, e.g. "Status". */ label: string /** The field's current value on each selected record. Empty strings count as empty. */ values: (string | null | undefined)[] /** Choices for a select. Leave out for a free-text field. */ options?: { value: string; label?: string }[] /** Controlled change. */ value?: BulkChange /** Initial change when uncontrolled. */ defaultValue?: BulkChange /** Called with the planned change: null keeps values, { set } writes one value. */ onValueChange?: (change: BulkChange) => void /** Offer "Clear on all" so emptying the field is an explicit choice, never an accident. */ allowClear?: boolean /** "40 tasks". Override to name your records or translate. */ formatItems?: (count: number) => string /** Templates: `{count}`, `{field}`, `{value}`, `{changed}`, `{total}`, `{already}`, `{items}` are filled in. */ mixedText?: string /** Shown when every record has the same value. */ uniformText?: string /** Label for empty values. */ emptyText?: string /** Readback when nothing will change. */ keepText?: string /** Readback template for setting a value. */ setText?: string /** Readback for records that already hold the value. */ alreadyText?: string /** Readback template for clearing the field. */ clearText?: string /** Shown when the change alters no records. */ noopText?: string /** Clear button text. */ clearLabel?: string /** Revert button text. */ revertLabel?: string } const defaultItems = (count: number) => `${count.toLocaleString()} ${count === 1 ? "record" : "records"}` /** * A field for editing many records at once. Instead of a misleading blank or * the first record's value it shows "Mixed (3 values)" with the distribution, * leaves each record's value alone unless you choose one, and reads back * exactly what saving will do, counting only the records that actually change. */ export function MixedValueField({ label, values, options, value, defaultValue = null, onValueChange, allowClear = false, formatItems = defaultItems, mixedText = "Mixed ({count} values)", uniformText = "{value} on all", emptyText = "Empty", keepText = "{field} stays as it is on each of the {items}.", setText = "Set {field} to {value} on {changed} of {items}.", alreadyText = "{already} already {value}.", clearText = "Clear {field} on {changed} of {items}.", noopText = "All {items} are already {value}. Nothing changes.", clearLabel = "Clear on all", revertLabel = "Keep as is", id, className, ...props }: MixedValueFieldProps) { const [inner, setInner] = React.useState<BulkChange>(defaultValue) const autoId = React.useId() const fieldId = id ?? autoId const change = value === undefined ? inner : value const summary = React.useMemo(() => summarizeValues(values), [values]) const total = values.length const changed = countChanged(values, change) const update = (next: BulkChange) => { if (value === undefined) setInner(next) onValueChange?.(next) } const nameOf = (v: string | null) => (v === null ? emptyText : (options?.find((o) => o.value === v)?.label ?? v)) const current = summary.length === 1 ? fill(uniformText, { value: nameOf(summary[0].value) }) : fill(mixedText, { count: summary.length }) const vars = { field: label, items: formatItems(total), total, changed, value: change ? nameOf(change.set) : "", } const already = change ? total - changed : 0 const readback = !change ? fill(keepText, vars) : changed === 0 ? fill(noopText, vars) : change.set === null ? fill(clearText, vars) : `${fill(setText, vars)}${already ? ` ${fill(alreadyText, { ...vars, already })}` : ""}` // Segments after the change: everything that changes moves to the new value. const keys = [...summary.map((s) => s.value), ...(change && !summary.some((s) => s.value === change.set) ? [change.set] : [])] const after = (key: string | null) => !change ? (summary.find((s) => s.value === key)?.count ?? 0) : key === (change.set || null) ? total : 0 const swatch = (index: number, key: string | null) => key === null ? "bg-muted-foreground/30" : (swatches[index] ?? "bg-foreground/30") return ( <div data-slot="mixed-value-field" data-state={!change ? "keep" : changed ? "change" : "noop"} className={cn("grid w-full max-w-sm gap-1.5", className)} {...props} > <div className="flex items-center justify-between gap-2"> <label htmlFor={fieldId} className="text-sm font-medium"> {label} </label> <span className="flex gap-1"> {allowClear && !options && ( <button type="button" aria-pressed={change?.set === null} onClick={() => update(change?.set === null ? null : { set: null })} className={ghost} > <Eraser aria-hidden className="size-3.5" /> {clearLabel} </button> )} {change && ( <button type="button" onClick={() => update(null)} className={ghost}> <Undo2 aria-hidden className="size-3.5" /> {revertLabel} </button> )} </span> </div> {options ? ( <select id={fieldId} value={!change ? KEEP : change.set === null ? CLEAR : change.set} onChange={(event) => { const next = event.target.value update(next === KEEP ? null : { set: next === CLEAR ? null : next }) }} aria-describedby={`${fieldId}-readback`} data-changed={change ? "" : undefined} className={cn(fieldClass, !change && "text-muted-foreground italic")} > <option value={KEEP}>{current}</option> {options.map((option) => ( <option key={option.value} value={option.value} className="text-foreground not-italic"> {option.label ?? option.value} </option> ))} {allowClear && <option value={CLEAR}>{clearLabel}</option>} </select> ) : ( <input id={fieldId} value={change?.set ?? ""} placeholder={change ? emptyText : current} onChange={(event) => update(event.target.value ? { set: event.target.value } : null)} aria-describedby={`${fieldId}-readback`} data-changed={change ? "" : undefined} className={cn(fieldClass, "placeholder:italic")} /> )} <div aria-hidden className="flex h-1.5 gap-px overflow-hidden rounded-full bg-muted"> {keys.map((key, index) => ( <span key={key ?? KEEP} style={{ flexGrow: after(key), flexBasis: 0 }} className={cn("transition-[flex-grow] duration-300 ease-out motion-reduce:transition-none", swatch(index, key))} /> ))} </div> <ul className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground" aria-label={`${label} now`}> {keys.map((v, index) => { const count = summary.find((s) => s.value === v)?.count ?? 0 return ( <li key={v ?? KEEP} className="flex items-center gap-1.5"> <span aria-hidden className={cn("size-2 rounded-full", swatch(index, v))} /> <span className={cn(v === null && "italic")}>{nameOf(v)}</span> <span className="tabular-nums text-foreground">{count}</span> {change && after(v) !== count && <span className="tabular-nums">→ {after(v)}</span>} </li> ) })} </ul> <p id={`${fieldId}-readback`} aria-live="polite" className={cn( "flex gap-1.5 text-xs", !change ? "text-muted-foreground" : changed ? "font-medium text-sky-800 dark:text-sky-300" : "text-amber-700 dark:text-amber-400", )} > {change && <PencilLine aria-hidden className="mt-px size-3.5 shrink-0" />} {readback} </p> </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { countChanged, MixedValueField, type BulkChange } from "@/components/ui/mixed-value-field"
<MixedValueField
label="Status"
values={status}
options={statusOptions}
value={statusChange}
onValueChange={setStatusChange}
formatItems={tasks}
/>What it catches
- Mixed values hidden behind a blank or the first record's value
- Saving overwrites records nobody meant to touch: nothing changes unless a value is picked
- Count of records that would really change, skipping those that already match
- Clearing a field on every record: explicit Clear button
Props
| Prop | Type | Default |
|---|---|---|
label* (required)Field name, e.g. "Status". | string | — |
values* (required)The field's current value on each selected record. Empty strings count as empty. | (string | null | undefined)[] | — |
optionsChoices for a select. Leave out for a free-text field. | { value: string; label?: string }[] | — |
valueControlled change. | BulkChange | — |
defaultValueInitial change when uncontrolled. | BulkChange | null |
onValueChangeCalled with the planned change: null keeps values, { set } writes one value. | (change: BulkChange) => void | — |
allowClearOffer "Clear on all" so emptying the field is an explicit choice, never an accident. | boolean | false |
formatItems"40 tasks". Override to name your records or translate. | (count: number) => string | defaultItems |
mixedTextTemplates: `{count}`, `{field}`, `{value}`, `{changed}`, `{total}`, `{already}`, `{items}` are filled in. | string | "Mixed ({count} values)" |
uniformTextShown when every record has the same value. | string | "{value} on all" |
emptyTextLabel for empty values. | string | "Empty" |
keepTextReadback when nothing will change. | string | "{field} stays as it is on each of the {items}." |
setTextReadback template for setting a value. | string | "Set {field} to {value} on {changed} of {items}." |
alreadyTextReadback for records that already hold the value. | string | "{already} already {value}." |
clearTextReadback template for clearing the field. | string | "Clear {field} on {changed} of {items}." |
noopTextShown when the change alters no records. | string | "All {items} are already {value}. Nothing changes." |
clearLabelClear button text. | string | "Clear on all" |
revertLabelRevert button text. | string | "Keep as is" |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- Visible label; the control is described by the readback
- Clear uses aria-pressed; Revert is a button
- The distribution bar is aria-hidden and summarised in text
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Works on one field at a time
- The save itself is yours; it only reports the planned change
What it stops
Bulk edit overwrites every record
How people describe it
Also known as
bulk edit · batch edit · mixed values · multi select · table actions · admin · multiple records · mass update
Field type