# Spoiler hidden under dust

> Part of the message is covered with moving dust; tap it and the dust dissolves in a circle from the point you pressed.

- Category: [Microinteractions](https://library.aimran.es/components/microinteractions)
- Built with: HTML + CSS
- Live editor: https://library.aimran.es/blocks/uv-mi27-spoiler-dust
- Library: AIMRAN Library (https://library.aimran.es), free, no sign-up, by Aimran Oukham

## Where to use it

Book clubs, series forums and group chats need a way to hide plot twists without hiding the whole message. Here the spoiler sits under a layer of moving dust made with stacked CSS gradients; tapping it grows a circular mask from the exact tap point, so the particles clear away and the words appear. Tapping again covers them back.

The main color is the sender name and focus ring, the secondary one adds a few colored specks to the dust. The animated radius is a registered CSS property, so it transitions smoothly without JavaScript timers. With reduced motion the dust stays still.

## Code

### HTML

```html
<div class="uvq15-spl"><div class="msg"><b class="nm">Book club</b><p><span>I didn’t expect the killer to be</span> <button type="button" class="sp" aria-expanded="false" aria-label="Show spoiler"><span class="tx">the narrator himself</span><span class="ns" aria-hidden="true"></span></button><span>.</span></p><small class="tm">21:47</small></div><p class="ht">Tap the hidden text to see it</p></div>
```

### CSS

```css
.uvq15-spl {
  --c1: #0891b2;
  --c2: #f43f5e;
  --s: 16px;
  /* tamaño */
  --k: 1;
  /* velocidad */
  --fg: #1d1d1f;
  /* texto */
  --bg: #f5f5f7;
  /* fondo */
}

@property --uvq15-r {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

.uvq15-spl {
  font-family: -apple-system,BlinkMacSystemFont,"SF Pro Text","Inter",system-ui,sans-serif;
  font-size: var(--s);
  color: var(--fg);
  -webkit-tap-highlight-color: transparent;
  display: grid;
  gap: .7em;
  width: 18em;
}

.uvq15-spl .msg {
  position: relative;
  padding: .7em .9em .5em;
  border-radius: 1.2em 1.2em 1.2em .3em;
  background: color-mix(in srgb,var(--fg) 7%,var(--bg));
  box-shadow: 0 0 0 1px color-mix(in srgb,var(--fg) 10%,transparent);
}

.uvq15-spl .nm {
  display: block;
  margin-bottom: .2em;
  font-size: .75em;
  color: var(--c1);
}

.uvq15-spl .msg p {
  margin: 0;
  font-size: .95em;
  line-height: 1.5;
}

.uvq15-spl .tm {
  display: block;
  text-align: right;
  font-size: .65em;
  color: color-mix(in srgb,var(--fg) 55%,transparent);
}

.uvq15-spl .sp {
  font: inherit;
  color: inherit;
  border: 0;
  margin: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  position: relative;
  display: inline;
  padding: 0 .1em;
  border-radius: .3em;
  font-weight: 600;
  --x: 50%;
  --y: 50%;
  --uvq15-r: 0px;
  transition: --uvq15-r calc(0.5s / var(--k)) ease-in;
}

.uvq15-spl .sp:focus-visible {
  outline: 2px solid var(--c1);
  outline-offset: 2px;
}

.uvq15-spl .tx {
  color: transparent;
  transition: color calc(0.5s / var(--k)) cubic-bezier(.25,.1,.25,1);
}

.uvq15-spl .ns {
  position: absolute;
  inset: -.05em -.1em;
  border-radius: .3em;
  background: radial-gradient(circle,currentColor .6px,transparent 1px) 0 0/4px 5px,radial-gradient(circle,currentColor .8px,transparent 1.2px) 2px 3px/7px 6px,radial-gradient(circle,currentColor .5px,transparent .9px) 1px 2px/3px 7px,radial-gradient(circle,var(--c2) .8px,transparent 1.2px) 1px 1px/11px 9px,color-mix(in srgb,var(--fg) 10%,transparent);
  opacity: .75;
  animation: uvq15-spl-n calc(1.6s / var(--k)) linear infinite;
  -webkit-mask-image: radial-gradient(circle at var(--x) var(--y),transparent var(--uvq15-r),#000 calc(var(--uvq15-r) + 1.6em));
  mask-image: radial-gradient(circle at var(--x) var(--y),transparent var(--uvq15-r),#000 calc(var(--uvq15-r) + 1.6em));
}

@keyframes uvq15-spl-n {
  25% {
    opacity: .6;
  }
  50% {
    opacity: .85;
  }
  to {
    background-position: 4px -5px,-7px 9px,3px 14px,11px -9px,0 0;
  }
}

.uvq15-spl .sp:hover .ns {
  opacity: .95;
}

.uvq15-spl .sp.on {
  --uvq15-r: 14em;
  transition: --uvq15-r calc(1.1s / var(--k)) cubic-bezier(.3,.6,.4,1);
}

.uvq15-spl .sp.on .tx {
  color: inherit;
}

.uvq15-spl .sp.on .ns {
  animation-play-state: paused;
}

.uvq15-spl .ht {
  margin: 0;
  font-size: .72em;
  color: color-mix(in srgb,var(--fg) 55%,transparent);
}

@media (prefers-reduced-motion:reduce) {
  .uvq15-spl .ns {
    animation: none;
  }
  .uvq15-spl .sp.on .ns {
    opacity: 0;
  }
}
```

### JavaScript

```js
document.querySelectorAll(".uvq15-spl").forEach(el => {
  const sp=el.querySelector(".sp");let on=false;
sp.addEventListener("click",e=>{const r=sp.getBoundingClientRect(),sc=r.width/sp.offsetWidth||1;sp.style.setProperty("--x",e.detail?((e.clientX-r.left)/sc)+"px":"50%");sp.style.setProperty("--y",e.detail?((e.clientY-r.top)/sc)+"px":"50%");
on=!on;sp.classList.toggle("on",on);sp.setAttribute("aria-expanded",String(on));if(on)sp.removeAttribute("aria-label");else sp.setAttribute("aria-label","Show spoiler")})
});
```

### React

```tsx
// @ts-nocheck: setup() es JavaScript del DOM sin tipos, el mismo de la pestaña JS.
import { useEffect, useRef, type CSSProperties } from "react";

const css = `
@property --uvq15-r {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

.uvq15-spl {
  font-family: -apple-system,BlinkMacSystemFont,"SF Pro Text","Inter",system-ui,sans-serif;
  font-size: var(--s);
  color: var(--fg);
  -webkit-tap-highlight-color: transparent;
  display: grid;
  gap: .7em;
  width: 18em;
}

.uvq15-spl .msg {
  position: relative;
  padding: .7em .9em .5em;
  border-radius: 1.2em 1.2em 1.2em .3em;
  background: color-mix(in srgb,var(--fg) 7%,var(--bg));
  box-shadow: 0 0 0 1px color-mix(in srgb,var(--fg) 10%,transparent);
}

.uvq15-spl .nm {
  display: block;
  margin-bottom: .2em;
  font-size: .75em;
  color: var(--c1);
}

.uvq15-spl .msg p {
  margin: 0;
  font-size: .95em;
  line-height: 1.5;
}

.uvq15-spl .tm {
  display: block;
  text-align: right;
  font-size: .65em;
  color: color-mix(in srgb,var(--fg) 55%,transparent);
}

.uvq15-spl .sp {
  font: inherit;
  color: inherit;
  border: 0;
  margin: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  position: relative;
  display: inline;
  padding: 0 .1em;
  border-radius: .3em;
  font-weight: 600;
  --x: 50%;
  --y: 50%;
  --uvq15-r: 0px;
  transition: --uvq15-r calc(0.5s / var(--k)) ease-in;
}

.uvq15-spl .sp:focus-visible {
  outline: 2px solid var(--c1);
  outline-offset: 2px;
}

.uvq15-spl .tx {
  color: transparent;
  transition: color calc(0.5s / var(--k)) cubic-bezier(.25,.1,.25,1);
}

.uvq15-spl .ns {
  position: absolute;
  inset: -.05em -.1em;
  border-radius: .3em;
  background: radial-gradient(circle,currentColor .6px,transparent 1px) 0 0/4px 5px,radial-gradient(circle,currentColor .8px,transparent 1.2px) 2px 3px/7px 6px,radial-gradient(circle,currentColor .5px,transparent .9px) 1px 2px/3px 7px,radial-gradient(circle,var(--c2) .8px,transparent 1.2px) 1px 1px/11px 9px,color-mix(in srgb,var(--fg) 10%,transparent);
  opacity: .75;
  animation: uvq15-spl-n calc(1.6s / var(--k)) linear infinite;
  -webkit-mask-image: radial-gradient(circle at var(--x) var(--y),transparent var(--uvq15-r),#000 calc(var(--uvq15-r) + 1.6em));
  mask-image: radial-gradient(circle at var(--x) var(--y),transparent var(--uvq15-r),#000 calc(var(--uvq15-r) + 1.6em));
}

@keyframes uvq15-spl-n {
  25% {
    opacity: .6;
  }
  50% {
    opacity: .85;
  }
  to {
    background-position: 4px -5px,-7px 9px,3px 14px,11px -9px,0 0;
  }
}

.uvq15-spl .sp:hover .ns {
  opacity: .95;
}

.uvq15-spl .sp.on {
  --uvq15-r: 14em;
  transition: --uvq15-r calc(1.1s / var(--k)) cubic-bezier(.3,.6,.4,1);
}

.uvq15-spl .sp.on .tx {
  color: inherit;
}

.uvq15-spl .sp.on .ns {
  animation-play-state: paused;
}

.uvq15-spl .ht {
  margin: 0;
  font-size: .72em;
  color: color-mix(in srgb,var(--fg) 55%,transparent);
}

@media (prefers-reduced-motion:reduce) {
  .uvq15-spl .ns {
    animation: none;
  }
  .uvq15-spl .sp.on .ns {
    opacity: 0;
  }
}
`;

// Interacción en JavaScript del DOM: la misma que en la pestaña JS.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setup(el: any) {
  const sp=el.querySelector(".sp");let on=false;
sp.addEventListener("click",e=>{const r=sp.getBoundingClientRect(),sc=r.width/sp.offsetWidth||1;sp.style.setProperty("--x",e.detail?((e.clientX-r.left)/sc)+"px":"50%");sp.style.setProperty("--y",e.detail?((e.clientY-r.top)/sc)+"px":"50%");
on=!on;sp.classList.toggle("on",on);sp.setAttribute("aria-expanded",String(on));if(on)sp.removeAttribute("aria-label");else sp.setAttribute("aria-label","Show spoiler")})
}

export default function Mi27SpoilerDust() {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current?.firstElementChild;
    return el ? setup(el) : undefined;
  }, []);

  return (
    <>
      <style>{css}</style>
      <div
        ref={ref} style={{
          "--c1": "#0891b2",
          "--c2": "#f43f5e",
          "--s": "16px",
          "--k": "1",
          "--fg": "#1d1d1f",
          "--bg": "#f5f5f7",
        } as CSSProperties}
      >
        <div className="uvq15-spl"><div className="msg"><b className="nm">Book club</b><p><span>I didn’t expect the killer to be</span> <button type="button" className="sp" aria-expanded="false" aria-label="Show spoiler"><span className="tx">the narrator himself</span><span className="ns" aria-hidden="true"></span></button><span>.</span></p><small className="tm">21:47</small></div><p className="ht">Tap the hidden text to see it</p></div>
      </div>
    </>
  );
}
```

## Prompt for AI assistants

Paste this into ChatGPT, Claude, Cursor, v0 or Lovable to adapt the block to your stack (Tailwind, Vue, Svelte…):

````text
I want to add the “Spoiler hidden under dust” block (Microinteractions) from AIMRAN Library to my project.

## What it is
Part of the message is covered with moving dust; tap it and the dust dissolves in a circle from the point you pressed.

## Chosen settings
- Main color: #0891b2
- Secondary color: #f43f5e
- Size: 16px (from 12 to 24px)
- Speed: 1x (from 0.5 to 2x)
- Text: "el propio narrador"

## What I need
1. Add it to my project with the same look and animation as the reference code: same sizes, colours, radii, shadows, timings and easing.
2. It is built with plain HTML and CSS (the reference wraps it in a React component with its CSS). If my project uses something else (Vue, Svelte, Astro, plain HTML…), translate it without losing any behaviour. If I use Tailwind, turn the styles into classes; if not, keep the CSS in its own file.
3. Expose the settings above as props (or CSS variables) with those values as defaults.
4. Make it work well on phones: nothing that depends only on hover, touch targets of at least 44 px and no overflow on 320 px screens.
5. Make it look right in light and dark mode.
6. Keep it accessible: semantic HTML, roles and aria-* where needed, visible focus and keyboard support.
7. Respect prefers-reduced-motion: with reduced motion, no big animations but the same end result.

## Reference code
```tsx
// @ts-nocheck: setup() es JavaScript del DOM sin tipos, el mismo de la pestaña JS.
import { useEffect, useRef, type CSSProperties } from "react";

const css = `
@property --uvq15-r {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

.uvq15-spl {
  font-family: -apple-system,BlinkMacSystemFont,"SF Pro Text","Inter",system-ui,sans-serif;
  font-size: var(--s);
  color: var(--fg);
  -webkit-tap-highlight-color: transparent;
  display: grid;
  gap: .7em;
  width: 18em;
}

.uvq15-spl .msg {
  position: relative;
  padding: .7em .9em .5em;
  border-radius: 1.2em 1.2em 1.2em .3em;
  background: color-mix(in srgb,var(--fg) 7%,var(--bg));
  box-shadow: 0 0 0 1px color-mix(in srgb,var(--fg) 10%,transparent);
}

.uvq15-spl .nm {
  display: block;
  margin-bottom: .2em;
  font-size: .75em;
  color: var(--c1);
}

.uvq15-spl .msg p {
  margin: 0;
  font-size: .95em;
  line-height: 1.5;
}

.uvq15-spl .tm {
  display: block;
  text-align: right;
  font-size: .65em;
  color: color-mix(in srgb,var(--fg) 55%,transparent);
}

.uvq15-spl .sp {
  font: inherit;
  color: inherit;
  border: 0;
  margin: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  position: relative;
  display: inline;
  padding: 0 .1em;
  border-radius: .3em;
  font-weight: 600;
  --x: 50%;
  --y: 50%;
  --uvq15-r: 0px;
  transition: --uvq15-r calc(0.5s / var(--k)) ease-in;
}

.uvq15-spl .sp:focus-visible {
  outline: 2px solid var(--c1);
  outline-offset: 2px;
}

.uvq15-spl .tx {
  color: transparent;
  transition: color calc(0.5s / var(--k)) cubic-bezier(.25,.1,.25,1);
}

.uvq15-spl .ns {
  position: absolute;
  inset: -.05em -.1em;
  border-radius: .3em;
  background: radial-gradient(circle,currentColor .6px,transparent 1px) 0 0/4px 5px,radial-gradient(circle,currentColor .8px,transparent 1.2px) 2px 3px/7px 6px,radial-gradient(circle,currentColor .5px,transparent .9px) 1px 2px/3px 7px,radial-gradient(circle,var(--c2) .8px,transparent 1.2px) 1px 1px/11px 9px,color-mix(in srgb,var(--fg) 10%,transparent);
  opacity: .75;
  animation: uvq15-spl-n calc(1.6s / var(--k)) linear infinite;
  -webkit-mask-image: radial-gradient(circle at var(--x) var(--y),transparent var(--uvq15-r),#000 calc(var(--uvq15-r) + 1.6em));
  mask-image: radial-gradient(circle at var(--x) var(--y),transparent var(--uvq15-r),#000 calc(var(--uvq15-r) + 1.6em));
}

@keyframes uvq15-spl-n {
  25% {
    opacity: .6;
  }
  50% {
    opacity: .85;
  }
  to {
    background-position: 4px -5px,-7px 9px,3px 14px,11px -9px,0 0;
  }
}

.uvq15-spl .sp:hover .ns {
  opacity: .95;
}

.uvq15-spl .sp.on {
  --uvq15-r: 14em;
  transition: --uvq15-r calc(1.1s / var(--k)) cubic-bezier(.3,.6,.4,1);
}

.uvq15-spl .sp.on .tx {
  color: inherit;
}

.uvq15-spl .sp.on .ns {
  animation-play-state: paused;
}

.uvq15-spl .ht {
  margin: 0;
  font-size: .72em;
  color: color-mix(in srgb,var(--fg) 55%,transparent);
}

@media (prefers-reduced-motion:reduce) {
  .uvq15-spl .ns {
    animation: none;
  }
  .uvq15-spl .sp.on .ns {
    opacity: 0;
  }
}
`;

// Interacción en JavaScript del DOM: la misma que en la pestaña JS.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setup(el: any) {
  const sp=el.querySelector(".sp");let on=false;
sp.addEventListener("click",e=>{const r=sp.getBoundingClientRect(),sc=r.width/sp.offsetWidth||1;sp.style.setProperty("--x",e.detail?((e.clientX-r.left)/sc)+"px":"50%");sp.style.setProperty("--y",e.detail?((e.clientY-r.top)/sc)+"px":"50%");
on=!on;sp.classList.toggle("on",on);sp.setAttribute("aria-expanded",String(on));if(on)sp.removeAttribute("aria-label");else sp.setAttribute("aria-label","Show spoiler")})
}

export default function Mi27SpoilerDust() {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current?.firstElementChild;
    return el ? setup(el) : undefined;
  }, []);

  return (
    <>
      <style>{css}</style>
      <div
        ref={ref} style={{
          "--c1": "#0891b2",
          "--c2": "#f43f5e",
          "--s": "16px",
          "--k": "1",
          "--fg": "#1d1d1f",
          "--bg": "#f5f5f7",
        } as CSSProperties}
      >
        <div className="uvq15-spl"><div className="msg"><b className="nm">Book club</b><p><span>I didn’t expect the killer to be</span> <button type="button" className="sp" aria-expanded="false" aria-label="Show spoiler"><span className="tx">the narrator himself</span><span className="ns" aria-hidden="true"></span></button><span>.</span></p><small className="tm">21:47</small></div><p className="ht">Tap the hidden text to see it</p></div>
      </div>
    </>
  );
}
```

Give me the complete final code, ready to paste, and tell me in one line where to put it in my project.
````
