Text
Redacted Text
Blur, black-bar or character-mask a sensitive string with hover / click / press-and-hold reveal — where mask actually removes the plaintext from the DOM.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/redacted-text.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RedactedText" component (no runtime
dependency beyond a cn() classname merger).
Contract
- Export a forwardRef component whose ref points at the root element: a real
<button> when the value is revealable, a <span> when revealOn="never".
- Props: value: string (the sensitive string, never reformatted),
variant = "blur" | "block" | "mask" (default "mask"),
revealOn = "hover" | "click" | "hold" | "never" (default "click"),
revealed?: boolean (controlled) + defaultRevealed?: boolean (default false)
+ onRevealChange?: (revealed: boolean) => void,
onReveal?: () => void (audit hook; fires only on a user-gesture reveal),
partial?: number | "email",
maskChar?: string (default "•", first code point only),
label?: string (default "text", used to build the screen-reader name).
Everything else spreads onto the root; className merges through cn().
Behavior
- The variants differ in SECURITY SEMANTICS, not just looks, and that
difference is the whole point of the component:
blur -> filter: blur(0.3em). Visual redaction ONLY: the plaintext stays in
the DOM, so it can still be selected, copied, found with Ctrl+F and
read by any script. Use it when the threat model is "somebody is
looking at my screen or my screenshot".
block -> foreground-colored background + transparent text: the classic
redaction bar. Same DOM caveat as blur.
mask -> the hidden run is REPLACED in the DOM by maskChar. Nothing to
select, nothing to copy, nothing for a script to read. This is
content redaction, and it is the default: a component whose job is
"do not leak this" should default to the option that actually does
not leak it. Choosing blur/block is an explicit decision to keep
the plaintext in the document.
Never document blur/block as hiding the value from anything but eyes.
- partial splits value into lead / hidden / trail. A number keeps the last N
characters in the clear; "email" keeps the first letter plus "@domain".
Fail closed: clamp the number to [0, value.length - 1] so a too-large partial
can never expose the whole string, and redact everything when "email" finds
no "@" or a local part shorter than two characters. A parse failure must
never open up.
- Masking replaces only non-whitespace characters, so grouping survives:
"4242 4242 4242 4242" with partial={4} renders "•••• •••• •••• 4242".
- Reveal state is one boolean. Every gesture goes through a single commit(next)
that (a) drops no-op transitions, (b) writes internal state only when
uncontrolled, (c) always calls onRevealChange, (d) calls onReveal only on the
false -> true edge. A controlled caller that ignores onRevealChange simply
never reveals; the component does not fight it.
click -> toggles.
hover -> pointerenter/pointerleave AND focus/blur, tracked as two separate
flags OR'd together. Pure hover is keyboard-unreachable, so focus
must open it too; and sharing one flag would make "mouse still
over it, but Tab moved away" re-redact while it is visibly hovered.
Touch users reach it through tap-focus.
hold -> pointerdown reveals; pointerup / pointerleave / pointercancel /
blur all re-redact. Space and Enter keydown reveal and are
preventDefault'd, otherwise the button's native activation fires a
toggle on keyup and space scrolls the page; keyup re-redacts;
keydown repeats are ignored. Nothing can leave it open.
never -> a non-interactive span with no gesture; only the controlled
revealed prop can open it.
- prefers-reduced-motion: drop the filter/color/background transition
(motion-reduce:transition-none) while the state still flips instantly —
redaction is information, the crossfade is decoration. mask has no crossfade
by construction, because swapped DOM text cannot be tweened.
- Chain consumer handlers instead of clobbering them: an incoming
onClick/onPointerUp/onKeyDown runs first, then the component's own gesture
handler.
Rendering & styling
- Semantic tokens only: bg-foreground + text-transparent for the block bar,
text-muted-foreground for the mask run, hover:bg-muted/60 as the interactive
affordance, focus-visible:ring-2 ring-ring ring-offset-background for the
focus ring. No hex / rgb() / oklch() anywhere, no hardcoded radius.
- The redacted run is its own inline-block span with max-w-full, so a long
secret wraps inside its container instead of overflowing the card.
- The blur variant writes an inline filter that toggles blur(0px) <-> blur(R)
instead of adding and removing a blur class: two interpolatable endpoints, so
the crossfade never snaps. R is a module constant in em, so the blur tracks
the surrounding font size.
- Accessibility: revealable variants are real <button type="button"> with
aria-pressed. While redacted, the entire visual run is aria-hidden and the
accessible name is "Redacted <label>, showing <clear hint>, activate to
reveal" ("press and hold to reveal" for hold) — a screen reader must never be
handed a row of bullets. Once revealed, drop the aria-label so the real value
is announced. revealOn="never" has no button to hang a name on, so it renders
an sr-only description instead.
Customization levers
- variant is a threat-model choice, not a skin: mask when the DOM must not
contain the value, blur/block when you only need to defeat eyes and cameras.
- revealOn is the friction dial: hover = glance, click = deliberate, hold =
dead-man switch that cannot be left open, never = display only.
- partial is the recognisability dial: last 4 for cards, "email" for accounts,
omit it to redact the whole string.
- BLUR_RADIUS is the only blur knob: raise it until the glyph shapes stop being
guessable at your font size — a 0.1em blur on a short number is still legible.
- maskChar swaps the dot for "*" or a block glyph. mask keeps the run's
character count so revealing does not reflow the line — which also means the
LENGTH of the secret still leaks. If length itself is sensitive, emit a fixed
number of mask characters instead (that is what a settings-page secret field
does).
- Add font-mono via className to keep redacted and revealed widths identical;
in a proportional font the line shifts slightly when it opens.
- Wire onReveal into your audit/telemetry sink and onRevealChange into page
state to get a "screenshot mode" switch that re-redacts every field at once.Concepts
- Visual redaction vs content redaction —
blurandblockare paint: the secret is still sitting in the DOM for anyone with a text cursor or atextContentcall.maskrewrites the characters, so there is nothing to lift. Picking a variant is picking a threat model, which is why the safe one is the default. - Fail closed — every parse or clamp path errs toward more redaction: a
partialbigger than the string still leaves a character hidden, and an"email"value with no@(or a one-letter local part) is redacted whole rather than half-shown. - Shape-preserving mask — only non-whitespace characters are substituted, so grouped values keep their rhythm (
•••• •••• •••• 4242) and the line width does not jump when it opens. The price is that the secret's length is still observable. - Dead-man switch —
revealOn="hold"cannot be left open: release, pointer-leave, pointer-cancel and blur all re-redact, and the keyboard path swallows the button's native activation so a press can never latch into a toggle. - Hover must also mean focus — a hover-only reveal is unreachable by keyboard, so pointer and focus are two independent flags OR'd together; that also stops "mouse still over it, Tab moved away" from hiding a value the user can plainly see.
- Reveal is an event, not just a style —
onRevealChangereports every transition for controlled page state (a screenshot-mode switch that redacts everything at once), whileonRevealfires only on the redacted → revealed edge, which is the one moment an audit log actually cares about.