← каталог
Flickering Grid
Мерцающая сетка: поле квадратов, случайные клетки перемигиваются. Канвас перерисовывает только изменившиеся клетки, вне кадра цикл спит.
cssautointermediate~2 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| backdrop? | string | false | BACKDROP | Подложка под клетками: CSS-значение `background` или `false`. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она. |
| square? | number | 3 | Сторона квадрата, px. |
| gap? | number | 3 | Зазор между квадратами, px. |
| chance? | number | 0.3 | Какая доля клеток мигает за секунду (0.05–1). |
| color? | string | — | Цвет клеток (любой CSS-цвет). По умолчанию — акцент дизайн-системы (`--ds-accent`), поэтому поле перекрашивается вместе со страницей. |
| children? | ReactNode | — | Содержимое поверх. В витрине — подпись, в секции обычно пусто. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/flickering-grid.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
/** Подложка по умолчанию — как в витрине. */
const BACKDROP = "oklch(0.14 0.015 270)";
export interface FlickeringGridProps {
/**
* Подложка под клетками: CSS-значение `background` или `false`.
* `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она.
*/
backdrop?: string | false;
/** Сторона квадрата, px. */
square?: number;
/** Зазор между квадратами, px. */
gap?: number;
/** Какая доля клеток мигает за секунду (0.05–1). */
chance?: number;
/**
* Цвет клеток (любой CSS-цвет). По умолчанию — акцент дизайн-системы (`--ds-accent`),
* поэтому поле перекрашивается вместе со страницей.
*/
color?: string;
/** Содержимое поверх. В витрине — подпись, в секции обычно пусто. */
children?: ReactNode;
}
const MAX_ALPHA = 0.55;
/**
* Мерцающая сетка: поле квадратов, у каждого своя яркость, случайные клетки
* перемигиваются. Канвас перерисовывает ТОЛЬКО изменившиеся клетки (их ~сотня за кадр),
* а не всё поле из тысяч; вне кадра и в фоновой вкладке цикл останавливается.
* Reduced-motion / data-shot — один статичный кадр без цикла.
*/
export function FlickeringGrid({
backdrop = BACKDROP,
square = 3,
gap = 3,
chance = 0.3,
color,
children,
}: FlickeringGridProps = {}) {
const hostRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
if (!host || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// цвет берётся ВЫЧИСЛЕННЫМ: в canvas нельзя написать var(--ds-accent) — строка
// молча игнорируется. Через токен поле следует акценту страницы (FIELD-TEST, находка 6)
const paint = color || getComputedStyle(host).getPropertyValue("--ds-accent").trim() || "#a78bfa";
const step = square + gap;
let cols = 0;
let rows = 0;
let alphas = new Float32Array(0);
let raf = 0;
let last = 0;
let inView = true;
let pageVisible = !document.hidden;
const drawCell = (i: number) => {
const x = (i % cols) * step;
const y = Math.floor(i / cols) * step;
ctx.clearRect(x, y, square, square);
ctx.globalAlpha = alphas[i] ?? 0;
ctx.fillRect(x, y, square, square);
};
const drawAll = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < alphas.length; i++) drawCell(i);
};
const resize = () => {
const dpr = Math.min(2, window.devicePixelRatio || 1);
const w = host.clientWidth;
const h = host.clientHeight;
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
// после смены width контекст сброшен: трансформ и цвет ставим заново
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = paint;
cols = Math.ceil(w / step);
rows = Math.ceil(h / step);
alphas = new Float32Array(cols * rows);
for (let i = 0; i < alphas.length; i++) alphas[i] = Math.random() * MAX_ALPHA;
drawAll();
};
const frame = (t: number) => {
raf = 0;
const dt = Math.min(0.1, (t - last) / 1000) || 0.016;
last = t;
// сколько клеток перемигнуть в ЭТОМ кадре; дробная часть — вероятностно,
// иначе на малых полях мигание вырождалось бы в ноль
const want = alphas.length * chance * dt;
let n = Math.floor(want) + (Math.random() < want % 1 ? 1 : 0);
while (n-- > 0) {
const i = (Math.random() * alphas.length) | 0;
alphas[i] = Math.random() * MAX_ALPHA;
drawCell(i);
}
schedule();
};
const schedule = () => {
if (!raf && inView && pageVisible && !frozen) raf = requestAnimationFrame(frame);
};
const stop = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(host);
// вне кадра поле не видно никому — цикл спит (лимит бюджета фоновых холстов)
const io = new IntersectionObserver(([e]) => {
inView = !!e?.isIntersecting;
inView ? ((last = performance.now()), schedule()) : stop();
});
io.observe(host);
const onVis = () => {
pageVisible = !document.hidden;
pageVisible ? ((last = performance.now()), schedule()) : stop();
};
document.addEventListener("visibilitychange", onVis);
resize();
schedule();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, [square, gap, chance, color]);
return (
<div
ref={hostRef}
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
...(backdrop === false ? null : { background: backdrop }),
}}
>
<canvas ref={canvasRef} style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />
{children && (
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>{children}</div>
)}
</div>
);
}
/**
* Flickering Grid — vanilla-версия (без React).
*
* Мерцающая сетка квадратов на канвасе: перерисовываются только изменившиеся клетки,
* вне кадра и в фоновой вкладке цикл спит. Reduced-motion / data-shot — статичный кадр.
*
* HTML:
* <div id="bg" style="position:relative; height:400px;"></div>
* <script type="module">
* import { initFlickeringGrid } from "./flickering-grid.vanilla.js";
* const destroy = initFlickeringGrid(document.querySelector("#bg"), { color: "#a78bfa" });
* // destroy() — снять эффект (канвас и слушатели убираются)
* </script>
*
* @param {HTMLElement} element - контейнер (position: relative/absolute); канвас создаётся внутри
* @param {{ square?: number, gap?: number, chance?: number, color?: string }} [options]
* @returns {() => void} destroy
*/
export function initFlickeringGrid(element, options = {}) {
const { square = 3, gap = 3, chance = 0.3, color = "#a78bfa" } = options;
const MAX_ALPHA = 0.55;
const step = square + gap;
const canvas = document.createElement("canvas");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
element.appendChild(canvas);
const ctx = canvas.getContext("2d");
if (!ctx) return () => canvas.remove();
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let cols = 0;
let rows = 0;
let alphas = new Float32Array(0);
let raf = 0;
let last = 0;
let inView = true;
let pageVisible = !document.hidden;
const drawCell = (i) => {
const x = (i % cols) * step;
const y = Math.floor(i / cols) * step;
ctx.clearRect(x, y, square, square);
ctx.globalAlpha = alphas[i];
ctx.fillRect(x, y, square, square);
};
const drawAll = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < alphas.length; i++) drawCell(i);
};
const resize = () => {
const dpr = Math.min(2, window.devicePixelRatio || 1);
const w = element.clientWidth;
const h = element.clientHeight;
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
// после смены width контекст сброшен: трансформ и цвет ставим заново
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = color;
cols = Math.ceil(w / step);
rows = Math.ceil(h / step);
alphas = new Float32Array(cols * rows);
for (let i = 0; i < alphas.length; i++) alphas[i] = Math.random() * MAX_ALPHA;
drawAll();
};
const frame = (t) => {
raf = 0;
const dt = Math.min(0.1, (t - last) / 1000) || 0.016;
last = t;
// дробная часть — вероятностно, иначе на малых полях мигание вырождается в ноль
const want = alphas.length * chance * dt;
let n = Math.floor(want) + (Math.random() < want % 1 ? 1 : 0);
while (n-- > 0) {
const i = (Math.random() * alphas.length) | 0;
alphas[i] = Math.random() * MAX_ALPHA;
drawCell(i);
}
schedule();
};
const schedule = () => {
if (!raf && inView && pageVisible && !frozen) raf = requestAnimationFrame(frame);
};
const stop = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(resize);
ro.observe(element);
const io = new IntersectionObserver(([e]) => {
inView = !!(e && e.isIntersecting);
inView ? ((last = performance.now()), schedule()) : stop();
});
io.observe(element);
const onVis = () => {
pageVisible = !document.hidden;
pageVisible ? ((last = performance.now()), schedule()) : stop();
};
document.addEventListener("visibilitychange", onVis);
resize();
schedule();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.remove();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Flickering Grid (MIT)v1.0.0