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
| Prop | Type | Default | What it does |
|---|---|---|---|
value | string | none | The full secret. Copy always copies all of it. |
label | ReactNode | none | Visible label, which also names the group. |
visibleStart | number | 4 | Characters left visible at the start while masked. |
visibleEnd | number | 4 | Characters left visible at the end while masked. |
revealFor | number | 0 | Seconds before a revealed value masks itself again, with a countdown. 0 keeps it revealed until toggled. |
onCopy | () => void | none | Called after the value reaches the clipboard. |
hint | ReactNode | none | Help text under the control, linked with aria-describedby. |
...rest | ComponentProps<"div"> | none | Every native div attribute except the native onCopy, including className, style and ref. |
Anatomy
| root | .eq-secret | data-state is masked or revealed. |
|---|---|---|
| label | .eq-secret-label | |
| control | .eq-secret-control | A read-only looking box with role group, named by the label. |
| value | .eq-secret-value | Monospace; selects in one go when revealed. |
| mask | .eq-secret-mask | A fixed run of bullets, whatever the secret's length. |
| timer | .eq-secret-timer | Seconds left before re-masking, shown only with revealFor. |
| button | .eq-secret-button | data-action is reveal or copy; copy carries data-state idle, copied or failed. |
| hint | .eq-secret-hint | |
| screen reader text | .eq-secret-sr | The masked description and the live region; visually hidden. |
Keyboard
| Tab | Moves between the reveal and copy buttons. |
|---|---|
| EnterSpace | Toggles 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;
}
}