Env-style editable key-value rows with drag and keyboard reordering, layout-animated add and remove, live duplicate-key flagging, and per-row secret masking.
npx shadcn@latest add @paragon/key-value-editorAlso installs: tooltip
"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
Reorder,
useDragControls,
useReducedMotion,
} from "motion/react";
import { Eye, EyeOff, GripVertical, Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface KeyValuePair {
key: string;
value: string;
/** Masks the value behind a reveal toggle. */
secret?: boolean;
}
interface PairState extends Required<KeyValuePair> {
id: string;
}
export interface KeyValueEditorProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
defaultPairs?: KeyValuePair[];
onChange?: (pairs: KeyValuePair[]) => void;
keyPlaceholder?: string;
valuePlaceholder?: string;
addLabel?: string;
/** Drag handles + keyboard reorder. */
reorderable?: boolean;
/** Disables add/remove/reorder motion. */
static?: boolean;
}
/**
* Env-style editable rows. Adding and removing animate through layout
* (popLayout, so siblings close the gap with transforms); rows reorder by
* drag handle or with arrow keys on the handle; duplicate keys are flagged
* on both offenders the moment they collide; secret values sit behind a
* per-row reveal toggle with the house icon swap.
*/
export function KeyValueEditor({
defaultPairs = [],
onChange,
keyPlaceholder = "KEY",
valuePlaceholder = "value",
addLabel = "Add variable",
reorderable = true,
static: isStatic = false,
className,
...props
}: KeyValueEditorProps) {
const uid = React.useId();
const counter = React.useRef(defaultPairs.length);
const justAdded = React.useRef<string | null>(null);
const initialMount = React.useRef(true);
const reducedMotion = useReducedMotion() ?? false;
const noMotion = reducedMotion || isStatic;
const [pairs, setPairs] = React.useState<PairState[]>(() =>
defaultPairs.map((pair, i) => ({
id: `${uid}-${i}`,
key: pair.key,
value: pair.value,
secret: pair.secret ?? false,
})),
);
const [revealed, setRevealed] = React.useState<Set<string>>(new Set());
const [announce, setAnnounce] = React.useState("");
React.useEffect(() => {
initialMount.current = false;
}, []);
const commit = (next: PairState[]) => {
setPairs(next);
onChange?.(next.map(({ key, value, secret }) => ({ key, value, secret })));
};
const update = (id: string, patch: Partial<PairState>) => {
commit(pairs.map((p) => (p.id === id ? { ...p, ...patch } : p)));
};
const addRow = () => {
const id = `${uid}-${counter.current++}`;
justAdded.current = id;
commit([...pairs, { id, key: "", value: "", secret: false }]);
};
const removeRow = (id: string) => {
commit(pairs.filter((p) => p.id !== id));
};
const moveRow = (id: string, delta: -1 | 1) => {
const index = pairs.findIndex((p) => p.id === id);
const target = index + delta;
if (index === -1 || target < 0 || target >= pairs.length) return;
const next = [...pairs];
[next[index], next[target]] = [next[target], next[index]];
commit(next);
setAnnounce(
`Moved ${next[target].key || "row"} ${delta === -1 ? "up" : "down"} to position ${target + 1}`,
);
};
// Keys used more than once (trimmed, non-empty) — both offenders flag.
const duplicates = React.useMemo(() => {
const seen = new Map<string, number>();
for (const pair of pairs) {
const key = pair.key.trim();
if (key) seen.set(key, (seen.get(key) ?? 0) + 1);
}
return new Set([...seen.entries()].filter(([, n]) => n > 1).map(([k]) => k));
}, [pairs]);
return (
<TooltipProvider>
<div
data-slot="key-value-editor"
className={cn("w-full", className)}
{...props}
>
{pairs.length === 0 ? (
<div className="flex flex-col items-center gap-2.5 rounded-xl border border-dashed px-4 py-8 text-center">
<p className="text-[13px] text-muted-foreground">
No variables yet
</p>
<AddButton onClick={addRow} label={addLabel} isStatic={isStatic} />
</div>
) : (
<>
<Reorder.Group
axis="y"
values={pairs}
onReorder={commit}
className="flex flex-col gap-2"
>
<AnimatePresence initial={false} mode="popLayout">
{pairs.map((pair, index) => (
<EditorRow
key={pair.id}
pair={pair}
index={index}
last={index === pairs.length - 1}
duplicate={duplicates.has(pair.key.trim())}
revealed={revealed.has(pair.id)}
reorderable={reorderable && pairs.length > 1}
noMotion={noMotion}
initialMount={initialMount.current}
autoFocusKey={justAdded.current === pair.id}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
onUpdate={update}
onRemove={removeRow}
onMove={moveRow}
onAddAfter={addRow}
onToggleReveal={() =>
setRevealed((prev) => {
const next = new Set(prev);
if (next.has(pair.id)) next.delete(pair.id);
else next.add(pair.id);
return next;
})
}
/>
))}
</AnimatePresence>
</Reorder.Group>
<div className="mt-3 flex items-center justify-between gap-2">
<AddButton onClick={addRow} label={addLabel} isStatic={isStatic} />
<span className="text-xs text-muted-foreground tabular-nums">
{pairs.length} {pairs.length === 1 ? "variable" : "variables"}
</span>
</div>
</>
)}
<AnimatePresence>
{duplicates.size > 0 && (
<motion.p
role="alert"
initial={{ opacity: 0, y: -4, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="mt-2 text-xs text-destructive"
>
Duplicate {duplicates.size === 1 ? "key" : "keys"}:{" "}
{[...duplicates].join(", ")}
</motion.p>
)}
</AnimatePresence>
<span role="status" aria-live="polite" className="sr-only">
{announce}
</span>
</div>
</TooltipProvider>
);
}
function AddButton({
onClick,
label,
isStatic,
}: {
onClick: () => void;
label: string;
isStatic: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-lg bg-card px-3 pl-2.5 text-[13px] font-medium shadow-border",
"transition-[scale,box-shadow,background-color] duration-150 ease-out hover:shadow-border-hover dark:bg-secondary/30",
!isStatic && "active:not-disabled:scale-[0.97]",
)}
>
<Plus aria-hidden className="size-3.5 text-muted-foreground" />
{label}
</button>
);
}
const INPUT_CLASSES = cn(
"h-8 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 font-mono text-[12.5px] text-foreground",
"transition-[border-color,box-shadow] duration-150 ease-out",
"placeholder:text-muted-foreground/70",
"outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
);
function EditorRow({
pair,
index,
last,
duplicate,
revealed,
reorderable,
noMotion,
initialMount,
autoFocusKey,
keyPlaceholder,
valuePlaceholder,
onUpdate,
onRemove,
onMove,
onAddAfter,
onToggleReveal,
}: {
pair: PairState;
index: number;
last: boolean;
duplicate: boolean;
revealed: boolean;
reorderable: boolean;
noMotion: boolean;
initialMount: boolean;
autoFocusKey: boolean;
keyPlaceholder: string;
valuePlaceholder: string;
onUpdate: (id: string, patch: Partial<PairState>) => void;
onRemove: (id: string) => void;
onMove: (id: string, delta: -1 | 1) => void;
onAddAfter: () => void;
onToggleReveal: () => void;
}) {
const dragControls = useDragControls();
const masked = pair.secret && !revealed;
return (
<Reorder.Item
value={pair}
dragListener={false}
dragControls={dragControls}
data-kv-row
initial={
noMotion ? { opacity: 0 } : { opacity: 0, y: 10, filter: "blur(4px)" }
}
animate={{
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: {
type: "spring",
duration: 0.35,
bounce: 0,
delay: initialMount ? Math.min(index, 8) * 0.05 : 0,
},
}}
exit={
noMotion
? { opacity: 0, transition: { duration: 0.1 } }
: {
opacity: 0,
scale: 0.98,
filter: "blur(4px)",
transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
}
}
className="grid grid-cols-[auto_minmax(0,2fr)_minmax(0,3fr)_auto] items-center gap-2"
>
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${pair.key || "row"} — arrow keys move it`}
onPointerDown={(event) => {
event.preventDefault();
dragControls.start(event);
}}
onKeyDown={(event) => {
if (event.key === "ArrowUp") {
event.preventDefault();
onMove(pair.id, -1);
} else if (event.key === "ArrowDown") {
event.preventDefault();
onMove(pair.id, 1);
}
}}
className={cn(
"relative flex size-6 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/60",
"transition-colors duration-(--duration-fast) hover:text-foreground active:cursor-grabbing",
"after:absolute after:top-1/2 after:left-1/2 after:size-8 after:-translate-1/2",
)}
>
<GripVertical className="size-3.5" />
</button>
) : (
<span aria-hidden className="w-6 shrink-0" />
)}
<input
type="text"
value={pair.key}
autoFocus={autoFocusKey}
spellCheck={false}
autoComplete="off"
placeholder={keyPlaceholder}
aria-label="Key"
aria-invalid={duplicate || undefined}
onChange={(event) => onUpdate(pair.id, { key: event.target.value })}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget
.closest("[data-kv-row]")
?.querySelector<HTMLInputElement>("[data-kv-value]")
?.focus();
}
}}
className={cn(
INPUT_CLASSES,
duplicate &&
"border-destructive text-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
)}
/>
<span className="relative min-w-0">
<input
data-kv-value
type={masked ? "password" : "text"}
value={pair.value}
spellCheck={false}
autoComplete="off"
placeholder={valuePlaceholder}
aria-label="Value"
onChange={(event) => onUpdate(pair.id, { value: event.target.value })}
onKeyDown={(event) => {
if (event.key === "Enter" && last) {
event.preventDefault();
onAddAfter();
}
}}
className={cn(INPUT_CLASSES, "pr-8")}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={masked ? "Reveal value" : "Mask value"}
aria-pressed={!masked}
onClick={() => {
if (!pair.secret) onUpdate(pair.id, { secret: true });
else onToggleReveal();
}}
className={cn(
"absolute top-1/2 right-1 flex size-6 -translate-y-1/2 items-center justify-center rounded-[5px] text-muted-foreground",
"transition-colors duration-(--duration-fast) hover:text-foreground",
)}
>
{noMotion ? (
masked ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={masked ? "off" : "on"}
className="flex"
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{masked ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</motion.span>
</AnimatePresence>
)}
</button>
</TooltipTrigger>
<TooltipContent>
{pair.secret ? (masked ? "Reveal" : "Mask") : "Mark as secret"}
</TooltipContent>
</Tooltip>
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`Remove ${pair.key || "row"}`}
onClick={() => onRemove(pair.id)}
className={cn(
"relative flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70",
"transition-colors duration-(--duration-fast) hover:text-destructive",
"after:absolute after:top-1/2 after:left-1/2 after:size-8 after:-translate-1/2",
)}
>
<Trash2 className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
</Reorder.Item>
);
}