Key Value Editor
Data Display

Key Value Editor

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.

Install

npx shadcn@latest add @paragon/key-value-editor

Also installs: tooltip

key-value-editor.tsx

"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>
  );
}