Motiva
Мои коллекции
← каталог

Spoiler Text

Спойлер как в Telegram: текст спрятан под дымкой мерцающих частиц, клик раскрывает (частицы разлетаются), клик снова прячет. Кнопка с aria-pressed, скрытый текст скрыт и от читалок; под reduced-motion — то же сокрытие блюром без анимации.

cssclickautorevealintermediate~3 КБreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
text?string"убийца — садовник, и это было понятно с…Скрываемый текст.
density?number22Частиц на 1000 px² — плотность дымки.
speed?number1Скорость дрейфа частиц.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/spoiler-text.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef, useState } from "react";
const css = `.spo {
  position: relative;
  display: inline-block;
  margin: 0;
  padding: 0.05em 0.28em;
  border: 0;
  border-radius: 0.4em;
  background: transparent;
  font: inherit;
  color: inherit;
  cursor: pointer;
  /* скрытый спойлер чуть темнее строки — видно, что тут «что-то лежит» */
  transition: background-color 0.25s ease;
}
.spo[aria-pressed="false"] {
  background: oklch(from var(--ds-fg, oklch(0.95 0.01 265)) l c h / 0.07);
}
.spo:focus-visible {
  outline: 2px solid var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.4));
  outline-offset: 2px;
}

.spo-text {
  position: relative;
  transition: opacity 0.3s ease, filter 0.3s ease;
}
.spo[aria-pressed="false"] .spo-text {
  opacity: 0;
}

.spo canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  border-radius: inherit;
}

/* деградация без частиц (reduced-motion, data-shot): спойлер прячет текст
   БЛЮРОМ — намерение то же, движения ноль. Ветку включает компонент атрибутом */
.spo[data-plain][aria-pressed="false"] .spo-text {
  opacity: 1;
  filter: blur(0.32em);
}
`;

export interface SpoilerTextProps {
  /** Скрываемый текст. */
  text?: string;
  /** Частиц на 1000 px² — плотность дымки. */
  density?: number;
  /** Скорость дрейфа частиц. */
  speed?: number;
}

/**
 * СПОЙЛЕР как в Telegram: текст спрятан под дымкой из мерцающих частиц,
 * клик раскрывает — частицы разлетаются; клик снова — прячет обратно.
 * Приём — интерактивное СОКРЫТИЕ: `text-scramble` декодирует сам по себе,
 * здесь решение прочитать принимает читатель (сюжетные спойлеры, суммы,
 * ответы викторины).
 *
 * Дисциплина цикла: rAF живёт только пока спойлер ЗАКРЫТ и виден в кадре
 * (IntersectionObserver), при раскрытии частицы дожигают короткий разлёт
 * и цикл останавливается. Под reduced-motion и data-shot частиц нет вовсе —
 * ветка `data-plain`: то же сокрытие, но блюром, без единого кадра анимации.
 * Семантика — <button aria-pressed>: спойлер и есть переключатель; пока
 * закрыт, текст скрыт и от читалок (aria-hidden), кнопка объявляет себя сама.
 */
export function SpoilerText({
  text = "убийца — садовник, и это было понятно со второй серии",
  density = 22,
  speed = 1,
}: SpoilerTextProps) {
  const [open, setOpen] = useState(false);
  const btn = useRef<HTMLButtonElement>(null);
  const cnv = useRef<HTMLCanvasElement>(null);
  const [plain, setPlain] = useState(false);

  useEffect(() => {
    const reduce =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduce) setPlain(true);
  }, []);

  useEffect(() => {
    const el = btn.current;
    const canvas = cnv.current;
    if (!el || !canvas || plain) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) { setPlain(true); return; }

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    let w = 0;
    let h = 0;
    type P = { x: number; y: number; a: number; ph: number; vx: number; vy: number };
    let pts: P[] = [];

    const seed = () => {
      const r = el.getBoundingClientRect();
      w = Math.max(r.width, 1);
      h = Math.max(r.height, 1);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      const n = Math.max(24, Math.round(((w * h) / 1000) * density));
      pts = Array.from({ length: n }, () => ({
        x: Math.random() * w,
        y: Math.random() * h,
        a: 0.35 + Math.random() * 0.65,
        ph: Math.random() * Math.PI * 2,
        vx: 0,
        vy: 0,
      }));
    };
    seed();

    let rafId = 0;
    let running = false;
    let inView = true;
    let mode: "veil" | "burst" = open ? "burst" : "veil";
    let burstT = 0;
    const color = getComputedStyle(el).color;

    const tick = (now: number) => {
      ctx.clearRect(0, 0, w, h);
      if (mode === "veil") {
        for (const p of pts) {
          // мелкий дрейф + мерцание: дымка живая, но не суетится
          p.x += Math.sin(now / 900 + p.ph) * 0.14 * speed;
          p.y += Math.cos(now / 1100 + p.ph * 1.7) * 0.12 * speed;
          if (p.x < 0) p.x += w; else if (p.x > w) p.x -= w;
          if (p.y < 0) p.y += h; else if (p.y > h) p.y -= h;
          const tw = 0.5 + 0.5 * Math.sin(now / 260 * speed + p.ph * 3);
          ctx.globalAlpha = p.a * (0.25 + 0.75 * tw);
          ctx.fillStyle = color;
          ctx.fillRect(p.x, p.y, 1.4, 1.4);
        }
      } else {
        // разлёт: короткий дожиг, затем канвас чист и цикл спит
        burstT += 1 / 60;
        const k = Math.max(0, 1 - burstT / 0.55);
        if (k === 0) { running = false; ctx.clearRect(0, 0, w, h); return; }
        for (const p of pts) {
          p.x += p.vx;
          p.y += p.vy;
          ctx.globalAlpha = p.a * k;
          ctx.fillStyle = color;
          ctx.fillRect(p.x, p.y, 1.4, 1.4);
        }
      }
      if (!inView && mode === "veil") { running = false; return; }
      rafId = requestAnimationFrame(tick);
    };
    const wake = () => {
      if (!running) { running = true; rafId = requestAnimationFrame(tick); }
    };

    if (open) {
      mode = "burst";
      burstT = 0;
      for (const p of pts) {
        const ang = Math.atan2(p.y - h / 2, p.x - w / 2) + (Math.random() - 0.5) * 0.8;
        const v = 1.2 + Math.random() * 2.2;
        p.vx = Math.cos(ang) * v;
        p.vy = Math.sin(ang) * v;
      }
      wake();
    } else {
      mode = "veil";
      seed();
      wake();
    }

    const io = new IntersectionObserver(([e]) => {
      inView = Boolean(e?.isIntersecting);
      if (inView && mode === "veil") wake();
    });
    io.observe(el);
    const ro = new ResizeObserver(() => { if (mode === "veil") seed(); });
    ro.observe(el);

    return () => {
      cancelAnimationFrame(rafId);
      io.disconnect();
      ro.disconnect();
    };
  }, [open, plain, density, speed]);

  return (
    <button
      ref={btn}
      type="button"
      className="spo"
      data-plain={plain ? "" : undefined}
      aria-pressed={open}
      aria-label={open ? "Скрыть спойлер" : "Спойлер — показать"}
      onClick={() => setOpen((v) => !v)}
    >
      <style href="motiva-spoiler-text" precedence="medium">{css}</style>
      <span className="spo-text" aria-hidden={open ? undefined : true}>{text}</span>
      {!plain && <canvas aria-hidden="true" ref={cnv} />}
    </button>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); приём известен по спойлерам Telegram, реализация свояv1.0.0