Dice UI
Components

Key Value

A dynamic input component for managing key-value pairs with paste support and validation.

API
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-value

Manual

Install the following dependencies:

npm install @radix-ui/react-slot lucide-react

Copy 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 AttributeValue
[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 AttributeValue
[data-orientation]vertical | horizontal

Item

Individual key-value pair item container.

Prop

Type

Data AttributeValue
[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

KeyDescription
TabNavigate between key inputs, value inputs, and buttons.
EnterSubmit the current input value.
EscapeCancel the current input.
CtrlVPaste 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-1234567890

When pasting multiple lines, the component will automatically parse and create separate items for each line.

On this page