Developer

Key value

A description list for facts about one thing, such as IDs, regions, versions and URLs, with optional copy.

Built on
<dl>CSS subgridAsync Clipboard API
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.0 kB
Dependencies
React
ID
dpl_7f3a9c2e81b4d05a
Region
eu-west-1
Commit
7f3a9c2e81b4d05a6c1e
Try its options

Loading its options.

Code
import { KeyValue, KeyValueItem } from "@/components/equip/key-value";

export default function Demo() {
  return (
    <KeyValue label="Deployment" dense style={{ width: 224 }}>
      <KeyValueItem term="ID" mono copy>
        dpl_7f3a9c2e81b4d05a
      </KeyValueItem>
      <KeyValueItem term="Region">eu-west-1</KeyValueItem>
      <KeyValueItem term="Commit" mono copy>
        7f3a9c2e81b4d05a6c1e
      </KeyValueItem>
      <KeyValueItem term="URL" href="https://web-7f3a9c.example.com" copy>
        web-7f3a9c.example.com
      </KeyValueItem>
    </KeyValue>
  );
}
Your versionchange its options in Look, This part

Examples

Stacked and grid

Grid fits as many columns as the width allows, which suits a summary above a detail page.

Engine
Postgres 16.4
Region
eu-central-1
Storage
48.2 GB of 100 GB
Replicas
2 read replicas
Host
db-prod-7f3a.eu-central-1.equip.internal
Port
5432
Code
import { KeyValue, KeyValueItem } from "@/components/equip/key-value";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 24, maxWidth: 560 }}>
      <KeyValue label="Database" layout="grid">
        <KeyValueItem term="Engine">Postgres 16.4</KeyValueItem>
        <KeyValueItem term="Region">eu-central-1</KeyValueItem>
        <KeyValueItem term="Storage">48.2 GB of 100 GB</KeyValueItem>
        <KeyValueItem term="Replicas">2 read replicas</KeyValueItem>
      </KeyValue>
      <KeyValue label="Connection" layout="stacked" style={{ maxWidth: 320 }}>
        <KeyValueItem term="Host" mono copy>
          db-prod-7f3a.eu-central-1.equip.internal
        </KeyValueItem>
        <KeyValueItem term="Port" mono>
          5432
        </KeyValueItem>
      </KeyValue>
    </div>
  );
}

Middle truncation and copy

Long values give way in the middle so both ends stay readable; the full value is in the tooltip and is what gets copied.

Key ID
key_01HZX4T8M3QK7N2R9VBW5YCDE6
Deploy
pnpm run deploy --prod
Code
import { KeyValue, KeyValueItem } from "@/components/equip/key-value";

export default function Example() {
  return (
    <KeyValue label="API key" style={{ maxWidth: 340 }}>
      <KeyValueItem term="Key ID" mono copy>
        key_01HZX4T8M3QK7N2R9VBW5YCDE6
      </KeyValueItem>
      <KeyValueItem term="Webhook" href="https://hooks.example.com/deploys/production" copy>
        https://hooks.example.com/deploys/production
      </KeyValueItem>
      <KeyValueItem term="Deploy" mono copy="pnpm run deploy --prod">
        pnpm run deploy --prod
      </KeyValueItem>
    </KeyValue>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneKeyValue: names the list for assistive tech.
layout"rows" | "stacked" | "grid""rows"KeyValue: term beside value, term above value, or pairs in as many columns as fit.
densebooleanfalseKeyValue: tighter spacing for side panels.
termReactNodenoneKeyValueItem: the name of the fact.
copystring | truenoneKeyValueItem: shows a copy button; true copies the full value, a string copies that text.
monobooleanfalseKeyValueItem: sets the value in the monospace face.
hrefstringnoneKeyValueItem: makes the value a link.
childrenReactNodenoneKeyValueItem: the value. A string truncates in the middle.
...restComponentProps<"dl"> / ComponentProps<"div">noneNative attributes for the list and for each pair.

Anatomy

root.eq-key-valueThe dl.
item.eq-key-value-itemA div grouping one dt and dd.
term.eq-key-value-term
value.eq-key-value-valueThe dd.
text.eq-key-value-textA span, or a link with href.
head.eq-key-value-headThe part of a long value that gives way.
tail.eq-key-value-tailThe end of a long value, always shown.
copy.eq-key-value-copy
status.eq-key-value-srAnnounces Copied.

Keyboard

TabMoves to each link and copy button.
EnterSpaceCopies that value; Enter follows a link.

Notes

  • It is a real dl with dt and dd, so screen readers announce terms and values as pairs.
  • Long string values truncate in the middle; the full value is in the DOM, in the tooltip and in what is copied.
  • Only string or number children truncate in the middle; other nodes render as given.
  • The file is a client component only for the copy buttons; everything else renders on the server.
  • The rows layout aligns terms with CSS subgrid; without it each pair stacks, term above value. Copy falls back to a temporary textarea and execCommand where the async clipboard is unavailable.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/key-value.tsx154 lines
"use client";

import { useEffect, useRef, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import "./key-value.css";

export type KeyValueProps = ComponentProps<"dl"> & {
  /** Names the list for assistive tech, such as "Deployment details". */
  label?: string;
  /** Term beside value, term above value, or stacked pairs in columns. */
  layout?: "rows" | "stacked" | "grid";
  /** Tighter spacing for side panels and inspectors. */
  dense?: boolean;
};

export type KeyValueItemProps = ComponentProps<"div"> & {
  term: ReactNode;
  /** Shows a copy button: `true` copies the value as shown in full, a string copies that text instead. */
  copy?: string | true;
  /** Sets the value in the monospace face, for IDs, hashes and paths. */
  mono?: boolean;
  /** Makes the value a link. */
  href?: string;
};

const COPIED_MS = 1600;

type CopyState = "idle" | "copied" | "failed";

/** Last resort for browsers without the async clipboard: a throwaway textarea inside the part. */
function legacyCopy(text: string, host: HTMLElement) {
  const active = document.activeElement;
  const area = document.createElement("textarea");
  area.value = text;
  area.setAttribute("readonly", "");
  area.setAttribute("aria-hidden", "true");
  area.style.position = "fixed";
  area.style.inset = "0 auto auto 0";
  area.style.opacity = "0";
  area.style.pointerEvents = "none";
  host.appendChild(area);
  area.select();
  let ok = false;
  try {
    ok = document.execCommand("copy");
  } catch {
    ok = false;
  }
  host.removeChild(area);
  if (active instanceof HTMLElement) active.focus();
  return ok;
}

/** The whole text stays in the DOM; only the head shrinks, so the end of an ID or URL stays readable. */
function middle(text: string): ReactNode {
  if (text.length < 16) return <span className="eq-key-value-head">{text}</span>;
  const tail = Math.min(12, Math.floor(text.length / 3));
  return (
    <>
      <span className="eq-key-value-head">{text.slice(0, -tail)}</span>
      <span className="eq-key-value-tail">{text.slice(-tail)}</span>
    </>
  );
}

export function KeyValue({ label, layout = "rows", dense = false, className, ...rest }: KeyValueProps) {
  return (
    <dl
      aria-label={label}
      {...rest}
      className={["eq-key-value", className].filter(Boolean).join(" ")}
      data-layout={layout}
      data-dense={dense || undefined}
    />
  );
}

export function KeyValueItem({ term, copy, mono = false, href, className, children, ...rest }: KeyValueItemProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const valueRef = useRef<HTMLElement>(null);
  const [copied, setCopied] = useState<CopyState>("idle");
  const full = typeof children === "string" || typeof children === "number" ? String(children) : null;
  const content = full != null ? middle(full) : children;

  useEffect(() => {
    if (copied === "idle") return;
    const t = window.setTimeout(() => setCopied("idle"), COPIED_MS);
    return () => window.clearTimeout(t);
  }, [copied]);

  async function handleCopy() {
    const text = typeof copy === "string" ? copy : (full ?? valueRef.current?.textContent ?? "");
    let ok = false;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(text);
        ok = true;
      }
    } catch {
      ok = false;
    }
    if (!ok && rootRef.current) ok = legacyCopy(text, rootRef.current);
    setCopied(ok ? "copied" : "failed");
  }

  const shared = {
    className: "eq-key-value-text",
    "data-mono": mono || undefined,
    title: full ?? undefined,
  };

  return (
    <div {...rest} ref={rootRef} className={["eq-key-value-item", className].filter(Boolean).join(" ")}>
      <dt className="eq-key-value-term">{term}</dt>
      <dd className="eq-key-value-value">
        {href ? (
          <a {...shared} ref={(node) => { valueRef.current = node; }} href={href}>
            {content}
          </a>
        ) : (
          <span {...shared} ref={(node) => { valueRef.current = node; }}>
            {content}
          </span>
        )}
        {copy != null && (
          <button
            type="button"
            className="eq-key-value-copy"
            data-state={copied}
            aria-label={typeof term === "string" ? `Copy ${term.toLowerCase()}` : "Copy value"}
            title={copied === "copied" ? "Copied" : copied === "failed" ? "Copy failed" : "Copy"}
            onClick={handleCopy}
          >
            {copied === "copied" ? (
              <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M3.5 8.5l3 3 6-7" pathLength={1} />
              </svg>
            ) : (
              <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
                <path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5" />
              </svg>
            )}
          </button>
        )}
        {copy != null && (
          <span className="eq-key-value-sr" role="status">
            {copied === "copied" ? "Copied" : copied === "failed" ? "Copy failed" : ""}
          </span>
        )}
      </dd>
    </div>
  );
}
components/equip/key-value.css184 lines
@layer equip {
  .eq-key-value {
    --_pad: var(--eq-space-2);

    display: grid;
    grid-template-columns: fit-content(40%) minmax(0, 1fr);
    column-gap: var(--eq-space-4);
    min-width: 0;
    margin: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.45 var(--eq-font);
  }

  .eq-key-value[data-dense] {
    --_pad: var(--eq-space-1);
  }

  .eq-key-value-item {
    display: grid;
    grid-template-columns: subgrid;
    grid-column: 1 / -1;
    align-items: baseline;
    min-width: 0;
    padding-block: var(--_pad);
    border-top: 1px solid var(--eq-line);
  }

  .eq-key-value-item:first-child {
    border-top: 0;
  }

  .eq-key-value[data-layout="stacked"] {
    grid-template-columns: minmax(0, 1fr);
  }

  .eq-key-value:is([data-layout="stacked"], [data-layout="grid"]) .eq-key-value-item {
    grid-template-columns: minmax(0, 1fr);
    row-gap: 2px;
  }

  .eq-key-value[data-layout="grid"] {
    grid-template-columns: repeat(auto-fill, minmax(min(100%, 160px), 1fr));
    gap: var(--eq-space-3) var(--eq-space-5);
  }

  .eq-key-value[data-layout="grid"] .eq-key-value-item {
    grid-column: auto;
    padding-block: 0;
    border-top: 0;
  }

  .eq-key-value-term {
    min-width: 0;
    color: var(--eq-ink-2);
    overflow-wrap: anywhere;
  }

  .eq-key-value-value {
    display: flex;
    align-items: baseline;
    gap: var(--eq-space-1);
    min-width: 0;
    margin: 0;
  }

  .eq-key-value-text {
    display: flex;
    flex: 0 1 auto;
    min-width: 0;
    color: var(--eq-ink);
  }

  .eq-key-value-text[data-mono] {
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: var(--eq-text-xs);
  }

  .eq-key-value-head {
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    white-space: pre;
    text-overflow: ellipsis;
  }

  .eq-key-value-tail {
    flex: none;
    white-space: pre;
  }

  a.eq-key-value-text {
    border-radius: 2px;
    text-decoration: underline;
    text-decoration-color: var(--eq-line-strong);
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
    transition: text-decoration-color var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    a.eq-key-value-text:hover {
      text-decoration-color: currentColor;
    }

    .eq-key-value-copy:hover {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }
  }

  a.eq-key-value-text:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-key-value-copy {
    display: grid;
    flex: none;
    place-items: center;
    align-self: center;
    width: 24px;
    height: 24px;
    margin-block: -4px;
    padding: 0;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink-3);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-key-value-copy > svg {
    width: 14px;
    height: 14px;
  }

  .eq-key-value-copy:active {
    scale: var(--eq-press);
  }

  .eq-key-value-copy:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-key-value-copy[data-state="copied"] {
    color: var(--eq-ok);
  }

  .eq-key-value-copy[data-state="failed"] {
    color: var(--eq-danger);
  }

  /* Copied: the tick draws itself in. */
  .eq-key-value-copy[data-state="copied"] path {
    stroke-dasharray: 1;
    stroke-dashoffset: 0;
    transition: stroke-dashoffset var(--eq-dur-move) var(--eq-ease);
  }

  @starting-style {
    .eq-key-value-copy[data-state="copied"] path {
      stroke-dashoffset: 1;
    }
  }

  /* Read by screen readers, never seen. */
  .eq-key-value-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index