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
| Prop | Type | Default | Description |
|---|---|---|---|
| value | { key: string; value: string }[] | Controlled rows. | |
| defaultValue | { key: string; value: string }[] | [] | Starting rows for the uncontrolled form. |
| onValueChange | (value: KeyValue[]) => void | Fires with the full list on every edit, add and remove. | |
| keyPlaceholder | string | "KEY" | Placeholder for the key column. |
| valuePlaceholder | string | "Value" | Placeholder for the value column. |
| addLabel | string | "Add variable" | Label on the add button. |
| secret | boolean | false | Render values as password fields with a reveal toggle per row. |
| disabled | boolean | false | Disables 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