spoiler-ui
v1.0.0
Published
Lightweight spoiler/content-reveal UI element — like Threads & Discord. Vanilla JS + optional React wrapper.
Maintainers
Readme
spoiler-ui
A lightweight, zero-dependency spoiler / content-reveal element — like Threads, Discord, and Telegram.
Blurs hidden content behind a pill overlay. Click (or press Enter / Space) to reveal with a smooth transition.
MIT license · zero dependencies · Vanilla JS + optional React wrapper · TypeScript types included
Install
npm install spoiler-uiThree modes
| Mode | Content in DOM before reveal? | Requires server? | |------|-------------------------------|-----------------| | UI only | Yes, blurred | No | | UI + encoding | Yes, as Base64 | No | | UI + backend | No | Yes |
Mode 1 — UI only
The simplest mode. Content is blurred in the DOM; revealing is purely client-side. A determined user can find the text in DevTools, but it stops casual glances.
Vanilla JS
import { createSpoiler, initSpoilers } from 'spoiler-ui';
import 'spoiler-ui/style';
// Programmatic
const spoiler = createSpoiler('Snape kills Dumbledore.', {
label: 'Book spoiler',
});
document.querySelector('p').appendChild(spoiler);
// Custom label, button-only reveal
const strictSpoiler = createSpoiler('He was dead the whole time.', {
label: 'Major spoiler',
revealOnClick: false, // only the pill button reveals, not the whole element
blurAmount: 10,
animationDuration: 500,
onReveal: (text) => console.log('Revealed:', text),
onError: (err) => console.error('Failed:', err),
});
document.body.appendChild(strictSpoiler);Auto-init from HTML attributes — no JS needed per element:
<script type="module">
import { initSpoilers } from 'spoiler-ui';
import 'spoiler-ui/style';
initSpoilers();
</script>
<!-- Basic -->
<span data-spoiler>Jon Snow is resurrected.</span>
<!-- Custom label, blur, and icon -->
<span data-spoiler="Huge spoiler" data-spoiler-blur="10" data-spoiler-icon="🔒">
The planet explodes at the end.
</span>
<!-- Extra CSS class for custom theming -->
<span data-spoiler="Spoiler" data-spoiler-class="my-theme">
The twist is revealed in chapter 12.
</span>React
import { Spoiler } from 'spoiler-ui/react';
import 'spoiler-ui/style';
export default function ReviewPost() {
return (
<p>
The film was stunning, but I did not expect{' '}
<Spoiler label="ending spoiler">
the hero to sacrifice herself in the final act
</Spoiler>
.
</p>
);
}With callbacks and a custom ref:
import { useRef } from 'react';
import { Spoiler } from 'spoiler-ui/react';
const ref = useRef(null);
<Spoiler
ref={ref}
label="Major spoiler"
blurAmount={10}
revealOnClick={false}
animationDuration={500}
onReveal={(text) => analytics.track('spoiler_revealed', { text })}
onError={(err) => console.error('Reveal failed:', err)}
>
The murderer was the detective all along.
</Spoiler>Mode 2 — UI + backend
The text is never sent to the browser until the user clicks reveal. On click, the component fetches the content from your server, shows a loading indicator, then reveals. A user inspecting DevTools before clicking will find nothing.
On network failure, the pill shows "Failed — retry?" and re-clicking retries the fetch automatically.
Vanilla JS
import { createSpoiler } from 'spoiler-ui';
import 'spoiler-ui/style';
const spoiler = createSpoiler(null, {
src: '/api/spoilers/42',
label: 'Spoiler',
onReveal: (text) => console.log('Fetched and revealed:', text),
onError: (err) => reportError(err),
});
document.querySelector('.post').appendChild(spoiler);With authentication headers:
const spoiler = createSpoiler(null, {
src: '/api/spoilers/42',
fetchOptions: {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
},
onReveal: (text) => markAsRead(42),
});With an optional blurred placeholder shown while loading:
// 'Loading content…' is shown blurred until the fetch completes,
// then replaced with the real server response.
const spoiler = createSpoiler('Loading content\u2026', {
src: '/api/spoilers/42',
});HTML attributes:
<span
data-spoiler="Spoiler"
data-spoiler-src="/api/spoilers/42"
>
</span>
<!-- Server returns HTML markup -->
<span
data-spoiler="Spoiler"
data-spoiler-src="/api/spoilers/43"
data-spoiler-response-type="html"
>
</span>Note on
responseType: 'html': The server response is injected viainnerHTML. You are responsible for sanitising HTML before serving it. UseresponseType: 'text'(the default) whenever the content is plain text.
React
import { Spoiler } from 'spoiler-ui/react';
import 'spoiler-ui/style';
export default function Post({ spoilerId }) {
return (
<p>
The twist is{' '}
<Spoiler
src={`/api/spoilers/${spoilerId}`}
label="Spoiler"
loadingLabel="Fetching…"
errorLabel="Load failed — retry?"
onReveal={(text) => console.log('Revealed:', text)}
onError={(err) => reportError(err)}
/>
.
</p>
);
}With auth and HTML response:
<Spoiler
src="/api/spoilers/99"
fetchOptions={{ headers: { Authorization: `Bearer ${token}` } }}
responseType="html"
onReveal={(html) => markSpoilerSeen(99)}
onError={(err) => toast.error('Could not load spoiler')}
/>State behaviour:
| State | Pill shows | Cursor | |-------|-----------|--------| | Idle | label + 👁 | pointer | | Loading | "Loading…" + spinner | wait | | Error | "Failed — retry?" + ⚠ | pointer | | Revealed | (overlay removed) | auto |
Mode 3 — UI + encoding
The content is Base64-encoded and stored in a data- attribute. The plaintext never appears in the DOM — it is decoded in memory only when the user clicks reveal, then injected and displayed. A DevTools inspection before clicking shows only the encoded payload, not readable text.
Security note: Base64 is obscurity, not encryption. Anyone who spots the encoded attribute can decode it in seconds with
atob(). This is meaningfully better than plaintext for casual spoiler protection, but not suitable for genuinely sensitive data. Use Mode 2 if the content must stay secret.
Prepare the payload
Use the bundled encodeBase64 utility anywhere that runs JS — a build script, a server, or the browser console:
import { encodeBase64 } from 'spoiler-ui';
const payload = encodeBase64('Darth Vader is Luke\'s father.');
// → 'RGFydGggVmFkZXIgaXMgTHVrZSdzIGZhdGhlci4='
// Full Unicode support — emoji and non-Latin scripts work fine
encodeBase64('王子 is the killer 🗡️');Vanilla JS
import { createSpoiler, encodeBase64 } from 'spoiler-ui';
import 'spoiler-ui/style';
const payload = encodeBase64('The real treasure was the friends we made along the way.');
const spoiler = createSpoiler(payload, {
encoded: true,
label: 'Spoiler',
onReveal: (text) => console.log('Decoded and revealed:', text),
onError: (err) => console.error('Decode failed:', err),
});
document.body.appendChild(spoiler);What DevTools shows (vanilla JS — React produces the same structure but as a React-managed DOM):
<!-- Before reveal — only the encoded payload is in the DOM -->
<span class="spoiler-ui" data-state="idle" data-revealed="false"
data-spoiler-payload="VGhlIHJlYWwgdHJlYXN1cmUgd2FzIHRoZSBmcmllbmRzIHdlIG1hZGUgYWxvbmcgdGhlIHdheS4=">
<span class="spoiler-ui__inner"></span>
<span class="spoiler-ui__overlay" role="button" tabindex="0" aria-label="Reveal Spoiler">
<span class="spoiler-ui__pill">Spoiler</span>
</span>
</span>
<!-- After reveal — payload attribute removed, plaintext injected -->
<span class="spoiler-ui" data-state="revealed" data-revealed="true">
<span class="spoiler-ui__inner">The real treasure was the friends we made along the way.</span>
</span>HTML attributes:
Encode your content and embed it in data-spoiler-encoded:
<span
data-spoiler="Spoiler"
data-spoiler-encoded="VGhlIHJlYWwgdHJlYXN1cmUgd2FzIHRoZSBmcmllbmRzIHdlIG1hZGUgYWxvbmcgdGhlIHdheS4="
>
</span>import { initSpoilers } from 'spoiler-ui';
initSpoilers();React
Pass the Base64 string as children and set encoded:
import { Spoiler } from 'spoiler-ui/react';
import 'spoiler-ui/style';
// Payload prepared at build time or server-side
const PAYLOAD = 'RGFydGggVmFkZXIgaXMgTHVrZSdzIGZhdGhlci4=';
export default function FilmReview() {
return (
<p>
The most iconic twist in cinema history:{' '}
<Spoiler encoded onReveal={(text) => console.log(text)}>
{PAYLOAD}
</Spoiler>
.
</p>
);
}Theming
Override CSS variables on .spoiler-ui or any ancestor to retheme without touching the stylesheet.
Stylesheet
.spoiler-ui {
--spoiler-pill-bg: #6366f1;
--spoiler-pill-color: #fff;
--spoiler-bg: rgba(99, 102, 241, 0.1);
}React — per-instance via style prop
<Spoiler
label="Critical"
style={{
'--spoiler-pill-bg': '#e11d48',
'--spoiler-pill-color': '#fff',
'--spoiler-bg': 'rgba(225, 29, 72, 0.08)',
}}
>
CVE-2024-1234 — remote code execution on /api/upload
</Spoiler>renderPill — full pill customisation (React)
<Spoiler
label="Reveal lineage"
renderPill={({ state, label }) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{state === 'idle' ? '🌑' : state === 'loading' ? '⏳' : '⚠️'}
<em>{label}</em>
</span>
)}
>
Luke, I am your father.
</Spoiler>API reference
createSpoiler(content, options?)
import { createSpoiler } from 'spoiler-ui';| Parameter | Type | Description |
|-----------|------|-------------|
| content | string \| Element \| null | Content to hide. Pass null when using src or encoded. |
| options.label | string | Pill text. Default: 'Spoiler' |
| options.blurAmount | number | Blur in px while hidden. Default: 6 |
| options.revealOnClick | boolean | Click anywhere on element to reveal. Default: true |
| options.animationDuration | number | Transition in ms. Default: 300 |
| options.className | string | Extra class on root element. Default: '' |
| options.src | string | URL to fetch content from on reveal. |
| options.fetchOptions | RequestInit | Passed directly to fetch(). Default: {} |
| options.responseType | 'text' \| 'html' | How to inject fetched/decoded content. Default: 'text' |
| options.encoded | boolean | Treat content as Base64; decode on reveal. Default: false |
| options.icon | string \| false \| null | Override the pill icon. String replaces the default 👁; false/null hides it entirely. Default: uses --spoiler-icon CSS variable. |
| options.showPill | boolean | Show the pill label button. Default: true |
| options.loadingLabel | string | Pill text while fetching. Default: 'Loading…' |
| options.errorLabel | string | Pill text on fetch/decode failure. Default: 'Failed — retry?' |
| options.onReveal | (content: string) => void | Called after successful reveal with the revealed text. |
| options.onError | (err: Error) => void | Called when a fetch or Base64 decode fails. |
Returns an HTMLElement ready to insert into the DOM.
initSpoilers(scope?)
import { initSpoilers } from 'spoiler-ui';Scans scope (default: document) for [data-spoiler] elements and converts them.
Supported HTML attributes:
| Attribute | Corresponds to |
|-----------|---------------|
| data-spoiler | label (required to activate) |
| data-spoiler-blur | blurAmount |
| data-spoiler-duration | animationDuration |
| data-spoiler-reveal-on-click | revealOnClick — set to 'false' to restrict reveal to the pill button only |
| data-spoiler-show-pill | showPill — set to 'false' to hide the pill label button |
| data-spoiler-loading-label | loadingLabel — pill text while fetching |
| data-spoiler-error-label | errorLabel — pill text on failure |
| data-spoiler-src | src |
| data-spoiler-response-type | responseType ('text' or 'html') |
| data-spoiler-encoded | Base64 payload; enables encoded mode |
| data-spoiler-class | className — extra CSS class on the root element |
| data-spoiler-icon | icon — 'false' hides the icon; any other string replaces the default 👁 |
onReveal,onError, andfetchOptionscannot be set via HTML attributes — usecreateSpoiler()or the React<Spoiler>component for callbacks and custom fetch configuration.
<Spoiler> (React)
import { Spoiler } from 'spoiler-ui/react';Accepts the same options as createSpoiler as props, plus children in place of the content parameter.
| Prop | Type | Default |
|------|------|---------|
| children | ReactNode | — |
| label | string | 'Spoiler' |
| blurAmount | number | 6 |
| revealOnClick | boolean | true |
| animationDuration | number | 300 |
| className | string | '' |
| src | string | — |
| fetchOptions | RequestInit | {} |
| responseType | 'text' \| 'html' | 'text' |
| encoded | boolean | false |
| icon | string \| false \| null | CSS --spoiler-icon (👁 by default) |
| showPill | boolean | true |
| loadingLabel | string | 'Loading…' |
| errorLabel | string | 'Failed — retry?' |
| renderPill | (ctx: { state, label }) => ReactNode | Render prop for full pill customisation. |
| onReveal | (content?: string) => void | Called after successful reveal. |
| onError | (err: Error) => void | Called when a fetch or decode fails. |
| style | CSSProperties & { [key: \--${string}`]: string }| Inline styles merged onto root. Use to override CSS variables per instance. |
|ref|RefObject| Forwarded to the rootviaforwardRef`. |
encodeBase64(str) / decodeBase64(b64)
import { encodeBase64, decodeBase64 } from 'spoiler-ui';Full Unicode support via TextEncoder / TextDecoder. Use encodeBase64 at build time or server-side to prepare payloads for encoded mode.
const payload = encodeBase64('Snape kills Dumbledore.');
// → 'U25hcGUga2lsbHMgRHVtYmxlZG9yZS4='
decodeBase64('U25hcGUga2lsbHMgRHVtYmxlZG9yZS4=');
// → 'Snape kills Dumbledore.'CSS customisation
Import the stylesheet and override variables on .spoiler-ui or any parent:
import 'spoiler-ui/style';.spoiler-ui {
--spoiler-blur: 8px; /* blur amount while hidden */
--spoiler-duration: 400ms; /* reveal transition speed */
--spoiler-bg: rgba(0,0,0,0.2); /* overlay background */
--spoiler-pill-bg: #1a1a2e; /* pill background (idle) */
--spoiler-pill-color: #e0e0ff; /* pill text colour */
--spoiler-pill-font: inherit; /* pill font family */
--spoiler-radius: 6px; /* corner radius on overlay */
--spoiler-error-bg: #c0392b; /* pill background on error */
--spoiler-icon: '🔒'; /* icon shown before pill text */
--spoiler-pill-padding: 1px 8px;
--spoiler-pill-font-size: 0.72em;
--spoiler-pill-font-weight: 600;
--spoiler-pill-letter-spacing: 0.02em;
--spoiler-pill-gap: 4px; /* gap between icon and label */
--spoiler-pill-radius: 100px; /* pill border radius */
--spoiler-shimmer-color: rgba(255,255,255,0.5);
--spoiler-shimmer-duration: 2.4s;
}Dark mode is not applied automatically — override the variables in your own stylesheet:
@media (prefers-color-scheme: dark) {
.spoiler-ui {
--spoiler-bg: rgba(255, 255, 255, 0.08);
--spoiler-pill-bg: rgba(255, 255, 255, 0.18);
--spoiler-pill-color: #fff;
--spoiler-error-bg: #e74c3c;
}
}Animations are disabled automatically via @media (prefers-reduced-motion: reduce).
Custom overlay animation
The overlay is .spoiler-ui__overlay. Target it directly to replace or extend the built-in shimmer:
/* Disable default shimmer */
.spoiler-ui {
--spoiler-shimmer-color: transparent;
}
/* Add your own animation */
.spoiler-ui__overlay::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background-image: url('noise.png');
animation: my-particles 3s linear infinite;
}
@keyframes my-particles {
to { background-position: 200px 200px; }
}The overlay switches to opacity: 0; pointer-events: none on reveal — your custom animation cleans up automatically.
Accessibility
- The reveal button has
role="button",tabindex="0", andaria-label="Reveal {label}". aria-expanded="false"is set while hidden; the attribute is removed after reveal.- Keyboard:
EnterorSpaceto reveal — works regardless ofrevealOnClick. - After reveal the overlay is removed from tab order and marked
aria-hidden. - Focus ring shown via
:focus-visible(no ring on mouse click).
License
MIT
