Text
Redact
Blurs inline or block content until the reader deliberately reveals it, for screen sharing and demos.
- Built on
<button>inertCSS filterReact context- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.3 kB
- Dependencies
- React
Mira HoltVisa
Code
import { Redact } from "@/components/equip/redact";
export default function Demo() {
return (
<div style={{ display: "grid", gap: 6, fontSize: 14, lineHeight: 1.4 }}>
<strong>Mira Holt</strong>
<span>
<Redact label="Customer email">mira.holt@fieldnote.io</Redact>
</span>
<span>
Visa <Redact label="Card number">4929 1830 0042 7714</Redact>
</span>
</div>
);
}Examples
Toggle
Toggle mode stays revealed until pressed again, which suits keyboard and screen reader users better than holding a key.
Database password for eu-west-1:
Code
import { Redact } from "@/components/equip/redact";
export default function Example() {
return (
<p style={{ margin: 0, fontSize: 14, lineHeight: 1.6 }}>
Database password for eu-west-1:{" "}
<Redact label="Database password" reveal="toggle">
h7Qe2vLm9pRz4xT
</Redact>
</p>
);
}Scope for a whole table
With the scope off, every Redact inside renders plain text and no button, so nothing extra is in the tab order.
| Customer | Plan | |
|---|---|---|
| Mira Holt | Team | |
| Jonas Eke | Pro | |
| Priya Nair | Team |
Code
"use client";
import { useState } from "react";
import { RedactScope, Redact } from "@/components/equip/redact";
const customers = [
{ name: "Mira Holt", email: "mira.holt@fieldnote.io", plan: "Team" },
{ name: "Jonas Eke", email: "jonas@eke-studio.de", plan: "Pro" },
{ name: "Priya Nair", email: "priya.nair@kestrel.app", plan: "Team" },
];
export default function Example() {
const [on, setOn] = useState(true);
const cell = { padding: "6px 12px 6px 0", textAlign: "left" as const };
return (
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 12, fontSize: 14 }}>
<label style={{ display: "inline-flex", alignItems: "center", gap: 8, minHeight: 24 }}>
<input type="checkbox" checked={on} onChange={(event) => setOn(event.currentTarget.checked)} />
Redact for screen sharing
</label>
<RedactScope active={on}>
{/* A narrow screen scrolls the table sideways rather than squeezing the addresses. */}
<div style={{ maxWidth: "100%", overflowX: "auto" }}>
<table style={{ borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={cell}>Customer</th>
<th style={cell}>Email</th>
<th style={cell}>Plan</th>
</tr>
</thead>
<tbody>
{customers.map((c) => (
<tr key={c.email}>
<td style={cell}>{c.name}</td>
<td style={cell}>
<Redact label={`Email for ${c.name}`}>{c.email}</Redact>
</td>
<td style={cell}>{c.plan}</td>
</tr>
))}
</tbody>
</table>
</div>
</RedactScope>
</div>
);
}Block
As a div it wraps whole paragraphs, and the blur scales with the text inside.
Support note
Code
import { Redact } from "@/components/equip/redact";
export default function Example() {
return (
<div style={{ display: "grid", gap: 8, maxWidth: 360, fontSize: 14, lineHeight: 1.5 }}>
<strong>Support note</strong>
<Redact as="div" label="Support note" reveal="toggle">
<p style={{ margin: 0 }}>
Customer asked to move billing to the new entity, Holt Fieldnote GmbH, VAT DE 318 442 907, from the April invoice on.
</p>
</Redact>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
children | ReactNode | none | The content to hide. |
label | string | none | What is hidden, such as Customer email. Names the reveal button. |
reveal | "hold" | "toggle" | "hold" | hold shows the content only while pressed; toggle flips it on each press. |
as | "span" | "div" | "span" | span for inline text, div for blocks such as paragraphs. |
...rest | ComponentProps<"span"> | none | Every native attribute for the root element, including className and style. |
RedactScope active | boolean | true | On RedactScope: turns redaction on or off for every Redact inside it. |
RedactScope ...rest | ComponentProps<"div"> | none | On RedactScope: every native div attribute. |
Anatomy
| scope | .eq-redact-scope | RedactScope's div, carrying data-redact-scope on or off. |
|---|---|---|
| root | .eq-redact | data-state is hidden, shown or off. |
| button | .eq-redact-button | Transparent button laid over the content; absent when off. |
| content | .eq-redact-content | Blurred, aria-hidden and inert until revealed. |
Keyboard
| SpaceEnter | Hold mode: shows the content while the key is held. |
|---|---|
| SpaceEnter | Toggle mode: shows or hides the content. |
Notes
- This is not a security boundary: the text is still in the DOM, so a page inspector or view source shows it.
- Hidden content is aria-hidden and inert, so a screen reader on a shared call does not read it out; revealed, it is exposed.
- The button is a sibling laid over the content rather than its parent, so revealed text is read as text and block content stays valid HTML.
- The button keeps the name Reveal and the label, and toggle mode reports its state with aria-pressed.
- Hold mode hides again on release, pointer leave, blur or a cancelled touch; toggle mode suits keyboard and screen reader users better.
- While revealed, the content stays under the button, so a click hides it rather than selecting text.
- Inside a RedactScope that is off, Redact renders plain content and no button, so nothing extra is in the tab order.
- The blur is sized in em, so it hides small and large text alike, and the reveal fades over --eq-dur.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/redact.tsx105 lines
"use client";
import { createContext, useContext, useState } from "react";
import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
import "./redact.css";
/** True while redaction is on for this subtree. Outside any scope it is on. */
const RedactContext = createContext(true);
export type RedactScopeProps = ComponentProps<"div"> & {
/** Turns redaction on or off for every Redact inside. */
active?: boolean;
};
export function RedactScope({ active = true, className, children, ...rest }: RedactScopeProps) {
return (
<div
{...rest}
className={["eq-redact-scope", className].filter(Boolean).join(" ")}
data-redact-scope={active ? "on" : "off"}
>
<RedactContext.Provider value={active}>{children}</RedactContext.Provider>
</div>
);
}
export type RedactProps = Omit<ComponentProps<"span">, "children" | "ref"> & {
/** The content to hide. */
children: ReactNode;
/** What is hidden, such as "Customer email". Names the reveal button. */
label: string;
/** hold reveals only while pressed; toggle flips on each press. */
reveal?: "hold" | "toggle";
/** span for inline text, div for a block such as a paragraph. */
as?: "span" | "div";
};
/** "Customer email" reads as "customer email" mid-sentence; "API key" keeps its capitals. */
function midSentence(label: string) {
const second = label.charAt(1);
return second && second === second.toLowerCase() ? label.charAt(0).toLowerCase() + label.slice(1) : label;
}
export function Redact({ children, label, reveal = "hold", as = "span", className, ...rest }: RedactProps) {
const active = useContext(RedactContext);
const [revealed, setRevealed] = useState(false);
// Turning a scope off and on again starts every item hidden, never left open from before.
const [wasActive, setWasActive] = useState(active);
if (wasActive !== active) {
setWasActive(active);
if (!active) setRevealed(false);
}
const Tag = as;
const classes = ["eq-redact", className].filter(Boolean).join(" ");
if (!active) {
return (
<Tag {...rest} className={classes} data-state="off" data-as={as}>
<Tag className="eq-redact-content">{children}</Tag>
</Tag>
);
}
const hold = reveal === "hold";
const show = () => setRevealed(true);
const hide = () => setRevealed(false);
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (!hold || (event.key !== " " && event.key !== "Enter")) return;
// Held keys reveal; nothing else on the page should hear them meanwhile.
event.preventDefault();
event.stopPropagation();
show();
}
function handleKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
if (!hold || (event.key !== " " && event.key !== "Enter")) return;
event.preventDefault();
event.stopPropagation();
hide();
}
return (
<Tag {...rest} className={classes} data-state={revealed ? "shown" : "hidden"} data-reveal={reveal} data-as={as}>
<button
type="button"
className="eq-redact-button"
aria-label={`Reveal ${midSentence(label)}`}
aria-pressed={hold ? undefined : revealed}
onClick={hold ? undefined : () => setRevealed((r) => !r)}
onPointerDown={hold ? (event) => event.button === 0 && show() : undefined}
onPointerUp={hold ? hide : undefined}
onPointerLeave={hold ? hide : undefined}
onPointerCancel={hold ? hide : undefined}
onContextMenu={hold ? (event) => event.preventDefault() : undefined}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={hold ? hide : undefined}
/>
<Tag className="eq-redact-content" aria-hidden={!revealed || undefined} inert={!revealed || undefined}>
{children}
</Tag>
</Tag>
);
}components/equip/redact.css115 lines
@layer equip {
/* A wrapper only: it carries the data-redact-scope attribute and adds no box styles. */
.eq-redact-scope {
min-width: 0;
}
.eq-redact {
position: relative;
display: inline-flex;
align-items: center;
min-width: 24px;
min-height: 24px;
max-width: 100%;
vertical-align: baseline;
border-radius: var(--eq-radius-s);
background: var(--eq-tint);
transition: background-color var(--eq-dur) var(--eq-ease);
}
.eq-redact[data-as="div"] {
display: flex;
padding: var(--eq-space-2) var(--eq-space-3);
border-radius: var(--eq-radius-m);
}
.eq-redact-content {
display: block;
min-width: 0;
padding-inline: 2px;
/* Blur in em, so it hides large and small text alike. Clipped so it does not bleed. */
filter: blur(0.42em);
clip-path: inset(0);
user-select: none;
-webkit-user-select: none;
/* The blur lifts like a lens coming into focus, slower than a colour change. */
transition: filter var(--eq-dur-move) var(--eq-ease);
}
.eq-redact[data-state="shown"] .eq-redact-content {
filter: none;
user-select: text;
-webkit-user-select: text;
}
/* Off: in a scope that is off, content reads as plain text in its place. */
.eq-redact[data-state="off"],
[data-redact-scope="off"] .eq-redact[data-state="off"] {
display: inline;
min-width: 0;
min-height: 0;
background: none;
}
[data-redact-scope="off"] .eq-redact[data-state="off"][data-as="div"] {
display: block;
padding: 0;
}
[data-redact-scope="off"] .eq-redact[data-state="off"] .eq-redact-content,
.eq-redact[data-state="off"] .eq-redact-content {
display: inline;
padding: 0;
filter: none;
clip-path: none;
user-select: text;
-webkit-user-select: text;
}
.eq-redact[data-state="off"][data-as="div"] .eq-redact-content {
display: block;
}
/* The button lies over the content, so the whole blurred area is the target. */
.eq-redact-button {
position: absolute;
inset: 0;
z-index: 1;
margin: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
touch-action: manipulation;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
transition: background-color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-redact[data-state="hidden"] .eq-redact-button:hover {
background: var(--eq-tint);
}
}
.eq-redact-button:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* Revealed in toggle mode, a solid edge says the content is showing on purpose. */
.eq-redact[data-reveal="toggle"][data-state="shown"] .eq-redact-button {
box-shadow: inset 0 0 0 1px var(--eq-line-strong);
}
.eq-redact[data-reveal="hold"] .eq-redact-button {
cursor: zoom-in;
}
.eq-redact[data-reveal="hold"][data-state="shown"] .eq-redact-button {
cursor: zoom-out;
}
}