Skip to content

Details-panel pairs with copy, in-place edit and a wash that marks what changed.

Tablesmotion

01Preview

checkout-web

Production deployment

Serving traffic
Deployment ID
dpl_8fK2mQx7Rz4VbN1c9Ta9c
Status
Ready
Commit
a41f9c2 · main
Description
Checkout redesign, phase 2
Owner
payments-team
Created
4m ago by Maya Lin
Build cache

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { KeyValue, KeyValueList, KeyValueStatus } from "@/components/ui/key-value-list";

<KeyValueList>
  <KeyValue label="Deployment ID" mono truncate="middle" copyValue={deployment.id}>
    {deployment.id}
  </KeyValue>
  <KeyValue label="Status">
    <KeyValueStatus tone="success">Ready</KeyValueStatus>
  </KeyValue>
  <KeyValue label="Description" onSave={(next) => updateDeployment({ description: next })}>
    {deployment.description}
  </KeyValue>
</KeyValueList>

04Source

"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useEffect, useId, useRef, useState } from "react";
import { useCopy } from "@/components/ui/copy-button";
import { cn } from "@/lib/cn";
import { ArrowUpRight, Copy, Loader, Pencil } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";

type ListContext = { divided: boolean };
const Ctx = createContext<ListContext>({ divided: true });

/* -------------------------------------------------------------------------------------------------
 * List
 * -----------------------------------------------------------------------------------------------*/

export type KeyValueListProps = React.ComponentProps<"dl"> & {
  /** Hairlines between rows. */
  divided?: boolean;
  /** Width of the label column. Under a 384px container, labels stack above values instead. */
  labelWidth?: number;
};

export function KeyValueList({ divided = true, labelWidth = 128, className, style, children, ...rest }: KeyValueListProps) {
  return (
    <Ctx.Provider value={{ divided }}>
      <dl
        style={{ "--kv-label": `${labelWidth}px`, ...style } as React.CSSProperties}
        className={cn("@container flex w-full min-w-0 flex-col text-[13px]", className)}
        {...rest}
      >
        {children}
      </dl>
    </Ctx.Provider>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Row
 * -----------------------------------------------------------------------------------------------*/

export type KeyValueProps = Omit<React.ComponentProps<"div">, "children" | "onCopy"> & {
  label: React.ReactNode;
  children?: React.ReactNode;
  /** Text to put on the clipboard. Pass it to show a copy button beside the value. */
  copyValue?: string;
  /** Monospace, for IDs, hashes, keys and hostnames. */
  mono?: boolean;
  /** "middle" keeps the end of a long string ID visible: dpl_8fK2…Ta9c. Needs a string child. */
  truncate?: "end" | "middle" | "none";
  /** Turns the value into a link; external URLs get an arrow and open in a new tab. */
  href?: string;
  /** Makes the value editable in place. Resolve to save; throw to keep the field open with an error. */
  onSave?: (next: string) => void | Promise<unknown>;
  /** The raw text being edited. Defaults to children when it's a string. */
  editValue?: string;
  /** Returns an error message, or null when the draft is fine. */
  validate?: (draft: string) => string | null;
  /** Shown in place of an empty value. */
  emptyLabel?: string;
};

export function KeyValue({
  label,
  children,
  copyValue,
  mono = false,
  truncate = "end",
  href,
  onSave,
  editValue,
  validate,
  emptyLabel = "Not set",
  className,
  ...rest
}: KeyValueProps) {
  const { divided } = useContext(Ctx);
  const [editing, setEditing] = useState(false);
  // Each copy or save bumps a counter; the flash replays on every bump.
  const [flash, setFlash] = useState({ n: 0, tone: "copied" as "copied" | "saved" });
  const editRef = useRef<HTMLButtonElement>(null);
  const empty = children == null || children === "" || children === false;
  const text = typeof children === "string" ? children : undefined;

  const valueNode = empty ? (
    <span className="text-fg-4" aria-label={emptyLabel}>

    </span>
  ) : truncate === "middle" && text && text.length > 14 ? (
    // The head shrinks with an ellipsis; the last six characters never do.
    <span className="flex min-w-0" title={text}>
      <span className="truncate">{text.slice(0, -6)}</span>
      <span className="shrink-0">{text.slice(-6)}</span>
    </span>
  ) : (
    <span className={cn("min-w-0", truncate === "none" ? "break-words [overflow-wrap:anywhere]" : "truncate")} title={truncate === "end" && text ? text : undefined}>
      {children}
    </span>
  );

  const external = href ? /^https?:\/\//.test(href) : false;

  return (
    <div
      data-editing={editing || undefined}
      className={cn(
        "group/kv relative grid min-h-10 grid-cols-[var(--kv-label)_minmax(0,1fr)] items-center gap-x-4 py-1.5",
        "@max-sm:grid-cols-1 @max-sm:gap-y-0.5 @max-sm:py-2",
        divided && "border-b border-line last:border-b-0",
        className,
      )}
      {...rest}
    >
      <dt className="self-start truncate pt-[5px] text-[12.5px] text-fg-3 @max-sm:pt-0">{label}</dt>
      <dd className="flex min-h-7 min-w-0 items-center gap-1">
        {editing && onSave ? (
          <InlineEditor
            initial={editValue ?? text ?? ""}
            label={typeof label === "string" ? label : "Value"}
            mono={mono}
            validate={validate}
            onSave={onSave}
            onDone={(didSave) => {
              setEditing(false);
              if (didSave) setFlash((f) => ({ n: f.n + 1, tone: "saved" }));
              // Hand focus back to the pencil so the keyboard user is where they started.
              requestAnimationFrame(() => editRef.current?.focus());
            }}
          />
        ) : (
          <>
            <span className={cn("relative isolate flex min-w-0 items-center", mono ? "font-mono text-[12px] tracking-[0.01em] text-fg" : "text-fg")}>
              {href && !empty ? (
                <a
                  href={href}
                  target={external ? "_blank" : undefined}
                  rel={external ? "noopener noreferrer" : undefined}
                  className="group/link flex min-w-0 items-center gap-0.5 rounded-sm underline decoration-fg-4 underline-offset-[3px] outline-none transition-[text-decoration-color] duration-150 hover:decoration-fg-2 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3"
                >
                  {valueNode}
                  {external && (
                    <ArrowUpRight size={12} className="shrink-0 text-fg-3 transition-transform duration-150 ease-out group-hover/link:-translate-y-px group-hover/link:translate-x-px" />
                  )}
                </a>
              ) : (
                valueNode
              )}
              <Flash token={flash.n} tone={flash.tone} />
            </span>
            {(copyValue || onSave) && (
              <span className="flex shrink-0 items-center">
                {copyValue && !empty && (
                  <CopyAction text={copyValue} label={typeof label === "string" ? label : "value"} onCopied={() => setFlash((f) => ({ n: f.n + 1, tone: "copied" }))} />
                )}
                {onSave && (
                  <RowAction ref={editRef} aria-label={`Edit ${typeof label === "string" ? label.toLowerCase() : "value"}`} onClick={() => setEditing(true)}>
                    <Pencil size={13} />
                  </RowAction>
                )}
              </span>
            )}
          </>
        )}
      </dd>
    </div>
  );
}

// Row actions stay hidden until the row is hovered or focused, and are always there on touch.
function RowAction({ className, ...rest }: React.ComponentProps<"button">) {
  return (
    <button
      type="button"
      className={cn(
        "relative grid size-6 place-items-center rounded-md text-fg-3 outline-none",
        "opacity-0 transition-[opacity,background-color,color,scale] duration-150 group-hover/kv:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100",
        "hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75",
        "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3",
        "before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden",
        className,
      )}
      {...rest}
    />
  );
}

function CopyAction({ text, label, onCopied }: { text: string; label: string; onCopied: () => void }) {
  const { state, copy } = useCopy({ timeout: 1600, onCopied });
  const reduce = useReducedMotion();
  const name = state === "copied" ? "Copied" : state === "failed" ? "Couldn’t copy" : `Copy ${label.toLowerCase()}`;
  return (
    <>
      <RowAction
        aria-label={name}
        title={name}
        onClick={() => copy(text)}
        data-state={state}
        className={cn(state !== "idle" && "opacity-100", state === "copied" && "text-fg", state === "failed" && "text-danger hover:text-danger")}
      >
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            key={state}
            className="grid place-items-center"
            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(2px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(2px)" }}
            transition={reduce ? { duration: 0.12 } : spring.pop}
          >
            {state === "copied" ? <DrawnMark d="M3.5 8.5 6.5 11.5 12.5 4.5" /> : state === "failed" ? <DrawnMark d="m4.5 4.5 7 7M11.5 4.5l-7 7" /> : <Copy size={13} />}
          </motion.span>
        </AnimatePresence>
      </RowAction>
      <span role="status" aria-live="polite" className="sr-only">
        {state === "copied" ? `${label} copied` : state === "failed" ? "Couldn’t copy. Select the text and copy it instead." : ""}
      </span>
    </>
  );
}

function DrawnMark({ d }: { d: string }) {
  const reduce = useReducedMotion();
  return (
    <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
      <motion.path d={d} initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.28, ease: ease.out, delay: 0.04 }} />
    </svg>
  );
}

// The value washes once and fades, right where you're looking: like a text selection after
// a copy, green after a save. It says what just happened to which value.
function Flash({ token, tone }: { token: number; tone: "copied" | "saved" }) {
  const reduce = useReducedMotion();
  if (!token) return null;
  return (
    <motion.span
      key={token}
      aria-hidden
      className={cn("pointer-events-none absolute -inset-x-1 -inset-y-0.5 -z-1 rounded-md", tone === "saved" ? "bg-success-soft" : "bg-fg/12")}
      initial={{ opacity: 1 }}
      animate={{ opacity: 0 }}
      transition={{ duration: reduce ? 0.3 : 0.9, ease: ease.outQuart, delay: 0.2 }}
    />
  );
}

/* -------------------------------------------------------------------------------------------------
 * Inline editor
 * -----------------------------------------------------------------------------------------------*/

function InlineEditor({
  initial,
  label,
  mono,
  validate,
  onSave,
  onDone,
}: {
  initial: string;
  label: string;
  mono: boolean;
  validate?: (draft: string) => string | null;
  onSave: (next: string) => void | Promise<unknown>;
  onDone: (saved: boolean) => void;
}) {
  const [draft, setDraft] = useState(initial);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const errorId = useId();
  const reduce = useReducedMotion();
  const inputRef = useRef<HTMLInputElement>(null);
  // Focus and select once on open, so typing replaces the old value.
  useEffect(() => {
    inputRef.current?.focus();
    inputRef.current?.select();
  }, []);

  const submit = async () => {
    if (busy) return;
    const next = draft.trim();
    if (next === initial.trim()) return onDone(false);
    const problem = validate?.(next) ?? null;
    if (problem) return setError(problem);
    setBusy(true);
    setError(null);
    try {
      await onSave(next);
      onDone(true);
    } catch {
      setBusy(false);
      setError("Couldn’t save. Try again.");
    }
  };

  return (
    <div className="flex min-w-0 flex-1 flex-col gap-1">
      <div
        className={cn(
          "relative flex h-7 items-center rounded-md border bg-frame transition-[border-color,box-shadow] duration-150",
          error ? "border-danger ring-2 ring-danger-soft" : "border-fg-4 ring-2 ring-fg/10",
        )}
      >
        <input
          ref={inputRef}
          value={draft}
          readOnly={busy}
          aria-label={label}
          aria-invalid={!!error || undefined}
          aria-describedby={error ? errorId : undefined}
          aria-busy={busy || undefined}
          onChange={(e) => (setDraft(e.target.value), error && setError(null))}
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              e.preventDefault();
              submit();
            } else if (e.key === "Escape") {
              e.preventDefault();
              e.stopPropagation();
              onDone(false);
            }
          }}
          // Clicking away keeps the edit: saving is what people expect when they leave a field they changed.
          onBlur={() => {
            if (!error) submit();
          }}
          className={cn(
            "h-full min-w-0 flex-1 bg-transparent px-2 text-base text-fg outline-none sm:text-[13px]",
            mono && "font-mono sm:text-[12px]",
            busy && "text-fg-2",
          )}
        />
        <AnimatePresence initial={false}>
          {busy && (
            <motion.span
              initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.14, delay: 0.12 }}
              className="mr-2 grid place-items-center text-fg-3"
            >
              <Loader size={13} className="animate-spin-slow" />
            </motion.span>
          )}
        </AnimatePresence>
      </div>
      <AnimatePresence initial={false}>
        {error && (
          <motion.p
            id={errorId}
            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -2 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, transition: { duration: 0.1 } }}
            transition={{ duration: 0.18, ease: ease.out }}
            className="text-[12px] text-danger"
          >
            {error}
          </motion.p>
        )}
      </AnimatePresence>
      <span className="sr-only">Enter to save, Escape to cancel.</span>
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Status value
 * -----------------------------------------------------------------------------------------------*/

export type KeyValueStatusProps = React.ComponentProps<"span"> & {
  tone?: "neutral" | "success" | "warning" | "danger" | "info";
  /** A soft ring pulses on the dot while the state is still changing (building, syncing). */
  live?: boolean;
};

const dot = { neutral: "bg-fg-3", success: "bg-success", warning: "bg-warning", danger: "bg-danger", info: "bg-info" } as const;

export function KeyValueStatus({ tone = "neutral", live = false, className, children, ...rest }: KeyValueStatusProps) {
  return (
    <span className={cn("inline-flex items-center gap-2 text-fg", className)} {...rest}>
      <span aria-hidden className="relative grid size-2 place-items-center">
        {live && <span className={cn("absolute inset-0 rounded-full motion-safe:animate-ping-soft", dot[tone])} />}
        <span className={cn("relative size-1.5 rounded-full", dot[tone])} />
      </span>
      {children}
    </span>
  );
}

05Props

KeyValueList

PropTypeDefaultDescription
dividedbooleantrueHairlines between rows.
labelWidthnumber128Label column width in px. Under a 384px container, labels stack above values.

KeyValue

PropTypeDefaultDescription
label*ReactNodeThe term. Also names the row's copy and edit buttons.
childrenReactNodeThe value. Empty shows a faint dash read out as “Not set”.
copyValuestringShows a copy button beside the value that copies this text.
monobooleanfalseMonospace for IDs, hashes and hostnames.
truncate"end" | "middle" | "none""end"“middle” keeps the last six characters of a long string visible; “none” wraps.
hrefstringMakes the value a link; http(s) links get an arrow and open in a new tab.
onSave(next: string) => void | Promise<unknown>Makes the value editable. Resolve to close; reject to keep the field open with “Couldn’t save”.
editValuestringThe raw text to edit when children isn't a plain string.
validate(draft: string) => string | nullReturn a message to block the save and show it under the field.
emptyLabelstring"Not set"What screen readers hear for an empty value.

KeyValueStatus

PropTypeDefaultDescription
tone"neutral" | "success" | "warning" | "danger" | "info""neutral"Dot color. Always paired with the word.
livebooleanfalseA soft ring pulses on the dot while the state is still changing.

06Notes

Behavior

  • Copy and edit buttons sit right after the value, appear on row hover or focus, and are always visible on touch; they never shift the value when they appear.
  • Editing swaps the value for a field of the same height with the text selected. Enter or clicking away saves, Escape cancels, and focus returns to the pencil either way. An unchanged value closes without saving.
  • A rejected save keeps the draft and says “Couldn’t save. Try again.” under the field; a validate message blocks the save before any request.
  • Long IDs truncate in the middle so both the prefix and the distinguishing tail stay readable; the full value is in the title and on the clipboard.

Motion

  • After a copy the value washes like a text selection, and after a save it washes green, then fades over 900ms on the quart ease-out: it points at exactly what changed.
  • The copy icon pops to a tick on the pop spring and the tick draws in 280ms; errors slide 2px in over 180ms. Nothing shakes.
  • The saving spinner waits 120ms before fading in so quick saves never flash it. Reduced motion keeps the washes and fades, drops scale, blur and the path draw.

Accessibility

  • A real dl with dt and dd pairs; rows stack on narrow containers without changing reading order.
  • Copy and edit are buttons named after the row (“Copy deployment id”, “Edit owner”); copy results are announced in a polite live region.
  • The edit field is labeled by the row's label, marked aria-invalid with its error linked by aria-describedby, and aria-busy while saving.