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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type RedactedTextVariant = "blur" | "block" | "mask"
export type RedactedTextRevealOn = "hover" | "click" | "hold" | "never"
/** keep the last N characters in the clear; `"email"` keeps the first letter + `@domain`. */
export type RedactedTextPartial = number | "email"

export interface RedactedTextProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
  /** the sensitive value. With `variant="mask"` and not revealed, it never reaches the DOM. */
  value: string
  /**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/redacted-text.json

Prompt

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 redactionblur and block are paint: the secret is still sitting in the DOM for anyone with a text cursor or a textContent call. mask rewrites 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 partial bigger 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 switchrevealOn="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 styleonRevealChange reports every transition for controlled page state (a screenshot-mode switch that redacts everything at once), while onReveal fires only on the redacted → revealed edge, which is the one moment an audit log actually cares about.

On This Page