Spoiler Text
Спойлер как в Telegram: текст спрятан под дымкой мерцающих частиц, клик раскрывает (частицы разлетаются), клик снова прячет. Кнопка с aria-pressed, скрытый текст скрыт и от читалок; под reduced-motion — то же сокрытие блюром без анимации.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| text? | string | "убийца — садовник, и это было понятно с… | Скрываемый текст. |
| density? | number | 22 | Частиц на 1000 px² — плотность дымки. |
| speed? | number | 1 | Скорость дрейфа частиц. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/spoiler-text.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}