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 placeholder="Test" />
          <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 hooks into your hooks directory.

import * as React from "react";
 
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
 
export { useIsomorphicLayoutEffect };
import * as React from "react";
 
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>;
}
 
export { useLazyRef };
import * as React from "react";
 
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
 
function useAsRef<T>(props: T) {
  const ref = React.useRef<T>(props);
 
  useIsomorphicLayoutEffect(() => {
    ref.current = props;
  });
 
  return ref;
}
 
export { useAsRef };

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";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
 
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 KeyValue>;
type KeyInputElement = React.ComponentRef<typeof KeyValueKeyInput>;
type RemoveElement = React.ComponentRef<typeof KeyValueRemove>;
type AddElement = React.ComponentRef<typeof KeyValueAdd>;
 
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,
  ogStore?: Store | null,
): T {
  const contextStore = React.useContext(StoreContext);
 
  const store = ogStore ?? contextStore;
 
  if (!store) {
    throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
  }
 
  const getSnapshot = React.useCallback(
    () => selector(store.getState()),
    [store, selector],
  );
 
  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
 
interface ItemData {
  id: string;
  key: string;
  value: string;
}
 
interface KeyValueState {
  value: ItemData[];
  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 {
  onPaste?: (event: ClipboardEvent, items: ItemData[]) => void;
  onAdd?: (value: ItemData) => void;
  onRemove?: (value: ItemData) => void;
  onKeyValidate?: (key: string, value: ItemData[]) => string | undefined;
  onValueValidate?: (
    value: string,
    key: string,
    items: ItemData[],
  ) => string | undefined;
  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 KeyValueProps extends Omit<DivProps, "onPaste" | "defaultValue"> {
  id?: string;
  defaultValue?: ItemData[];
  value?: ItemData[];
  onValueChange?: (value: ItemData[]) => 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: ItemData[]) => void;
  onAdd?: (value: ItemData) => void;
  onRemove?: (value: ItemData) => void;
  onKeyValidate?: (key: string, value: ItemData[]) => string | undefined;
  onValueValidate?: (
    value: string,
    key: string,
    items: ItemData[],
  ) => string | undefined;
}
 
function KeyValue(props: KeyValueProps) {
  const {
    value: valueProp,
    defaultValue,
    onValueChange,
    onPaste,
    onAdd,
    onRemove,
    onKeyValidate,
    onValueValidate,
    maxItems,
    minItems = 0,
    keyPlaceholder = "Key",
    valuePlaceholder = "Value",
    allowDuplicateKeys = false,
    asChild,
    enablePaste = true,
    trim = true,
    stripQuotes = true,
    disabled = false,
    readOnly = false,
    required = false,
    className,
    id,
    name,
    ref,
    ...rootProps
  } = props;
 
  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 listenersRef = useLazyRef(() => new Set<() => void>());
  const stateRef = useLazyRef<KeyValueState>(() => ({
    value: valueProp ??
      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 ItemData[];
          propsRef.current.onValueChange?.(val as ItemData[]);
        } else {
          stateRef.current[key] = val;
        }
 
        store.notify();
      },
      notify: () => {
        for (const cb of listenersRef.current) {
          cb();
        }
      },
    };
  }, [listenersRef, stateRef, propsRef]);
 
  const value = useStore((state) => state.value, store);
  const errors = useStore((state) => state.errors, store);
  const isInvalid = Object.keys(errors).length > 0;
 
  useIsomorphicLayoutEffect(() => {
    if (valueProp !== undefined) {
      store.setState("value", valueProp);
    }
  }, [valueProp]);
 
  const contextValue = React.useMemo<KeyValueContextValue>(
    () => ({
      onPaste,
      onAdd,
      onRemove,
      onKeyValidate,
      onValueValidate,
      rootId,
      maxItems,
      minItems,
      keyPlaceholder,
      valuePlaceholder,
      allowDuplicateKeys,
      enablePaste,
      trim,
      stripQuotes,
      disabled,
      readOnly,
      required,
    }),
    [
      onPaste,
      onAdd,
      onRemove,
      onKeyValidate,
      onValueValidate,
      rootId,
      disabled,
      readOnly,
      required,
      maxItems,
      minItems,
      keyPlaceholder,
      valuePlaceholder,
      allowDuplicateKeys,
      enablePaste,
      trim,
      stripQuotes,
    ],
  );
 
  const RootPrimitive = asChild ? Slot : "div";
 
  return (
    <StoreContext.Provider value={store}>
      <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)}
        />
        {isFormControl && (
          <VisuallyHiddenInput
            type="hidden"
            control={formTrigger}
            name={name}
            value={value}
            disabled={disabled}
            readOnly={readOnly}
            required={required}
          />
        )}
      </KeyValueContext.Provider>
    </StoreContext.Provider>
  );
}
 
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<ItemData | 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 React.ComponentProps<"input"> {
  asChild?: boolean;
}
 
function KeyValueKeyInput(props: KeyValueKeyInputProps) {
  const {
    onChange: onChangeProp,
    onPaste: onPasteProp,
    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 propsRef = useAsRef({
    onChange: onChangeProp,
    onPaste: onPasteProp,
  });
 
  const isDisabled = disabled || context.disabled;
  const isReadOnly = readOnly || context.readOnly;
  const isRequired = required || context.required;
  const isInvalid = errors[itemData.id]?.key !== undefined;
 
  const onChange = 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 (context.onKeyValidate) {
          const keyError = context.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 (context.onValueValidate) {
          const valueError = context.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, propsRef],
  );
 
  const onPaste = React.useCallback(
    (event: React.ClipboardEvent<KeyInputElement>) => {
      if (!context.enablePaste) return;
 
      propsRef.current.onPaste?.(event);
      if (event.defaultPrevented) 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: ItemData[] = [];
 
        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: ItemData[];
          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 (context.onPaste) {
            context.onPaste(
              event.nativeEvent as unknown as ClipboardEvent,
              parsed,
            );
          }
        }
      }
    },
    [context, 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}
      placeholder={context.keyPlaceholder}
      {...keyInputProps}
      value={itemData.key}
      onChange={onChange}
      onPaste={onPaste}
    />
  );
}
 
interface KeyValueValueInputProps
  extends Omit<React.ComponentProps<"textarea">, "rows"> {
  maxRows?: number;
  asChild?: boolean;
}
 
function KeyValueValueInput(props: KeyValueValueInputProps) {
  const {
    onChange: onChangeProp,
    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 errors = useStore((state) => state.errors);
 
  const propsRef = useAsRef({
    onChange: onChangeProp,
  });
 
  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 onChange = 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) => item.id === itemData.id);
      if (updatedItemData) {
        const errors: { key?: string; value?: string } = {};
 
        if (context.onKeyValidate) {
          const keyError = context.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 (context.onValueValidate) {
          const valueError = context.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, 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}
      placeholder={context.valuePlaceholder}
      {...valueInputProps}
      className={cn(
        "field-sizing-content min-h-9 resize-none",
        maxRows && "overflow-y-auto",
        className,
      )}
      style={{
        ...style,
        ...(maxHeight && { maxHeight }),
      }}
      value={itemData.value}
      onChange={onChange}
    />
  );
}
 
interface KeyValueRemoveProps extends React.ComponentProps<typeof Button> {}
 
function KeyValueRemove(props: KeyValueRemoveProps) {
  const { onClick: onClickProp, children, ...removeProps } = props;
 
  const context = useKeyValueContext(REMOVE_NAME);
  const itemData = useKeyValueItemContext(REMOVE_NAME);
  const store = useStoreContext(REMOVE_NAME);
 
  const propsRef = useAsRef({
    onClick: onClickProp,
  });
  const value = useStore((state) => state.value);
  const isDisabled = context.disabled || value.length <= context.minItems;
 
  const onClick = 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);
 
      context.onRemove?.(itemToRemove);
    },
    [store, context, itemData.id, propsRef],
  );
 
  return (
    <Button
      type="button"
      data-slot="key-value-remove"
      variant="outline"
      size="icon"
      disabled={isDisabled}
      {...removeProps}
      onClick={onClick}
    >
      {children ?? <XIcon />}
    </Button>
  );
}
 
function KeyValueAdd(props: React.ComponentProps<typeof Button>) {
  const { onClick: onClickProp, children, ...addProps } = props;
 
  const context = useKeyValueContext(ADD_NAME);
  const store = useStoreContext(ADD_NAME);
 
  const propsRef = useAsRef({
    onClick: onClickProp,
  });
  const value = useStore((state) => state.value);
  const isDisabled =
    context.disabled ||
    (context.maxItems !== undefined && value.length >= context.maxItems);
 
  const onClick = 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: ItemData = {
        id: crypto.randomUUID(),
        key: "",
        value: "",
      };
 
      const newValue = [...state.value, newItem];
      store.setState("value", newValue);
      store.setState("focusedId", newItem.id);
 
      context.onAdd?.(newItem);
    },
    [store, context, propsRef],
  );
 
  return (
    <Button
      type="button"
      data-slot="key-value-add"
      variant="outline"
      disabled={isDisabled}
      {...addProps}
      onClick={onClick}
    >
      {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 {
  KeyValue,
  KeyValueList,
  KeyValueItem,
  KeyValueKeyInput,
  KeyValueValueInput,
  KeyValueRemove,
  KeyValueAdd,
  KeyValueError,
  //
  useStore as useKeyValueStore,
  //
  type KeyValueProps,
  type ItemData as KeyValueItemData,
};

Update the import paths to match your project setup.

Layout

Import the parts, and compose them together.

import {
  KeyValue,
  KeyValueList,
  KeyValueItem,
  KeyValueKeyInput,
  KeyValueValueInput,
  KeyValueRemove,
  KeyValueError,
  KeyValueAdd,
} from "@/components/ui/key-value";

return (
  <KeyValue>
    <KeyValueList>
      <KeyValueItem>
        <KeyValueKeyInput />
        <KeyValueValueInput />
        <KeyValueRemove />
        <KeyValueError field="key" />
        <KeyValueError field="value" />
      </KeyValueItem>
    </KeyValueList>
    <KeyValueAdd />
  </KeyValue>
)

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

KeyValue

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

KeyValueList

Container for rendering the list of key-value items.

Prop

Type

Data AttributeValue
[data-orientation]vertical | horizontal

KeyValueItem

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

KeyValueRemove

Button to remove a key-value item.

Prop

Type

KeyValueAdd

Button to add a new key-value item.

Prop

Type

KeyValueError

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