Key Value Field
A form component for managing a dynamic list of key-value pairs with add and delete controls.
Installation
npx shadcn@latest add @redpanda/key-value-fieldpnpm dlx shadcn@latest add @redpanda/key-value-fieldyarn dlx shadcn@latest add @redpanda/key-value-fieldbunx shadcn@latest add @redpanda/key-value-fieldnubx shadcn@latest add @redpanda/key-value-fieldaube dlx shadcn@latest add @redpanda/key-value-fieldWhen to use
Use KeyValueField when users need to define arbitrary metadata as key-value pairs — for example, resource labels, environment variables, or HTTP headers. It manages a dynamic list where items can be freely added, edited, and removed.
Usage
import { KeyValueField } from '@/components/redpanda-ui/components/key-value-field';import { createKeyValueRows, keyValuePairs } from '@/components/redpanda-ui/components/key-value-field/rows';const [rows, setRows] = useState(() => createKeyValueRows([{ key: 'environment', value: 'production' }]));
<KeyValueField label="Labels" onChange={setRows} value={rows} />;
// Validate or submit only the domain metadata, never the UI IDs.const pairs = keyValuePairs(rows);Examples
Default
Creatable Combobox
Form Integration
Keep domain pairs in the form. Use useFieldArray’s fields IDs for the controlled UI rows, paired with the current controller values. Translate describeKeyValueRowChange actions as follows:
append: append the new pair withappend(pair, { shouldFocus: false }).remove: remove its position withremove(index).edit: pass the projected pairs to the existing controller’sfield.onChange.
Do not call useFieldArray.update or replace the whole field array for each keystroke: those operations can replace the row IDs and remount focused controls. RHF owns IDs; they must not enter schema validation or submitted metadata. The working example below uses the pure keyValuePairsSchema and displays every row error:
Custom Placeholders
<KeyValueField keyFieldProps={{ placeholder: 'Header name' }} label="HTTP Headers" onChange={setHeaderRows} valueFieldProps={{ placeholder: 'Header value' }} value={headerRows}/>Combobox Fields
Use mode: 'combobox' on keyFieldProps or valueFieldProps to render a combobox with predefined options instead of a plain input. All ComboboxProps (such as creatable, options, and onCreateOption) are available.
<KeyValueField keyFieldProps={{ mode: 'combobox', options: [ { label: 'environment', value: 'environment' }, { label: 'region', value: 'region' }, ], creatable: true, placeholder: 'Select or create key', }} label="Labels" onChange={setRows} value={rows}/>Custom Add Button Label
<KeyValueField addButtonLabel="Add variable" label="Environment Variables" onChange={setEnvironmentRows} value={environmentRows}/>Without Add Button
Use showAddButton={false} when you want to control the list externally or render the add control elsewhere.
<KeyValueField label="Labels" onChange={setRows} showAddButton={false} value={rows} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
value |
readonly KeyValueRow[] |
[] |
Controlled UI rows with stable IDs |
onChange |
(value: KeyValueRow[]) => void |
— | Called with updated UI rows; retained rows keep their IDs |
label |
ReactNode |
— | Label rendered above the field |
description |
ReactNode |
— | Helper text rendered below the label |
addButtonLabel |
string |
'Add' |
Label for the add button |
keyFieldProps |
KeyValueFieldConfig |
{ placeholder: 'Key' } |
Props for each key field — see below |
valueFieldProps |
KeyValueFieldConfig |
{ placeholder: 'Value' } |
Props for each value field — see below |
showAddButton |
boolean |
true |
Whether to render the add button |
disabled |
boolean |
— | Disables all inputs and buttons |
maxItems |
number |
— | Maximum number of pairs allowed — hides the add button when reached |
testId |
string |
— | Base data-testid — sub-IDs are derived automatically |
KeyValuePair type
type KeyValuePair = { key: string; value: string;};KeyValueRow identity and helpers
type KeyValueRow = { readonly id: string; pair: KeyValuePair;};createKeyValueRows(pairs) allocates IDs once when creating a new controlled document. Call it in a state initializer or replacement event, not during a render map. New rows added by the editor receive an ID once in the add event. Keep each ID when editing, cloning, reordering, removing, or undoing removal; never use the editable key/value as identity.
keyValuePairs(rows) projects only { key, value } for validation, diffing, or submission. The existing KeyValuePair domain type and keyValuePairsSchema remain unchanged. A genuinely new document gets fresh IDs, deliberately resetting old controls. Replacing values while retaining IDs deliberately preserves those controls. Duplicate or empty IDs produce a visible error instead of rendering ambiguous rows.
The previous value: KeyValuePair[] / pair-only onChange contract is retired. Existing callers must keep KeyValueRow[] UI state and explicitly project domain pairs. Blank or duplicate pairs are valid distinct rows because identity is separate from their contents.
KeyValueFieldConfig type
A discriminated union that configures each key/value field. Set mode to choose between a plain input and a combobox.
// Plain input (default)type InputFieldConfig = { mode?: 'input' } & Omit<InputProps, 'value' | 'onChange' | 'disabled' | 'aria-invalid'>;
// Combobox with optionstype ComboboxFieldConfig = { mode: 'combobox' } & Omit<ComboboxProps, 'value' | 'onChange' | 'disabled'>;
type KeyValueFieldConfig = InputFieldConfig | ComboboxFieldConfig;Validation
The component applies inline validation styling automatically:
- If a value is filled but the key is empty, the key input is marked
aria-invalid. - If a key is filled but the value is empty, the value input is marked
aria-invalid. - If two or more rows share the same key, all duplicate key inputs are marked
aria-invalid.
For full form validation (required fields, custom rules), integrate with a form library such as React Hook Form and render <FormMessage /> alongside the field.
Focus management
After an added row is accepted by the controlled owner, focus automatically moves to the first input in the new row. After deleting a row, focus moves to the next row (or the previous row if the last row was deleted). This is powered by the useInputListFocus hook from input-utils. Ignored or throwing updates do not move focus; thrown update errors are visible. ID-preserving edits and reorders retain mounted inputs, local combobox searches, refs, and native validity state.
Companion Utilities
The input-utils library provides utilities that pair well with KeyValueField.
npx shadcn@latest add @redpanda/input-utilspnpm dlx shadcn@latest add @redpanda/input-utilsyarn dlx shadcn@latest add @redpanda/input-utilsbunx shadcn@latest add @redpanda/input-utilsnubx shadcn@latest add @redpanda/input-utilsaube dlx shadcn@latest add @redpanda/input-utilskeyValuePairsSchema
Returns a Zod schema that validates key-value pairs — covering empty keys, duplicate keys, character patterns, length limits, and item count. Use with React Hook Form + zodResolver for form-level validation.
import { zodResolver } from '@hookform/resolvers/zod';import { useForm } from 'react-hook-form';import { z } from 'zod';import { keyValuePairsSchema } from '@/lib/input-utils';
const FormSchema = z.object({ labels: keyValuePairsSchema({ maxItems: 10, maxKeyLength: 128 }),});
const form = useForm<z.infer<typeof FormSchema>>({ resolver: zodResolver(FormSchema), defaultValues: { labels: [] },});The schema adds a root-level Zod issue (displayed by FormMessage) and per-field issues at [index, field] paths for row-level error access.
KeyValuePairsSchemaOptions
| Option | Type | Description |
|---|---|---|
maxItems |
number |
Maximum number of pairs allowed |
maxKeyLength |
number |
Maximum character length for keys |
maxValueLength |
number |
Maximum character length for values |
allowedPattern |
RegExp |
Regex that keys and values must match |
getKeyValueDiff
Compares initial and current pairs to produce a delta of created, updated, and removed entries — useful when submitting only changes to an API.
import { getKeyValueDiff } from '@/lib/input-utils';
const diff = getKeyValueDiff(initialPairs, currentPairs);// diff.created → { newKey: 'value' }// diff.updated → { existingKey: 'newValue' }// diff.removed → ['deletedKey']useUndoRemoval
Soft-delete pattern with timed auto-confirm and undo support. Call markForRemoval with a string identifier and a callback that fires after the timeout unless undoRemoval is called first.
import { useUndoRemoval } from '@/lib/input-utils';
const { markForRemoval, undoRemoval, isPending } = useUndoRemoval(5000);
markForRemoval('item-id', () => { // Actually remove the item after 5 seconds});
// Cancel within the timeoutundoRemoval('item-id');
// Check pending state for renderingisPending('item-id'); // true while waitinguseInputListFocus
Manages focus after add/remove operations in dynamic input lists. Already integrated into KeyValueField — use directly when building your own dynamic list components.
import { useInputListFocus } from '@/lib/input-utils';
const containerRef = useRef<HTMLDivElement>(null);const { onAdd, onRemove } = useInputListFocus(containerRef);
// After appending a row:onAdd(); // focuses the first input in the new row
// After deleting a row:onRemove(deletedIndex); // focuses the next logical rowRows must have a data-row attribute for the focus selector to work.
findDuplicateIndices
Generic utility that returns a Set<number> of indices whose extracted key appears more than once. Already used internally by KeyValueField for duplicate detection.
import { findDuplicateIndices } from '@/lib/input-utils';
const duplicates = findDuplicateIndices(items, (item) => item.key);// duplicates → Set { 1, 3 } if items[1] and items[3] share a keyTesting
When testId is provided, child elements receive derived IDs:
| Element | data-testid |
|---|---|
| Root container | testId |
Key input at index n |
testId-key-n |
Value input at index n |
testId-value-n |
Delete button at index n |
testId-delete-n |
| Add button | testId-add |