Inline redaction bar sized by the real text beneath it — hold to peek and the ink flows back after release, click to latch it open; the lifted bar stays hovering as a thin overline so the redaction never disappears.
npx shadcn add https://design.helpmarq.com /r/redaction-hold-reveal.jsonregistry/core/redaction-hold-reveal/component.tsx"use client";
import { useEffect, useRef, useState } from "react";
export interface UnderInkProps {
/** The sensitive text under the bar. Sized by the real text, so the bar never lies about length. */
children: string;
/** Tiny mono tag on the bar naming WHAT is hidden without revealing it — "email", "amount". */
label?: string;
/** ms after a hold-peek release before the ink flows back. */
resealMs?: number;
className?: string;
}
const HOLD_THRESHOLD_MS = 280; // press shorter than this is a click-toggle, longer is a peek
/**
* Inline redaction. The bar is drawn over the real text (which sets the
* width — a redaction that hides length hides less than people think, and a
* fixed-width bar that lies about it reads as fake). Two ways in:
* press-and-hold lifts the ink for a peek and it flows back shortly after
* release; a plain click (or Enter/Space) latches it open until clicked
* again. aria-pressed tracks whether the text is currently exposed.
*/
export function UnderInk({ children, label, resealMs = 900, className = "" }: UnderInkProps) {
const [latched, setLatched] = useState(false);
const [peeking, setPeeking] = useState(false);
const pressStartRef = useRef(0);
const resealRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const suppressClickRef = useRef(false);
const revealed = latched || peeking;
useEffect(() => () => window.clearTimeout(resealRef.current), []);
// Escape is the panic key: seal immediately, latched or mid-peek.
useEffect(() => {
if (!revealed) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
window.clearTimeout(resealRef.current);
setLatched(false);
setPeeking(false);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [revealed]);
const onPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0 || latched) return;
window.clearTimeout(resealRef.current);
suppressClickRef.current = false; // a drag-out could leave a stale suppress behind
pressStartRef.current = performance.now();
setPeeking(true);
};
const endPress = () => {
if (!peeking) return;
const held = performance.now() - pressStartRef.current;
if (held >= HOLD_THRESHOLD_MS) {
// it was a peek: the ink takes a moment to flow back
suppressClickRef.current = true; // the click that follows pointerup must not latch
resealRef.current = setTimeout(() => setPeeking(false), resealMs);
} else {
// short press: let the click handler latch it
setPeeking(false);
}
};
const onClick = (e: React.MouseEvent) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
// e.detail === 0 → keyboard activation (Enter/Space): always a toggle
window.clearTimeout(resealRef.current);
setPeeking(false);
setLatched((l) => !l);
};
return (
<button
type="button"
aria-pressed={revealed}
aria-label={
revealed
? `Hide redacted ${label ?? "content"}`
: `Reveal redacted ${label ?? "content"} — hold to peek, click to keep open`
}
onPointerDown={onPointerDown}
onPointerUp={endPress}
onPointerCancel={endPress}
onPointerLeave={endPress}
onClick={onClick}
className={`ns-underink group relative inline-block cursor-pointer whitespace-nowrap rounded-[3px] px-[3px] align-baseline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${className}`}
>
<style>{`
.ns-underink .ns-underink-bar {
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1), opacity 200ms ease-out;
transform-origin: top;
}
.ns-underink[aria-pressed="true"] .ns-underink-bar {
transform: scaleY(0.14) translateY(-2px);
opacity: 0.85;
}
.ns-underink .ns-underink-text {
transition: opacity 180ms ease-out 60ms;
}
.ns-underink:hover .ns-underink-bar { opacity: 0.82; }
@media (prefers-reduced-motion: reduce) {
.ns-underink .ns-underink-bar, .ns-underink .ns-underink-text { transition: none; }
}
`}</style>
{/* the real text sets the width; hidden from everyone (visually + AT) while sealed */}
<span
data-underink-text
aria-hidden={!revealed}
className={`ns-underink-text relative z-0 ${revealed ? "opacity-100" : "opacity-0"}`}
>
{children}
</span>
{/* the ink: lifts to a thin overline on reveal, so the redaction never disappears — it hovers over what it was hiding */}
<span
aria-hidden
className="ns-underink-bar absolute inset-0 z-10 rounded-[3px] bg-foreground"
>
{label && (
// stays mounted; hidden by opacity when revealed. Unmounting it on
// reveal removed the exact node under a centred click between
// pointerdown and pointerup, so the browser fired no `click` and the
// latch never happened.
<span
className={`flex h-full items-center justify-center overflow-hidden px-1 font-mono text-[9px] uppercase tracking-[0.18em] text-background/80 ${revealed ? "opacity-0" : "opacity-100"}`}
>
{label}
</span>
)}
</span>
</button>
);
}
An inline redaction primitive: `<UnderInk label="email">p.raghavan@…</UnderInk>` drops into running prose. The bar is drawn OVER the real text, which stays in flow and sets the width — a fixed-width bar lies about the hidden content's length and reads as fake; this one cannot. An optional `label` renders as a tiny uppercase mono tag centered on the ink ('NAME', 'AMOUNT'), naming what is hidden without revealing it, so a redacted document self-explains at rest. TWO WAYS IN, one control: press-and-hold (≥280ms) lifts the ink for a peek — on release the text stays exposed for `resealMs` (default 900) and then the ink flows back, because real ink takes a moment to pool; a short press, plain click, or keyboard Enter/Space latches the reveal open until activated again (e.detail===0 keyboard clicks always toggle). The pointer paths are disambiguated by press duration with a suppressed synthetic click after a genuine hold, and pointercancel/pointerleave end a peek safely, so a drag off the bar never wedges it open. THE LIFT: revealing doesn't delete the bar — it scales to a 14%-height overline hovering above the text (scaleY transform, transform-origin top, 240ms cubic-bezier(0.22,1,0.36,1)), so what was redacted remains visibly marked as redacted even while exposed, and re-sealing is the same motion reversed. The text beneath fades in 60ms behind the lift so ink and ink-shadow never show doubled. ACCESSIBILITY: the whole thing is one real <button> with aria-pressed tracking exposure and an accessible name that states both the label and the interaction contract ('Reveal redacted email — hold to peek, click to keep open'); the hidden text is aria-hidden while sealed so assistive tech cannot read through the ink, and un-hidden the moment it is exposed. Focus shows a visible accent outline ring; --accent appears nowhere else. Escape is the panic key: while anything is exposed a document-level listener re-seals it instantly, latched or mid-peek. REDUCED MOTION: lift and fade transitions drop to instant toggles — every state still reachable, nothing hidden. Colors are tokens only: the ink is --foreground, the tag text is --background over it, so the bar is true black-on-white and white-on-black in the two themes with zero hue. Pure DOM/CSS, zero dependencies, no canvas.