Redact
import { useState } from 'react';
const css = `
@keyframes field-redact-cover { from { background-size: 0% 100%; } }
.field-redact { width: 256px; }
.field-redact__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.field-redact__label { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-redact__toggle { padding: 3px 8px; border: 0; border-radius: 6px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-family: inherit; font-size: 11px; font-weight: 600; cursor: pointer; }
.field-redact__toggle:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.field-redact__box { position: relative; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: box-shadow 0.2s; }
.field-redact__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-redact__area, .field-redact__mirror { padding: 10px 12px; font-family: inherit; font-size: 13px; line-height: 20px; font-kerning: none; white-space: pre-wrap; overflow-wrap: break-word; }
.field-redact__area { display: block; width: 100%; height: 82px; border: 0; outline: 0; resize: none; background: none; color: light-dark(#373a40, #f1f2f3); overflow: hidden; }
.field-redact__mirror { position: absolute; inset: 0; color: transparent; pointer-events: none; }
/* The bar wipes across each match; revealing wipes it back off. */
.field-redact__bar {
border-radius: 2px;
background: linear-gradient(light-dark(#373a40, #f1f2f3), light-dark(#373a40, #f1f2f3)) no-repeat 0 0 / 100% 100%;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
animation: field-redact-cover 0.45s cubic-bezier(0.6, 0, 0.3, 1);
transition: background-size 0.4s cubic-bezier(0.6, 0, 0.3, 1);
}
.field-redact[data-reveal] .field-redact__bar { background-size: 0% 100%; box-shadow: inset 0 -2px 0 light-dark(#6d7179, #7d8087); }
@media (prefers-reduced-motion: reduce) { .field-redact__bar { animation: none; transition: none; } }
.field-redact__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-redact__top > :first-child { margin: 0; }
.field-redact__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-redact__info svg { width: 14px; height: 14px; }
.field-redact__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-redact__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-redact__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-redact__info:hover::after, .field-redact__info:focus-visible::after { opacity: 1; transform: none; }
`;
// Email addresses, then anything that looks like a phone or card number.
const SECRET = /[\w.+-]+@[\w-]+\.[\w.]+|\+?\d[\d ().-]{6,}\d/g;
export function RedactInput() {
const [value, setValue] = useState('Call me on 415 555 0199 or write to sam@studio.io before Friday.');
const [reveal, setReveal] = useState(false);
const parts: { text: string; secret: boolean; at: number }[] = [];
let last = 0;
for (const m of value.matchAll(SECRET)) {
if (m.index! > last) parts.push({ text: value.slice(last, m.index), secret: false, at: last });
parts.push({ text: m[0], secret: true, at: m.index! });
last = m.index! + m[0].length;
}
if (last < value.length) parts.push({ text: value.slice(last), secret: false, at: last });
return (
<>
<style>
{css}
</style>
<div className="field-redact" data-reveal={reveal || undefined}>
<div className="field-redact__head">
<div className="field-redact__top">
<label className="field-redact__label" htmlFor="field-redact-area">
Notes
</label>
<button type="button" className="field-redact__info" aria-label="Hides phone numbers and emails" data-tip="Hides phone numbers and emails">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 7.5v.01" />
</svg>
</button>
</div>
<button type="button" className="field-redact__toggle" aria-pressed={reveal} onClick={() => setReveal(!reveal)}>
{reveal ? 'Hide' : 'Reveal'}
</button>
</div>
<div className="field-redact__box">
<textarea id="field-redact-area" className="field-redact__area" value={value} spellCheck={false} onChange={(e) => setValue(e.target.value)} />
<div className="field-redact__mirror" aria-hidden="true">
{parts.map((p) =>
p.secret ? (
<span key={`${p.at}${p.text}`} className="field-redact__bar">
{p.text}
</span>
) : (
<span key={`${p.at}t`}>{p.text}</span>
),
)}
</div>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldRedact(el) on the mounted element instead.
<div class="field-redact">
<div class="field-redact__head">
<div class="field-redact__top">
<label class="field-redact__label" for="field-redact-area">Notes</label>
<button type="button" class="field-redact__info" aria-label="Hides phone numbers and emails" data-tip="Hides phone numbers and emails">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 11v5M12 7.5v.01"></path>
</svg>
</button>
</div>
<button type="button" class="field-redact__toggle" aria-pressed="false">Reveal</button>
</div>
<div class="field-redact__box">
<textarea id="field-redact-area" class="field-redact__area" spellCheck="false">Call me on 415 555 0199 or write to sam@studio.io before Friday.</textarea>
<div class="field-redact__mirror" aria-hidden="true"><span>Call me on </span><span class="field-redact__bar">415 555 0199</span><span> or write to </span><span class="field-redact__bar">sam@studio.io</span><span> before Friday.</span></div>
</div>
</div>@keyframes field-redact-cover { from { background-size: 0% 100%; } }
.field-redact { width: 256px; }
.field-redact__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.field-redact__label { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-redact__toggle { padding: 3px 8px; border: 0; border-radius: 6px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-family: inherit; font-size: 11px; font-weight: 600; cursor: pointer; }
.field-redact__toggle:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.field-redact__box { position: relative; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: box-shadow 0.2s; }
.field-redact__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-redact__area, .field-redact__mirror { padding: 10px 12px; font-family: inherit; font-size: 13px; line-height: 20px; font-kerning: none; white-space: pre-wrap; overflow-wrap: break-word; }
.field-redact__area { display: block; width: 100%; height: 82px; border: 0; outline: 0; resize: none; background: none; color: light-dark(#373a40, #f1f2f3); overflow: hidden; }
.field-redact__mirror { position: absolute; inset: 0; color: transparent; pointer-events: none; }
/* The bar wipes across each match; revealing wipes it back off. */
.field-redact__bar {
border-radius: 2px;
background: linear-gradient(light-dark(#373a40, #f1f2f3), light-dark(#373a40, #f1f2f3)) no-repeat 0 0 / 100% 100%;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
animation: field-redact-cover 0.45s cubic-bezier(0.6, 0, 0.3, 1);
transition: background-size 0.4s cubic-bezier(0.6, 0, 0.3, 1);
}
.field-redact[data-reveal] .field-redact__bar { background-size: 0% 100%; box-shadow: inset 0 -2px 0 light-dark(#6d7179, #7d8087); }
@media (prefers-reduced-motion: reduce) { .field-redact__bar { animation: none; transition: none; } }
.field-redact__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-redact__top > :first-child { margin: 0; }
.field-redact__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-redact__info svg { width: 14px; height: 14px; }
.field-redact__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-redact__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-redact__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-redact__info:hover::after, .field-redact__info:focus-visible::after { opacity: 1; transform: none; }function initFieldRedact(root) {
var area = root.querySelector('.field-redact__area');
var mirror = root.querySelector('.field-redact__mirror');
var toggle = root.querySelector('.field-redact__toggle');
if (!area || !mirror || !toggle) return;
// Email addresses, then anything that looks like a phone or card number.
var SECRET = /[\w.+-]+@[\w-]+\.[\w.]+|\+?\d[\d ().-]{6,}\d/g;
var reveal = false;
var seen = {};
function render() {
var value = area.value;
var now = {};
mirror.textContent = '';
var last = 0;
var m;
SECRET.lastIndex = 0;
while ((m = SECRET.exec(value))) {
if (m.index > last) mirror.appendChild(document.createTextNode(value.slice(last, m.index)));
var bar = document.createElement('span');
bar.className = 'field-redact__bar';
// A bar wipes in only when its match first appears.
var key = m.index + m[0];
if (seen[key]) bar.style.animation = 'none';
now[key] = true;
bar.textContent = m[0];
mirror.appendChild(bar);
last = m.index + m[0].length;
}
if (last < value.length) mirror.appendChild(document.createTextNode(value.slice(last)));
seen = now;
}
function flip() {
reveal = !reveal;
root.toggleAttribute('data-reveal', reveal);
toggle.setAttribute('aria-pressed', String(reveal));
toggle.textContent = reveal ? 'Hide' : 'Reveal';
}
area.addEventListener('input', render);
toggle.addEventListener('click', flip);
render();
return function () {
area.removeEventListener('input', render);
toggle.removeEventListener('click', flip);
};
}
document.querySelectorAll('.field-redact').forEach(initFieldRedact);Goal: Create a "Redact" input.
Visual behavior: A notes field that hides private details as you type: phone numbers and email addresses get a solid redaction bar wiped over them. Reveal wipes the bars off to an underline; Hide wipes them back.
Technique:
- A mirror with the same font and wrapping sits over the textarea with transparent text; matches of an email pattern and a phone-like digit pattern become spans.
- Each bar is a background gradient sized 100% 100% with box-decoration-break: clone, so it wraps across lines; it wipes in on a keyframe from 0% width when first matched.
- Reveal transitions every bar's background-size to 0%, leaving an underline. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <textarea> with a <label>; the bars are aria-hidden and only visual (the text is still in the field). Reveal/Hide is a button with aria-pressed. Under prefers-reduced-motion the bars switch without wiping.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Redact" input.
Visual behavior: A notes field that hides private details as you type: phone numbers and email addresses get a solid redaction bar wiped over them. Reveal wipes the bars off to an underline; Hide wipes them back.
Technique:
- A mirror with the same font and wrapping sits over the textarea with transparent text; matches of an email pattern and a phone-like digit pattern become spans.
- Each bar is a background gradient sized 100% 100% with box-decoration-break: clone, so it wraps across lines; it wipes in on a keyframe from 0% width when first matched.
- Reveal transitions every bar's background-size to 0%, leaving an underline. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <textarea> with a <label>; the bars are aria-hidden and only visual (the text is still in the field). Reveal/Hide is a button with aria-pressed. Under prefers-reduced-motion the bars switch without wiping.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A notes field that hides private details as you type: phone numbers and email addresses get a solid redaction bar wiped over them. Reveal wipes the bars off to an underline; Hide wipes them back.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change, fill
- Techniques
- keyframes, css transition, gradient
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <textarea> with a <label>; the bars are aria-hidden and only visual (the text is still in the field). Reveal/Hide is a button with aria-pressed. Under prefers-reduced-motion the bars switch without wiping.