npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

spoiler-ui

v1.0.0

Published

Lightweight spoiler/content-reveal UI element — like Threads & Discord. Vanilla JS + optional React wrapper.

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-ui

Three 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 via innerHTML. You are responsible for sanitising HTML before serving it. Use responseType: '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, and fetchOptions cannot be set via HTML attributes — use createSpoiler() 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", and aria-label="Reveal {label}".
  • aria-expanded="false" is set while hidden; the attribute is removed after reveal.
  • Keyboard: Enter or Space to reveal — works regardless of revealOnClick.
  • 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