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

Flickering Grid

Мерцающая сетка: поле квадратов, случайные клетки перемигиваются. Канвас перерисовывает только изменившиеся клетки, вне кадра цикл спит.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
backdrop?string | falseBACKDROPПодложка под клетками: CSS-значение `background` или `false`. `false` нужен, когда запись стоит фоном СЕКЦИИ и подложку задаёт она.
square?number3Сторона квадрата, px.
gap?number3Зазор между квадратами, px.
chance?number0.3Какая доля клеток мигает за секунду (0.05–1).
color?stringЦвет клеток (любой CSS-цвет). По умолчанию — акцент дизайн-системы (`--ds-accent`), поэтому поле перекрашивается вместе со страницей.
children?ReactNodeСодержимое поверх. В витрине — подпись, в секции обычно пусто.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/flickering-grid.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Flickering Grid (MIT)v1.0.0