Skip to content
goodtabs UI
BrowseBrowse components

Key value editor

Rows of key and value inputs with remove buttons and an add button below.

npx shadcn@latest add https://ui.goodtabs.co/r/key-value-editor.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/key-value-editor

Two rows to start. Enter in the last value adds a row.

Secret values

Values render as password fields with a reveal toggle per row.

Controlled, with a count line

2 variables, 1 empty

Controlled rows with a mono line below that counts filled and empty rows.

Usage

import { KeyValueEditor } from "@/components/ui/key-value-editor"

<KeyValueEditor defaultValue={[{ key: "DATABASE_URL", value: "" }]} onValueChange={setEnv} />
<KeyValueEditor secret addLabel="Add secret" value={secrets} onValueChange={setSecrets} />

Props

PropTypeDefaultDescription
value{ key: string; value: string }[]Controlled rows.
defaultValue{ key: string; value: string }[][]Starting rows for the uncontrolled form.
onValueChange(value: KeyValue[]) => voidFires with the full list on every edit, add and remove.
keyPlaceholderstring"KEY"Placeholder for the key column.
valuePlaceholderstring"Value"Placeholder for the value column.
addLabelstring"Add variable"Label on the add button.
secretbooleanfalseRender values as password fields with a reveal toggle per row.
disabledbooleanfalseDisables every input and button.

Source

key-value-editor/key-value-editor.tsx
"use client";

import * as React from "react";
import { EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

export type KeyValue = { key: string; value: string };

type KeyValueEditorProps = Omit<React.ComponentProps<"div">, "defaultValue"> & {
  /** Controlled rows. Leave out for an uncontrolled editor. */
  value?: KeyValue[];
  /** Starting rows for the uncontrolled form. */
  defaultValue?: KeyValue[];
  /** Fires with the full list on every edit, add and remove. */
  onValueChange?: (value: KeyValue[]) => void;
  /** Placeholder for the key column. */
  keyPlaceholder?: string;
  /** Placeholder for the value column. */
  valuePlaceholder?: string;
  /** Label on the add button. */
  addLabel?: string;
  /** Render values as password fields with a reveal toggle per row. */
  secret?: boolean;
  /** Disables every input and button. */
  disabled?: boolean;
};

let seq = 0;
const nextId = () => `kv-${++seq}`;

/**
 * Rows of key and value inputs with a remove button each and an add button below.
 * Keys are mono. Enter in the last value adds a row and moves focus to its key.
 */
export function KeyValueEditor({
  value,
  defaultValue = [],
  onValueChange,
  keyPlaceholder = "KEY",
  valuePlaceholder = "Value",
  addLabel = "Add variable",
  secret = false,
  disabled,
  className,
  ...props
}: KeyValueEditorProps) {
  const [inner, setInner] = React.useState<KeyValue[]>(defaultValue);
  const controlled = value !== undefined;
  const rows = controlled ? value : inner;

  // One stable id per row index, so React keys survive edits and removals.
  const [ids, setIds] = React.useState<string[]>(() => (value ?? defaultValue).map(() => nextId()));
  const rowIds = rows.map((_, i) => ids[i] ?? `row-${i}`);
  const [revealed, setRevealed] = React.useState<Set<string>>(() => new Set());
  const pendingFocus = React.useRef<string | null>(null);

  const commit = (next: KeyValue[]) => {
    if (!controlled) setInner(next);
    onValueChange?.(next);
  };

  const update = (index: number, patch: Partial<KeyValue>) =>
    commit(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));

  const add = () => {
    const id = nextId();
    pendingFocus.current = id;
    setIds([...rowIds, id]);
    commit([...rows, { key: "", value: "" }]);
  };

  const remove = (index: number) => {
    const id = rowIds[index];
    setIds(rowIds.filter((_, i) => i !== index));
    setRevealed((prev) => {
      if (!prev.has(id)) return prev;
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
    commit(rows.filter((_, i) => i !== index));
  };

  const focusNewRow = (id: string) => (el: HTMLInputElement | null) => {
    if (el && pendingFocus.current === id) {
      pendingFocus.current = null;
      el.focus();
    }
  };

  const toggleReveal = (id: string) =>
    setRevealed((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  return (
    <div data-slot="key-value-editor" className={cn("flex w-full flex-col gap-2", className)} {...props}>
      {rows.map((row, index) => {
        const id = rowIds[index];
        const shown = !secret || revealed.has(id);
        const label = row.key || `row ${index + 1}`;
        return (
          <div key={id} className="flex items-center gap-2">
            <Input
              ref={focusNewRow(id)}
              value={row.key}
              placeholder={keyPlaceholder}
              aria-label={`Key for row ${index + 1}`}
              disabled={disabled}
              autoCapitalize="off"
              autoCorrect="off"
              spellCheck={false}
              onChange={(e) => update(index, { key: e.target.value })}
              className="min-w-0 flex-1 basis-0 font-mono placeholder:font-mono"
            />
            <div className="relative flex min-w-0 flex-1 basis-0 items-center">
              <Input
                type={shown ? "text" : "password"}
                value={row.value}
                placeholder={valuePlaceholder}
                aria-label={`Value for ${label}`}
                disabled={disabled}
                autoCapitalize="off"
                autoCorrect="off"
                spellCheck={false}
                onChange={(e) => update(index, { value: e.target.value })}
                onKeyDown={(e) => {
                  if (e.key === "Enter" && index === rows.length - 1) {
                    e.preventDefault();
                    add();
                  }
                }}
                className={cn(secret && "pr-8")}
              />
              {secret && (
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-xs"
                  disabled={disabled}
                  aria-pressed={shown}
                  aria-label={shown ? `Hide value for ${label}` : `Show value for ${label}`}
                  onClick={() => toggleReveal(id)}
                  className="absolute right-1 text-muted-foreground hover:text-foreground"
                >
                  {shown ? <EyeOffIcon aria-hidden="true" /> : <EyeIcon aria-hidden="true" />}
                </Button>
              )}
            </div>
            <Button
              type="button"
              variant="ghost"
              size="icon-sm"
              disabled={disabled}
              aria-label={`Remove ${label}`}
              onClick={() => remove(index)}
              className="shrink-0 text-muted-foreground hover:text-foreground"
            >
              <XIcon aria-hidden="true" />
            </Button>
          </div>
        );
      })}
      <div>
        <Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={add} className="-ml-2">
          <PlusIcon data-icon="inline-start" aria-hidden="true" />
          {addLabel}
        </Button>
      </div>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/key-value-editor/prompt.md, or the Copy AI prompt button above.

When to use

  • Environment variables, request headers, labels and metadata pairs
  • Settings where the number of pairs is open ended and each pair is short
  • When people type keys by hand and need to add rows quickly from the keyboard

When not to use

  • Long values such as certificates or JSON, use a textarea per entry
  • Fixed fields with known names, use a normal form
  • More than a few dozen rows, use a table with inline editing

Agent notes

  • It takes a function (onValueChange), so the page or demo that renders it must be a client component
  • Uncontrolled by default; pass value plus onValueChange to control it, and keep the array stable between renders
  • Rows keep a stable internal id per index, so removing a row never re-keys its neighbours
  • No validation is built in; empty rows stay in the list so you can trim or flag them yourself