PII Detector
A pre-send privacy scan — detected emails, phones, cards, IDs and names underlined by category, a hover or focus popover per entity, a mask-or-keep decision on each, and a masked preview of exactly what will be sent.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pii-detector.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PiiDetector" component (lucide-react for
icons, a shadcn Button, and any clipboard button for the copy action): a
pre-send privacy scan that renders a draft read-only, underlines every detected
entity in its category tint, and lets the reader decide entity by entity
whether it is masked or sent as typed.
Detection is INJECTED — ship a regex detector as the default, never as the
contract.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus children.
- text: string — the draft about to leave. Read-only: this component reviews
text, it never edits it (an editable overlay would have to fight a textarea
for the same pixels and would lose the per-entity hit targets).
- detect?: (text: string) => PiiMatch[]; default = the built-in detector.
PiiMatch = { start, end, category, text?, confidence?, mask? }; offsets are
UTF-16 code units, `end` exclusive, `text` is informational only.
- categories?: Record<string, Partial<PiiCategoryConfig>>, merged field by
field over the built-ins. PiiCategoryConfig =
{ label, tone, mask?(text, index), partial?(text), hint? }.
- maskStyle?: "placeholder" | "partial" (default "placeholder").
- minConfidence?: number (default 0) — matches under it never reach the reader.
- defaultDecision?: "mask" | "keep" (default "mask" — fail closed).
- defaultView?: "original" | "masked" (default "original").
- decisions?: Record<entityId, "mask" | "keep"> with onDecisionsChange for
controlled use; uncontrolled otherwise.
- onOutgoingTextChange?(result) and onSend?(result), where result is
{ text, masked, kept, entities }. Passing onSend is what renders the send
button — no callback, no button, no fake affordance.
- busy?: boolean, showCopy?: boolean (default true), label?, sendLabel?,
emptyLabel?.
- Built-in categories: email, phone, card, gov-id, name, ip, secret. Each owns
a chart token, a one-line "why this matters" hint and a partial masker.
Behavior — building the model (pure, runs in a memo)
- A match is an OFFSET RANGE over the source, never a detached string: slice
every run out of `text` itself so a detector that lies about `text`, leaves a
hole or overlaps a neighbour can still never alter what the reader sees.
- Sanitise first — the detector is third-party code. Clamp start/end into
[0, text.length], truncate to integers, drop inverted or empty ranges, treat
a missing confidence as 1, drop anything below minConfidence.
- Sort by start ascending, then longest, then most confident, THEN sweep
keeping only matches that start at or after the previous match's end. Sorting
before the sweep is what makes the result independent of detector rule order:
a phone pattern that fires inside a card number always loses to the card.
- Emit alternating segments — plain run, entity run, plain run — that
concatenate back to the exact source. The rendered text is the source.
- Number placeholders per DISTINCT value inside a category (normalise by
trim + lowercase): the same address twice is [EMAIL_1] twice, a different one
is [EMAIL_2]. A model reading "[EMAIL_1] forwarded it to [EMAIL_2]" can still
tell two people apart; a row of dots collapses them and silently changes what
the prompt says.
- Resolve the mask as: match.mask ?? (maskStyle === "partial" && category
.partial ? partial(text) : placeholder(text, index)). If the result equals
the original value, fall back to the placeholder — "masked" must never be a
no-op.
Behavior — the shipped detector (a demo, and honest about it)
- Rules: API keys/tokens (sk_live_…, ghp_…, AKIA…, xox?-…) 0.97, email 0.96,
payment card 0.99 but only after a Luhn check on 13–19 digits, government ID
(NNN-NN-NNNN) 0.9, IPv4 0.75, phone 0.7 (0.85 with a country code) after a
digit-count check, person name 0.55.
- Second-stage verification is what keeps precision usable: an order number
that happens to be 16 digits fails Luhn and is rejected, and it must not come
back through the phone rule either — a bare run of more than 11 digits is not
a phone number in any numbering plan.
- Person names are a capitalised-bigram guess filtered by a stop list of
sentence openers, months and generic nouns. Report them at 0.55 and let the
UI say so. A detector that pretends to be certain about names teaches people
to click "mask all" without reading.
- Use String.matchAll: it clones the regex, so module-level /g patterns stay
reentrant across concurrent renders.
Behavior — decisions, views, actions
- Every entity carries a decision, default "mask" (fail closed). Clicking the
entity, pressing Enter or Space on it, or pressing the button inside its
popover flips mask ⇄ keep.
- The outgoing text is derived, never stored: walk the segments and append
either the mask or the source slice; count masked and kept on the way.
- Two views. "Original" shows the source with tinted underlines; "Masked" swaps
every masked entity for its placeholder AND flags every kept entity with a
wavy destructive underline — the reader must see what is still leaving.
- "Mask all" sets every decision to mask and switches to the masked preview in
one click, because "just make this safe" wants to see the result. "Keep all"
only changes decisions.
- onSend is a ONE-SHOT LOCK: it fires once, then the toggles, the bulk buttons
and the button itself go inert and the footer states what was sent. Editing
`text` releases the lock — offsets changed, so every decision keyed by them
is stale anyway. Clear decisions, the active popover and the lock together,
during render via a prev-props comparison, so no frame shows a decision that
belongs to the previous draft.
- busy: the caller's async classifier is still out. Render the source plain
with aria-busy, highlight nothing and disable every action — half a scan
reads as "clean" and is worse than an honest "not scanned yet".
Behavior — the popover
- Opens on pointerenter AND on focus, closes on pointerleave after a ~140ms
grace period (so the pointer can travel into it), on Escape (returning focus
to its entity), and when focus leaves the whole surface.
- The close timer must not fire while focus is inside the popover or on its
own entity: a wandering mouse cannot be allowed to unmount the panel a
keyboard user is standing in. Clear the timer on unmount.
- Render it as a child of the surface immediately after its entity, absolutely
positioned against a `relative` wrapper. DOM order gives natural tab order
(entity → its button); coordinates come from offsetLeft/offsetTop/offsetHeight
and are clamped to the wrapper width so a panel anchored to the last word on a
line cannot hang off the card.
- Measure in a layout effect (never a paint effect) and keep the panel
visibility:hidden until it has coordinates. Re-measure through a
ResizeObserver on the wrapper and the panel — the panel changes height when
its button label changes — and disconnect it on unmount. Make the position
state identity-stable when nothing moved, or the observer's first callback
starts a render loop.
- Contents: category label, confidence ("low confidence" under 0.7), the found
value and its mask in mono, the category hint, and one button whose label is
the ACTION, not the state ("Mask this value" / "Send this one as typed").
Rendering & styling
- Semantic tokens only: bg-card, bg-popover, bg-muted/30, border, text-
foreground, text-muted-foreground, text-destructive, outline-ring, and
var(--chart-1..5) for the category tints. No hex, no rgb(), no oklch().
Every tint goes through color-mix(in oklab, <tone> N%, transparent) so one
set of tokens covers light and dark.
- The tint is a convenience, not the signal: the category is always also
written in the popover and the chips, and confidence is carried by the
underline STYLE (solid vs dashed), so a reader with a colour vision
deficiency loses nothing.
- Entity spans are role="button" + aria-pressed + tabIndex 0 (not <button>): an
atomic inline cannot wrap mid-value, and a 40-character key must be able to
break across lines. box-decoration-clone keeps the tint intact when it does.
preventDefault on Space so the page cannot scroll under the press.
- aria-label spells out the decision ("Email address, [email protected] — masked as
[EMAIL_1]"); an sr-only role="status" region announces the SCAN ("3 sensitive
values found: 2 email addresses, 1 payment card") and nothing else — the
per-entity state is already spoken by aria-pressed.
- The surface is whitespace-pre-wrap + wrap-anywhere: a pasted log keeps its
line breaks and an unbreakable token cannot widen the card.
- Motion is one 120ms pop on the popover, shipped as a React 19 hoisted <style>
and switched off under motion-reduce; nothing about the scan depends on it.
Customization levers
- Category registry: add your own ids (invoice numbers, patient ids, internal
codenames) with a label, a chart token, a hint and a mask builder; unknown
categories still render, with a hashed tint and a prettified id.
- Mask vocabulary: placeholders read best for LLM prompts, partial redaction
("j•••@•••.com", "•••• •••• •••• 4242") reads best for a human reviewer.
Swap per category, or per entity through match.mask from the detector.
- Detection: replace `detect` with a server round-trip, Presidio, Comprehend or
a local NER model. Debounce it upstream and pass busy while it is out; the
component only needs offsets and a confidence.
- Policy: defaultDecision="keep" turns the panel into an advisory highlighter
instead of a redactor; minConfidence trims the long tail of guesses;
maskStyle + categories decide how loud the result looks.
- Chrome: drop the copy button (showCopy), the send footer (omit onSend), the
chips row or the legend line and the card still works — they are independent
blocks under one flex column.
- Density: the card's gap-3 / p-4 and the surface's leading-7 are the spacing
knobs; tighten leading-7 to leading-6 if your drafts are long, and raise the
tint percentages (14% / 22%) if your theme is low contrast.Concepts
- Offset-owned spans — a detection is a range over the draft, so every character on screen is sliced out of the original string; a detector that reports the wrong
text, a hole or a stale offset can shift the highlight but can never rewrite what the reader is about to send. - One owner per character — matches are sorted (earliest, longest, most confident) and then swept, so a phone pattern that fires inside a card number is dropped instead of double-masking it, and the outcome does not depend on the order the detector's rules happened to run in.
- Fail closed, then argue — everything starts masked and the reader opts values back OUT one at a time; the reverse default turns the review into a checklist nobody finishes.
- Confidence as an invitation — a person-name guess arrives at 0.55, is drawn with a dashed underline and says "low confidence" in its popover, because the honest answer to "is this a name?" is often "you tell me".
- Numbered placeholders — masking to
[EMAIL_1]/[EMAIL_2]per distinct value keeps co-reference alive, so the model can still tell two participants apart; masking everything to dots quietly changes the meaning of the prompt. - One-shot send lock — a send fires exactly once and freezes the decisions that produced it; editing the draft invalidates every offset, so the lock, the decisions and the open popover are all cleared together.
Structured Output
A JSON-mode response rendered through its schema — labelled rows in schema order, kind-aware values, field-level expected-vs-got validation, pending fields while the stream runs, off-schema keys kept, and a raw JSON view.
Thinking Indicator
A transcript-line thinking status — a highlight sweeping the label, a phase walk that never lies about progress, a clock that only appears once the wait is real, and a fade-out that hands the slot to the answer.