AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ScanLine, ShieldAlert, ShieldCheck, WandSparkles } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { CopyButton } from "@/registry/ui/copy-button"

/* -------------------------------------------------------------------------- *
 * Detection contract
 *
 * A match is an OFFSET RANGE over the source, never a detached string. Offsets
 * are what makes the review lossless: the panel slices every run out of `text`
 * itself, so a detector that lies about `text`, leaves a hole or overlaps its

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pii-detector.json

Prompt

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.

On This Page