Key Value
A dynamic input component for managing key-value pairs with paste support and validation.
import {
KeyValue,
KeyValueAdd,
KeyValueItem,
KeyValueKeyInput,
KeyValueList,
KeyValueRemove,
KeyValueValueInput,
} from "@/components/ui/key-value";
export function KeyValueDemo() {
return (
<KeyValue>
<KeyValueList>
<KeyValueItem>
<KeyValueKeyInput />
<KeyValueValueInput />
<KeyValueRemove />
</KeyValueItem>
</KeyValueList>
<KeyValueAdd />
</KeyValue>
);
}Installation
CLI
npx shadcn@latest add @diceui/key-valueManual
Install the following dependencies:
npm install @radix-ui/react-slot lucide-reactCopy and paste the refs composition utilities into your lib/compose-refs.ts file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };Copy and paste the visually hidden input component into your components/visually-hidden-input.tsx file.
"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import { PlusIcon, XIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
const ROOT_NAME = "KeyValue";
const LIST_NAME = "KeyValueList";
const ITEM_NAME = "KeyValueItem";
const KEY_INPUT_NAME = "KeyValueKeyInput";
const VALUE_INPUT_NAME = "KeyValueValueInput";
const REMOVE_NAME = "KeyValueRemove";
const ADD_NAME = "KeyValueAdd";
const ERROR_NAME = "KeyValueError";
type Orientation = "vertical" | "horizontal";
type Field = "key" | "value";
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
type RootElement = React.ComponentRef<typeof KeyValueRoot>;
type KeyInputElement = React.ComponentRef<typeof KeyValueKeyInput>;
type RemoveElement = React.ComponentRef<typeof KeyValueRemove>;
type AddElement = React.ComponentRef<typeof KeyValueAdd>;
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
function getErrorId(rootId: string, itemId: string, field: Field) {
return `${rootId}-${itemId}-${field}-error`;
}
function removeQuotes(string: string, shouldStrip: boolean): string {
if (!shouldStrip) return string;
const trimmed = string.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => KeyValueState;
setState: <K extends keyof KeyValueState>(
key: K,
value: KeyValueState[K],
) => void;
notify: () => void;
}
function useStore<T>(selector: (state: KeyValueState) => T): T {
const store = useStoreContext("useStore");
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface KeyValueItemData {
id: string;
key: string;
value: string;
}
interface KeyValueState {
value: KeyValueItemData[];
focusedId: string | null;
errors: Record<string, { key?: string; value?: string }>;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface KeyValueContextValue {
rootId: string;
maxItems?: number;
minItems: number;
keyPlaceholder: string;
valuePlaceholder: string;
allowDuplicateKeys: boolean;
enablePaste: boolean;
trim: boolean;
stripQuotes: boolean;
disabled: boolean;
readOnly: boolean;
required: boolean;
}
const KeyValueContext = React.createContext<KeyValueContextValue | null>(null);
function useKeyValueContext(consumerName: string) {
const context = React.useContext(KeyValueContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface KeyValueRootProps extends Omit<DivProps, "onPaste" | "defaultValue"> {
id?: string;
defaultValue?: KeyValueItemData[];
value?: KeyValueItemData[];
onValueChange?: (value: KeyValueItemData[]) => void;
maxItems?: number;
minItems?: number;
keyPlaceholder?: string;
valuePlaceholder?: string;
name?: string;
allowDuplicateKeys?: boolean;
enablePaste?: boolean;
trim?: boolean;
stripQuotes?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
onPaste?: (event: ClipboardEvent, items: KeyValueItemData[]) => void;
onAdd?: (value: KeyValueItemData) => void;
onRemove?: (value: KeyValueItemData) => void;
onKeyValidate?: (
key: string,
value: KeyValueItemData[],
) => string | undefined;
onValueValidate?: (
value: string,
key: string,
items: KeyValueItemData[],
) => string | undefined;
}
function KeyValueRoot(props: KeyValueRootProps) {
const { value, defaultValue, onValueChange, ...rootProps } = props;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<KeyValueState>(() => ({
value: value ??
defaultValue ?? [{ id: crypto.randomUUID(), key: "", value: "" }],
focusedId: null,
errors: {},
}));
const propsRef = useAsRef({ onValueChange });
const store = React.useMemo<Store>(() => {
return {
subscribe: (cb) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
},
getState: () => stateRef.current,
setState: (key, val) => {
if (Object.is(stateRef.current[key], val)) return;
if (key === "value" && Array.isArray(val)) {
stateRef.current.value = val as KeyValueItemData[];
propsRef.current.onValueChange?.(val as KeyValueItemData[]);
} else {
stateRef.current[key] = val;
}
store.notify();
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
};
}, [listenersRef, stateRef, propsRef]);
return (
<StoreContext.Provider value={store}>
<KeyValueRootImpl {...rootProps} value={value} />
</StoreContext.Provider>
);
}
interface KeyValueRootImplProps
extends Omit<
KeyValueRootProps,
| "defaultValue"
| "onValueChange"
| "onPaste"
| "onAdd"
| "onRemove"
| "onKeyValidate"
| "onValueValidate"
> {}
function KeyValueRootImpl(props: KeyValueRootImplProps) {
const {
id,
value: valueProp,
maxItems,
minItems = 0,
keyPlaceholder = "Key",
valuePlaceholder = "Value",
name,
allowDuplicateKeys = false,
enablePaste = true,
trim = true,
stripQuotes = true,
disabled = false,
readOnly = false,
required = false,
asChild,
className,
ref,
...rootProps
} = props;
const store = useStoreContext("KeyValueRootImpl");
const value = useStore((state) => state.value);
const errors = useStore((state) => state.errors);
const isInvalid = Object.keys(errors).length > 0;
useIsomorphicLayoutEffect(() => {
if (valueProp !== undefined) {
store.setState("value", valueProp);
}
}, [valueProp, store]);
const instanceId = React.useId();
const rootId = id ?? instanceId;
const [formTrigger, setFormTrigger] = React.useState<RootElement | null>(
null,
);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
const contextValue = React.useMemo<KeyValueContextValue>(
() => ({
rootId,
maxItems,
minItems,
keyPlaceholder,
valuePlaceholder,
allowDuplicateKeys,
enablePaste,
trim,
stripQuotes,
disabled,
readOnly,
required,
}),
[
rootId,
disabled,
readOnly,
required,
maxItems,
minItems,
keyPlaceholder,
valuePlaceholder,
allowDuplicateKeys,
enablePaste,
trim,
stripQuotes,
],
);
const RootPrimitive = asChild ? Slot : "div";
return (
<>
<KeyValueContext.Provider value={contextValue}>
<RootPrimitive
id={id}
data-slot="key-value"
data-disabled={disabled ? "" : undefined}
data-invalid={isInvalid ? "" : undefined}
data-readonly={readOnly ? "" : undefined}
{...rootProps}
ref={composedRef}
className={cn("flex flex-col gap-2", className)}
/>
</KeyValueContext.Provider>
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
control={formTrigger}
name={name}
value={value}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
)}
</>
);
}
interface KeyValueListProps extends DivProps {
orientation?: Orientation;
}
function KeyValueList(props: KeyValueListProps) {
const { orientation = "vertical", asChild, className, ...listProps } = props;
const value = useStore((state) => state.value);
const ListPrimitive = asChild ? Slot : "div";
return (
<ListPrimitive
role="list"
aria-orientation={orientation}
data-slot="key-value-list"
data-orientation={orientation}
{...listProps}
className={cn(
"flex",
orientation === "vertical" ? "flex-col gap-2" : "flex-row gap-2",
className,
)}
>
{value.map((item) => {
const children = React.Children.toArray(props.children);
return (
<KeyValueItemContext.Provider key={item.id} value={item}>
{children}
</KeyValueItemContext.Provider>
);
})}
</ListPrimitive>
);
}
const KeyValueItemContext = React.createContext<KeyValueItemData | null>(null);
function useKeyValueItemContext(consumerName: string) {
const context = React.useContext(KeyValueItemContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${LIST_NAME}\``);
}
return context;
}
interface KeyValueItemProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
function KeyValueItem(props: KeyValueItemProps) {
const { asChild, className, ...itemProps } = props;
const itemData = useKeyValueItemContext(ITEM_NAME);
const focusedId = useStore((state) => state.focusedId);
const ItemPrimitive = asChild ? Slot : "div";
return (
<ItemPrimitive
role="listitem"
data-slot="key-value-item"
data-highlighted={focusedId === itemData.id ? "" : undefined}
{...itemProps}
className={cn("flex items-start gap-2", className)}
/>
);
}
interface KeyValueKeyInputProps
extends Omit<React.ComponentProps<"input">, "onPaste">,
Pick<KeyValueRootProps, "onKeyValidate" | "onValueValidate" | "onPaste"> {
asChild?: boolean;
}
function KeyValueKeyInput(props: KeyValueKeyInputProps) {
const {
onKeyValidate,
onValueValidate,
onChange,
onPaste,
asChild,
disabled,
readOnly,
required,
...keyInputProps
} = props;
const context = useKeyValueContext(KEY_INPUT_NAME);
const itemData = useKeyValueItemContext(KEY_INPUT_NAME);
const store = useStoreContext(KEY_INPUT_NAME);
const errors = useStore((state) => state.errors);
const isDisabled = disabled || context.disabled;
const isReadOnly = readOnly || context.readOnly;
const isRequired = required || context.required;
const isInvalid = errors[itemData.id]?.key !== undefined;
const propsRef = useAsRef({
onKeyValidate,
onValueValidate,
onChange,
onPaste,
});
const onKeyInputChange = React.useCallback(
(event: React.ChangeEvent<KeyInputElement>) => {
const state = store.getState();
const newValue = state.value.map((item) => {
if (item.id !== itemData.id) return item;
const updated = { ...item, key: event.target.value };
if (context.trim) updated.key = updated.key.trim();
return updated;
});
store.setState("value", newValue);
const updatedItemData = newValue.find((item) => item.id === itemData.id);
if (updatedItemData) {
const errors: { key?: string; value?: string } = {};
if (propsRef.current.onKeyValidate) {
const keyError = propsRef.current.onKeyValidate(
updatedItemData.key,
newValue,
);
if (keyError) errors.key = keyError;
}
if (!context.allowDuplicateKeys) {
const duplicateKey = newValue.find(
(item) =>
item.id !== updatedItemData.id &&
item.key === updatedItemData.key &&
updatedItemData.key !== "",
);
if (duplicateKey) {
errors.key = "Duplicate key";
}
}
if (propsRef.current.onValueValidate) {
const valueError = propsRef.current.onValueValidate(
updatedItemData.value,
updatedItemData.key,
newValue,
);
if (valueError) errors.value = valueError;
}
const newErrorsState = { ...state.errors };
if (Object.keys(errors).length > 0) {
newErrorsState[itemData.id] = errors;
} else {
delete newErrorsState[itemData.id];
}
store.setState("errors", newErrorsState);
}
propsRef.current.onChange?.(event);
},
[store, itemData.id, context.trim, context.allowDuplicateKeys, propsRef],
);
const onKeyInputPaste = React.useCallback(
(event: React.ClipboardEvent<KeyInputElement>) => {
if (!context.enablePaste) return;
const content = event.clipboardData.getData("text");
const lines = content.split(/\r?\n/).filter((line) => line.trim());
if (lines.length > 1) {
event.preventDefault();
const parsed: KeyValueItemData[] = [];
for (const line of lines) {
let key = "";
let value = "";
if (line.includes("=")) {
const parts = line.split("=");
key = parts[0]?.trim() ?? "";
value = removeQuotes(
parts.slice(1).join("=").trim(),
context.stripQuotes,
);
} else if (line.includes(":")) {
const parts = line.split(":");
key = parts[0]?.trim() ?? "";
value = removeQuotes(
parts.slice(1).join(":").trim(),
context.stripQuotes,
);
} else if (/\s{2,}|\t/.test(line)) {
const parts = line.split(/\s{2,}|\t/);
key = parts[0]?.trim() ?? "";
value = removeQuotes(
parts.slice(1).join(" ").trim(),
context.stripQuotes,
);
}
if (key) {
parsed.push({ id: crypto.randomUUID(), key, value });
}
}
if (parsed.length > 0) {
const state = store.getState();
const currentIndex = state.value.findIndex(
(item) => item.id === itemData.id,
);
let newValue: KeyValueItemData[];
if (itemData.key === "" && itemData.value === "") {
newValue = [
...state.value.slice(0, currentIndex),
...parsed,
...state.value.slice(currentIndex + 1),
];
} else {
newValue = [
...state.value.slice(0, currentIndex + 1),
...parsed,
...state.value.slice(currentIndex + 1),
];
}
if (context.maxItems !== undefined) {
newValue = newValue.slice(0, context.maxItems);
}
store.setState("value", newValue);
if (propsRef.current.onPaste) {
propsRef.current.onPaste(
event.nativeEvent as unknown as ClipboardEvent,
parsed,
);
}
}
}
},
[
context.enablePaste,
context.maxItems,
context.stripQuotes,
store,
itemData,
propsRef,
],
);
const KeyInputPrimitive = asChild ? Slot : Input;
return (
<KeyInputPrimitive
aria-invalid={isInvalid}
aria-describedby={
isInvalid ? getErrorId(context.rootId, itemData.id, "key") : undefined
}
data-slot="key-value-key-input"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
disabled={isDisabled}
readOnly={isReadOnly}
required={isRequired}
{...keyInputProps}
placeholder={context.keyPlaceholder}
value={itemData.key}
onChange={onKeyInputChange}
onPaste={onKeyInputPaste}
/>
);
}
interface KeyValueValueInputProps
extends Omit<React.ComponentProps<"textarea">, "rows">,
Pick<KeyValueRootProps, "onKeyValidate" | "onValueValidate"> {
maxRows?: number;
asChild?: boolean;
}
function KeyValueValueInput(props: KeyValueValueInputProps) {
const {
onKeyValidate,
onValueValidate,
onChange,
asChild,
disabled,
readOnly,
required,
className,
maxRows,
style,
...valueInputProps
} = props;
const context = useKeyValueContext(VALUE_INPUT_NAME);
const itemData = useKeyValueItemContext(VALUE_INPUT_NAME);
const store = useStoreContext(VALUE_INPUT_NAME);
const propsRef = useAsRef({
onKeyValidate,
onValueValidate,
onChange,
});
const errors = useStore((state) => state.errors);
const isDisabled = disabled || context.disabled;
const isReadOnly = readOnly || context.readOnly;
const isRequired = required || context.required;
const isInvalid = errors[itemData.id]?.value !== undefined;
const maxHeight = maxRows ? `calc(${maxRows} * 1.5em + 1rem)` : undefined;
const onValueInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
propsRef.current.onChange?.(event);
const state = store.getState();
const newValue = state.value.map((item) => {
if (item.id !== itemData.id) return item;
const updated = { ...item, value: event.target.value };
if (context.trim) updated.value = updated.value.trim();
return updated;
});
store.setState("value", newValue);
const updatedItemData = newValue.find(
(item: KeyValueItemData) => item.id === itemData.id,
);
if (updatedItemData) {
const errors: { key?: string; value?: string } = {};
if (propsRef.current.onKeyValidate) {
const keyError = propsRef.current.onKeyValidate(
updatedItemData.key,
newValue,
);
if (keyError) errors.key = keyError;
}
if (!context.allowDuplicateKeys) {
const duplicateKey = newValue.find(
(item: KeyValueItemData) =>
item.id !== updatedItemData.id &&
item.key === updatedItemData.key &&
updatedItemData.key !== "",
);
if (duplicateKey) {
errors.key = "Duplicate key";
}
}
if (propsRef.current.onValueValidate) {
const valueError = propsRef.current.onValueValidate(
updatedItemData.value,
updatedItemData.key,
newValue,
);
if (valueError) errors.value = valueError;
}
const newErrorsState = { ...state.errors };
if (Object.keys(errors).length > 0) {
newErrorsState[itemData.id] = errors;
} else {
delete newErrorsState[itemData.id];
}
store.setState("errors", newErrorsState);
}
},
[store, itemData.id, context.trim, context.allowDuplicateKeys, propsRef],
);
const ValueInputPrimitive = asChild ? Slot : Textarea;
return (
<ValueInputPrimitive
aria-invalid={isInvalid}
aria-describedby={
isInvalid ? getErrorId(context.rootId, itemData.id, "value") : undefined
}
data-slot="key-value-value-input"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
disabled={isDisabled}
readOnly={isReadOnly}
required={isRequired}
{...valueInputProps}
placeholder={context.valuePlaceholder}
className={cn(
"field-sizing-content min-h-9 resize-none",
maxRows && "overflow-y-auto",
className,
)}
style={{
...style,
...(maxHeight && { maxHeight }),
}}
value={itemData.value}
onChange={onValueInputChange}
/>
);
}
interface KeyValueRemoveProps
extends React.ComponentProps<typeof Button>,
Pick<KeyValueRootProps, "onRemove"> {}
function KeyValueRemove(props: KeyValueRemoveProps) {
const { onClick, onRemove, children, ...removeProps } = props;
const context = useKeyValueContext(REMOVE_NAME);
const itemData = useKeyValueItemContext(REMOVE_NAME);
const store = useStoreContext(REMOVE_NAME);
const propsRef = useAsRef({ onClick, onRemove });
const value = useStore((state) => state.value);
const isDisabled = context.disabled || value.length <= context.minItems;
const onRemoveClick = React.useCallback(
(event: React.MouseEvent<RemoveElement>) => {
propsRef.current.onClick?.(event);
const state = store.getState();
if (state.value.length <= context.minItems) return;
const itemToRemove = state.value.find((item) => item.id === itemData.id);
if (!itemToRemove) return;
const newValue = state.value.filter((item) => item.id !== itemData.id);
const newErrors = { ...state.errors };
delete newErrors[itemData.id];
store.setState("value", newValue);
store.setState("errors", newErrors);
propsRef.current.onRemove?.(itemToRemove);
},
[store, context.minItems, itemData.id, propsRef],
);
return (
<Button
type="button"
data-slot="key-value-remove"
variant="outline"
size="icon"
disabled={isDisabled}
{...removeProps}
onClick={onRemoveClick}
>
{children ?? <XIcon />}
</Button>
);
}
interface KeyValueAddProps
extends React.ComponentProps<typeof Button>,
Pick<KeyValueRootProps, "onAdd"> {}
function KeyValueAdd(props: KeyValueAddProps) {
const { onClick, onAdd, children, ...addProps } = props;
const context = useKeyValueContext(ADD_NAME);
const store = useStoreContext(ADD_NAME);
const propsRef = useAsRef({ onClick, onAdd });
const value = useStore((state) => state.value);
const isDisabled =
context.disabled ||
(context.maxItems !== undefined && value.length >= context.maxItems);
const onAddClick = React.useCallback(
(event: React.MouseEvent<AddElement>) => {
propsRef.current.onClick?.(event);
const state = store.getState();
if (
context.maxItems !== undefined &&
state.value.length >= context.maxItems
) {
return;
}
const newItem: KeyValueItemData = {
id: crypto.randomUUID(),
key: "",
value: "",
};
const newValue = [...state.value, newItem];
store.setState("value", newValue);
store.setState("focusedId", newItem.id);
propsRef.current.onAdd?.(newItem);
},
[store, context.maxItems, propsRef],
);
return (
<Button
type="button"
data-slot="key-value-add"
variant="outline"
disabled={isDisabled}
{...addProps}
onClick={onAddClick}
>
{children ?? (
<>
<PlusIcon />
Add
</>
)}
</Button>
);
}
interface KeyValueErrorProps extends DivProps {
field: Field;
}
function KeyValueError(props: KeyValueErrorProps) {
const { field, asChild, className, ...errorProps } = props;
const context = useKeyValueContext(ERROR_NAME);
const itemData = useKeyValueItemContext(ERROR_NAME);
const errors = useStore((state) => state.errors);
const error = errors[itemData.id]?.[field];
if (!error) return null;
const ErrorPrimitive = asChild ? Slot : "span";
return (
<ErrorPrimitive
id={getErrorId(context.rootId, itemData.id, field)}
role="alert"
{...errorProps}
className={cn("font-medium text-destructive text-sm", className)}
>
{error}
</ErrorPrimitive>
);
}
export {
KeyValueRoot as Root,
KeyValueList as List,
KeyValueItem as Item,
KeyValueKeyInput as KeyInput,
KeyValueValueInput as ValueInput,
KeyValueRemove as Remove,
KeyValueAdd as Add,
KeyValueError as Error,
//
KeyValueRoot as KeyValue,
KeyValueList,
KeyValueItem,
KeyValueKeyInput,
KeyValueValueInput,
KeyValueRemove,
KeyValueAdd,
KeyValueError,
//
useStore as useKeyValueStore,
//
type KeyValueItemData,
type KeyValueRootProps as KeyValueProps,
};Layout
Import the parts, and compose them together.
import * as KeyValue from "@/components/ui/key-value";
return (
<KeyValue.Root>
<KeyValue.List>
<KeyValue.Item>
<KeyValue.KeyInput />
<KeyValue.ValueInput />
<KeyValue.Remove />
<KeyValue.Error field="key" />
<KeyValue.Error field="value" />
</KeyValue.Item>
</KeyValue.List>
<KeyValue.Add />
</KeyValue.Root>
)Examples
With Paste Support
Paste multiple key-value pairs at once. Supports formats like KEY=VALUE, KEY: VALUE, and tab-separated values.
import { ClipboardIcon } from "lucide-react";
import {
KeyValue,
KeyValueAdd,
KeyValueItem,
KeyValueKeyInput,
KeyValueList,
KeyValueRemove,
KeyValueValueInput,
} from "@/components/ui/key-value";
export function KeyValuePasteDemo() {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 rounded-lg border bg-muted/50 p-4">
<div className="flex items-center gap-2">
<ClipboardIcon className="size-4" />
<p className="font-medium text-sm">Paste Support</p>
</div>
<p className="text-muted-foreground text-xs">
Try pasting multiple lines in any of these formats:
</p>
<pre className="rounded bg-background p-2 text-xs">
{`API_KEY=sk-1234567890
DATABASE_URL=postgresql://localhost
PORT=3000`}
</pre>
</div>
<KeyValue keyPlaceholder="KEY" valuePlaceholder="value">
<KeyValueList>
<KeyValueItem>
<KeyValueKeyInput className="font-mono" />
<KeyValueValueInput className="font-mono" />
<KeyValueRemove />
</KeyValueItem>
</KeyValueList>
<KeyValueAdd />
</KeyValue>
</div>
);
}With Validation
Add validation rules for keys and values with error messages.
"use client";
import {
KeyValue,
KeyValueAdd,
KeyValueError,
KeyValueItem,
KeyValueKeyInput,
KeyValueList,
KeyValueRemove,
KeyValueValueInput,
} from "@/components/ui/key-value";
export function KeyValueValidationDemo() {
return (
<KeyValue
defaultValue={[
{ id: "1", key: "API_KEY", value: "sk-1234567890" },
{ id: "2", key: "invalid key", value: "" },
{ id: "3", key: "DATABASE_URL", value: "short" },
]}
keyPlaceholder="KEY"
valuePlaceholder="value"
onKeyValidate={(key) => {
if (!key) return "Key is required";
if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) {
return "Must be uppercase with underscores";
}
return undefined;
}}
onValueValidate={(value, key) => {
if (key.includes("KEY") && value.length < 10) {
return "API keys must be at least 10 characters";
}
return undefined;
}}
allowDuplicateKeys={false}
>
<KeyValueList>
<KeyValueItem className="flex-col items-start">
<div className="flex w-full gap-2">
<div className="flex flex-1 flex-col gap-1">
<KeyValueKeyInput className="font-mono" />
<KeyValueError field="key" />
</div>
<div className="flex flex-1 flex-col gap-1">
<KeyValueValueInput className="font-mono" />
<KeyValueError field="value" />
</div>
<KeyValueRemove />
</div>
</KeyValueItem>
</KeyValueList>
<KeyValueAdd />
</KeyValue>
);
}With Form
Integrate with React Hook Form for form validation.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
KeyValue,
KeyValueAdd,
KeyValueItem,
KeyValueKeyInput,
KeyValueList,
KeyValueRemove,
KeyValueValueInput,
} from "@/components/ui/key-value";
const formSchema = z.object({
projectName: z.string().min(1, "Project name is required"),
envVariables: z
.array(
z.object({
id: z.string(),
key: z.string().min(1, "Key is required"),
value: z.string(),
}),
)
.min(1, "At least one environment variable is required"),
});
type FormValues = z.infer<typeof formSchema>;
export function KeyValueFormDemo() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
projectName: "",
envVariables: [{ id: "1", key: "", value: "" }],
},
});
const onSubmit = React.useCallback((data: FormValues) => {
toast.success("Submitted values:", {
description: (
<pre className="mt-2 w-full rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
});
}, []);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<FormField
control={form.control}
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input placeholder="my-awesome-project" {...field} />
</FormControl>
<FormDescription>The name of your project</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envVariables"
render={({ field }) => (
<FormItem>
<FormLabel>Environment Variables</FormLabel>
<FormControl>
<KeyValue
value={field.value}
onValueChange={field.onChange}
keyPlaceholder="KEY"
valuePlaceholder="value"
onKeyValidate={(key) => {
if (!key) return "Key is required";
if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) {
return "Key must be uppercase with underscores";
}
return undefined;
}}
allowDuplicateKeys={false}
>
<KeyValueList>
<KeyValueItem>
<KeyValueKeyInput className="flex-1" />
<KeyValueValueInput className="flex-1" />
<KeyValueRemove />
</KeyValueItem>
</KeyValueList>
<KeyValueAdd />
</KeyValue>
</FormControl>
<FormDescription>
Add environment variables for your project. Supports pasting
multiple lines in KEY=VALUE format.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}API Reference
Root
The main container component that manages the key-value items state.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-disabled] | Present when disabled |
[data-invalid] | Present when any item has validation errors |
[data-readonly] | Present when read-only |
List
Container for rendering the list of key-value items.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-orientation] | vertical | horizontal |
Item
Individual key-value pair item container.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-highlighted] | Present when item is highlighted/focused |
KeyInput
Input field for the key part of the item.
Prop
Type
ValueInput
Input field for the value part of the item.
Prop
Type
Remove
Button to remove a key-value item.
Prop
Type
Add
Button to add a new key-value item.
Prop
Type
Error
Error message display for validation errors.
Prop
Type
Accessibility
Keyboard Interactions
| Key | Description |
|---|---|
| Tab | Navigate between key inputs, value inputs, and buttons. |
| Enter | Submit the current input value. |
| Escape | Cancel the current input. |
| CtrlV | Paste multiple key-value pairs (supports multiple formats). |
Features
- Dynamic Items: Add and remove key-value pairs dynamically
- Paste Support: Paste multiple items at once in various formats (
KEY=VALUE,KEY: VALUE, tab-separated) - Validation: Built-in validation for keys and values with custom validators
- Duplicate Detection: Optional prevention of duplicate keys
- Item Limits: Set minimum and maximum item counts
- Form Integration: Works seamlessly with React Hook Form
- Controlled/Uncontrolled: Supports both controlled and uncontrolled patterns
- Accessibility: Full keyboard navigation and screen reader support
- Customizable: Fully customizable styling and behavior
Paste Formats
The component supports pasting multiple key-value pairs in the following formats:
KEY=VALUE
DATABASE_URL=postgresql://localhost:5432
API_KEY=sk-1234567890
KEY: VALUE
DATABASE_URL: postgresql://localhost:5432
API_KEY: sk-1234567890
KEY VALUE (tab-separated)
DATABASE_URL postgresql://localhost:5432
API_KEY sk-1234567890When pasting multiple lines, the component will automatically parse and create separate items for each line.