Developer

Secret

Shows a sensitive value such as an API key, masked by default, with reveal and copy buttons.

Built on
Async Clipboard APIaria-pressedrole=status
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.6 kB
Dependencies
React
Production key
Hidden value ending in 4f2a
Code
import { Secret } from "@/components/equip/secret";

export default function Demo() {
  return <Secret label="Production key" value="sk_live_9aF2kQ7mXc41Lz8T4f2a" />;
}

Examples

Re-masks after ten seconds

The countdown starts when the value is shown, and screen readers hear Hidden again when it ends.

Webhook signing secret
Hidden value ending in 1Yd9

Rotate it from Settings if it was shared.

Code
import { Secret } from "@/components/equip/secret";

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 280 }}>
      <Secret
        label="Webhook signing secret"
        value="whsec_Nq4vT8pLz2Rk6mWc1Yd9"
        revealFor={10}
        hint="Rotate it from Settings if it was shared."
      />
    </div>
  );
}

Long token

Masked, every value is the same width. Revealed, a long token wraps at any character instead of being cut off.

Service account token
Hidden value ending in 9rZW4x
Code
import { Secret } from "@/components/equip/secret";

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 280 }}>
      <Secret
        label="Service account token"
        value="eyJhbGciOiJSUzI1NiIsImtpZCI6ImVxLTIwMjYtMTEifQ.eyJzdWIiOiJkZXBsb3ktYm90In0.Qm9vdHN0cmFwVG9rZW4x"
        visibleStart={6}
        visibleEnd={6}
      />
    </div>
  );
}

A list of keys

Publishable key
Hidden value ending in Za3c
Secret key
Hidden value ending in Kf7b

Created 12 Mar by Ana Ruiz. Last used 2 hours ago.

Code
import { Secret } from "@/components/equip/secret";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 280 }}>
      <Secret label="Publishable key" value="pk_live_51HqX2bLk8Rt0vQm7Za3c" visibleStart={8} />
      <Secret label="Secret key" value="sk_live_51HqX2bJw4Np9sDe2Kf7b" hint="Created 12 Mar by Ana Ruiz. Last used 2 hours ago." />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
valuestringnoneThe full secret. Copy always copies all of it.
labelReactNodenoneVisible label, which also names the group.
visibleStartnumber4Characters left visible at the start while masked.
visibleEndnumber4Characters left visible at the end while masked.
revealFornumber0Seconds before a revealed value masks itself again, with a countdown. 0 keeps it revealed until toggled.
onCopy() => voidnoneCalled after the value reaches the clipboard.
hintReactNodenoneHelp text under the control, linked with aria-describedby.
...restComponentProps<"div">noneEvery native div attribute except the native onCopy, including className, style and ref.

Anatomy

root.eq-secretdata-state is masked or revealed.
label.eq-secret-label
control.eq-secret-controlA read-only looking box with role group, named by the label.
value.eq-secret-valueMonospace; selects in one go when revealed.
mask.eq-secret-maskA fixed run of bullets, whatever the secret's length.
timer.eq-secret-timerSeconds left before re-masking, shown only with revealFor.
button.eq-secret-buttondata-action is reveal or copy; copy carries data-state idle, copied or failed.
hint.eq-secret-hint
screen reader text.eq-secret-srThe masked description and the live region; visually hidden.

Keyboard

TabMoves between the reveal and copy buttons.
EnterSpaceToggles the reveal, or copies the full value.

Notes

  • This is not a security boundary: the full value is in the DOM and in React props, so only render secrets the viewer may see.
  • Masked, the visible characters are hidden from screen readers, which hear Hidden value ending in the last characters instead.
  • The mask is always the same number of bullets, so it does not reveal how long the secret is.
  • The reveal button keeps the name Show value and reports its state with aria-pressed.
  • Copy uses the async Clipboard API, falls back to a temporary textarea, and shows Copy failed when neither works.
  • A live region announces Copied, Copy failed and Hidden again.
  • Where navigator.clipboard is missing or refused, such as on plain http, copy falls back to document.execCommand("copy"), and shows Copy failed if that is blocked too.

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/secret.tsx224 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import "./secret.css";

export type SecretProps = Omit<ComponentProps<"div">, "children" | "onCopy"> & {
  /** The full secret. It is in the DOM and in props; masking is visual only. */
  value: string;
  /** Visible label, which also names the group. */
  label: ReactNode;
  /** Characters kept visible at the start while masked. */
  visibleStart?: number;
  /** Characters kept visible at the end while masked. */
  visibleEnd?: number;
  /** Seconds before a revealed value masks itself again. 0 keeps it revealed until toggled. */
  revealFor?: number;
  /** Called after the value reaches the clipboard. */
  onCopy?: () => void;
  /** Help text under the control. */
  hint?: ReactNode;
};

/** Fixed, so the mask never hints at the secret's length. */
const BULLETS = "•".repeat(8);
const COPIED_MS = 1600;

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

function present(node: ReactNode): boolean {
  return node != null && node !== false && node !== "";
}

export function Secret({
  value,
  label,
  visibleStart = 4,
  visibleEnd = 4,
  revealFor = 0,
  onCopy,
  hint,
  className,
  ...rest
}: SecretProps) {
  const id = useId();
  const labelId = `${id}-label`;
  const hintId = `${id}-hint`;
  const controlRef = useRef<HTMLDivElement>(null);
  const [revealed, setRevealed] = useState(false);
  const [left, setLeft] = useState(0);
  const [copy, setCopy] = useState<CopyState>("idle");
  const [announce, setAnnounce] = useState("");

  // Auto re-mask: counts down whole seconds, then hides and says so.
  useEffect(() => {
    if (!revealed || revealFor <= 0) return;
    setLeft(Math.ceil(revealFor));
    const started = performance.now();
    const tick = window.setInterval(() => {
      const remaining = Math.ceil(revealFor - (performance.now() - started) / 1000);
      if (remaining <= 0) {
        setRevealed(false);
        setAnnounce("Hidden again");
      } else {
        setLeft(remaining);
      }
    }, 250);
    return () => window.clearInterval(tick);
  }, [revealed, revealFor]);

  // The copied or failed state returns to idle on its own.
  useEffect(() => {
    if (copy === "idle") return;
    const t = window.setTimeout(() => {
      setCopy("idle");
      // Cleared so the next copy is announced again.
      setAnnounce((a) => (a === "Copied" || a === "Copy failed" ? "" : a));
    }, COPIED_MS);
    return () => window.clearTimeout(t);
  }, [copy]);

  function toggle() {
    setAnnounce("");
    setLeft(Math.ceil(revealFor));
    setRevealed((r) => !r);
  }

  /** Last resort for browsers without the async clipboard: a throwaway textarea in this root. */
  function legacyCopy(text: string) {
    const root = controlRef.current;
    if (!root) return false;
    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";
    root.appendChild(area);
    area.select();
    let ok = false;
    try {
      ok = document.execCommand("copy");
    } catch {
      ok = false;
    }
    root.removeChild(area);
    if (active instanceof HTMLElement) active.focus();
    return ok;
  }

  async function handleCopy() {
    let ok = false;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(value);
        ok = true;
      }
    } catch {
      ok = false;
    }
    if (!ok) ok = legacyCopy(value);
    setCopy(ok ? "copied" : "failed");
    setAnnounce(ok ? "Copied" : "Copy failed");
    if (ok) onCopy?.();
  }

  const showEnds = value.length > visibleStart + visibleEnd;
  const start = showEnds ? value.slice(0, visibleStart) : "";
  const end = showEnds && visibleEnd > 0 ? value.slice(-visibleEnd) : "";
  const hiddenText = end ? `Hidden value ending in ${end}` : "Hidden value";
  const copyLabel = copy === "copied" ? "Copied" : copy === "failed" ? "Copy failed" : "Copy value";

  return (
    <div
      {...rest}
      className={["eq-secret", className].filter(Boolean).join(" ")}
      data-state={revealed ? "revealed" : "masked"}
    >
      <span className="eq-secret-label" id={labelId}>
        {label}
      </span>
      <div
        ref={controlRef}
        className="eq-secret-control"
        role="group"
        aria-labelledby={labelId}
        aria-describedby={present(hint) ? hintId : undefined}
      >
        {revealed ? (
          <span key="shown" className="eq-secret-value">{value}</span>
        ) : (
          <span key="masked" className="eq-secret-value">
            <span aria-hidden="true">
              {start}
              <span className="eq-secret-mask">{BULLETS}</span>
              {end}
            </span>
            <span className="eq-secret-sr">{hiddenText}</span>
          </span>
        )}
        {revealed && revealFor > 0 && (
          <span className="eq-secret-timer" aria-hidden="true">
            {left}s
          </span>
        )}
        <button
          type="button"
          className="eq-secret-button"
          data-action="reveal"
          aria-pressed={revealed}
          aria-label="Show value"
          onClick={toggle}
        >
          {revealed ? (
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M2 2l12 12M6.6 3.7A6.6 6.6 0 0 1 8 3.5c3.6 0 6 4.5 6 4.5a11 11 0 0 1-1.7 2.2M4.2 4.9A11 11 0 0 0 2 8s2.4 4.5 6 4.5a6 6 0 0 0 2.6-.6M6.6 6.6a2 2 0 0 0 2.8 2.8" />
            </svg>
          ) : (
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M2 8s2.4-4.5 6-4.5S14 8 14 8s-2.4 4.5-6 4.5S2 8 2 8Z" />
              <circle cx="8" cy="8" r="2" />
            </svg>
          )}
        </button>
        <button
          type="button"
          className="eq-secret-button"
          data-action="copy"
          data-state={copy}
          aria-label="Copy value"
          title={copyLabel}
          onClick={handleCopy}
        >
          {copy === "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>
          ) : copy === "failed" ? (
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M8 4.5v4M8 11.2v.1" />
              <circle cx="8" cy="8" r="6" />
            </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>
      </div>
      {present(hint) && (
        <p className="eq-secret-hint" id={hintId}>
          {hint}
        </p>
      )}
      <span className="eq-secret-sr" role="status">
        {announce}
      </span>
    </div>
  );
}
components/equip/secret.css171 lines
@layer equip {
  .eq-secret {
    display: grid;
    gap: var(--eq-space-1);
    min-width: 0;
    max-width: 100%;
  }

  .eq-secret-label {
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  .eq-secret-hint {
    margin: 0;
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  /* Looks like a read-only text field with two tool buttons at its end. */
  .eq-secret-control {
    box-sizing: border-box;
    display: flex;
    align-items: center;
    gap: 2px;
    min-width: 0;
    min-height: var(--eq-h-m);
    padding-inline: var(--eq-space-3) 2px;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    color: var(--eq-ink);
  }

  .eq-secret-value {
    flex: 1 1 auto;
    min-width: 0;
    margin-inline-end: var(--eq-space-2);
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight);
    line-height: 1.4;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  /* Revealing or masking swaps the value in with a short fade rather than a cut. */
  @starting-style {
    .eq-secret-value {
      opacity: 0;
    }
  }

  .eq-secret-mask {
    color: var(--eq-ink-2);
  }

  /* Revealed, the whole value is visible: it wraps at any character and selects in one go. */
  .eq-secret[data-state="revealed"] .eq-secret-value {
    padding-block: 6px;
    white-space: normal;
    overflow-wrap: anywhere;
    word-break: break-all;
    user-select: all;
    -webkit-user-select: all;
  }

  .eq-secret-timer {
    flex: none;
    margin-inline-end: var(--eq-space-1);
    color: var(--eq-ink-3);
    font: var(--eq-weight) var(--eq-text-xs) / 1 var(--eq-font);
    font-variant-numeric: tabular-nums;
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  @starting-style {
    .eq-secret-timer {
      opacity: 0;
    }
  }

  .eq-secret-button {
    display: grid;
    flex: none;
    place-items: center;
    align-self: center;
    width: 28px;
    height: 28px;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink-2);
    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-secret-button > svg {
    width: 16px;
    height: 16px;
  }

  @media (hover: hover) {
    .eq-secret-button:hover {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }

    .eq-secret-button[aria-pressed="true"]:hover {
      background: color-mix(in srgb, var(--eq-solid) 88%, var(--eq-solid-ink));
      color: var(--eq-solid-ink);
    }
  }

  .eq-secret-button:active {
    background: var(--eq-tint-strong);
    scale: var(--eq-press);
  }

  .eq-secret-button[aria-pressed="true"] {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-secret-button:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-secret-button[data-state="copied"] {
    color: var(--eq-ok);
  }

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

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

  .eq-secret-button[data-state="failed"] {
    color: var(--eq-danger);
  }

  /* Read by screen readers, never seen. */
  .eq-secret-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