Skip to content
Redpanda UI
Esc
↑↓navigate↵open⌘Jpreview
Made by eblairmckee
Edit on GitHub

Key Value Field

A form component for managing a dynamic list of key-value pairs with add and delete controls.

Installation

bash
npx shadcn@latest add @redpanda/key-value-field
bash
pnpm dlx shadcn@latest add @redpanda/key-value-field
bash
yarn dlx shadcn@latest add @redpanda/key-value-field
bash
bunx shadcn@latest add @redpanda/key-value-field
bash
nubx shadcn@latest add @redpanda/key-value-field
bash
aube dlx shadcn@latest add @redpanda/key-value-field

When 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

tsx
import { KeyValueField } from '@/components/redpanda-ui/components/key-value-field';import { createKeyValueRows, keyValuePairs } from '@/components/redpanda-ui/components/key-value-field/rows';
tsx
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 with append(pair, { shouldFocus: false }).
  • remove: remove its position with remove(index).
  • edit: pass the projected pairs to the existing controller’s field.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

tsx
<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.

tsx
<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

tsx
<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.

tsx
<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

ts
type KeyValuePair = {  key: string;  value: string;};

KeyValueRow identity and helpers

ts
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.

ts
// 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.

bash
npx shadcn@latest add @redpanda/input-utils
bash
pnpm dlx shadcn@latest add @redpanda/input-utils
bash
yarn dlx shadcn@latest add @redpanda/input-utils
bash
bunx shadcn@latest add @redpanda/input-utils
bash
nubx shadcn@latest add @redpanda/input-utils
bash
aube dlx shadcn@latest add @redpanda/input-utils

keyValuePairsSchema

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.

tsx
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.

tsx
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.

tsx
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 waiting

useInputListFocus

Manages focus after add/remove operations in dynamic input lists. Already integrated into KeyValueField — use directly when building your own dynamic list components.

tsx
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 row

Rows 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.

tsx
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 key

Testing

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

Recent changes

  • patchv1.2.0Pin shipped dependency floors to the version we develop against. Registry items now declare ranges like `^5.1.9` (the actual installed version) instead of collapsing to `^5.0.0`, so consumers start on the known-tested baseline while caret semantics still allow any compatible release within the same major.#133
  • minorv1.1.0Theme docs refresh, readability pass on semantic foregrounds, and consumer-facing Base UI regression fixes.#121
  • minorv1.0.0Post-Base-UI polish. Public API unchanged.#116
  • majorv1.0.0Migrate every Radix-based primitive to `@base-ui/react@^1.4.0` (Base UI).#114
  • minorv0.3.0Add theme-provider component to the registry with documentation and tests. Includes playground type improvements (export RegistryItem, remove as-const boilerplate) and docs site dark mode border color fix.#109
See full history →

Last updated on

Was this page helpful?