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

Bento Grid

Bento-сетка: плитки разного размера в одной решётке, подсветка следует за курсором. Один обработчик на всю сетку вместо слушателя на каждой плитке.

cssmouse-movehoverrevealbasic~2 КБreduced-motion ✓

Пропсы

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

ПропТипПо умолчаниюЧто это
cells?BentoCell[]CELLS
BentoCell — что внутри
  • title: string
  • text: string
  • span?: number— Сколько колонок занимает плитка (формат «2»).
  • rows?: number— Сколько строк занимает плитка (формат «2»).
  • accent?: string
  • art?: ReactNode

Установка

npx shadcn@latest add https://motiva.pages.dev/r/bento-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 { useRef, type CSSProperties, type ReactNode } from "react";

export interface BentoCell {
  title: string;
  text: string;
  /** Сколько колонок занимает плитка (формат «2»). */
  span?: number;
  /** Сколько строк занимает плитка (формат «2»). */
  rows?: number;
  accent?: string;
  art?: ReactNode;
}

const CELLS: BentoCell[] = [
  { title: "Живые превью", text: "Каждый эффект крутится в изолированном iframe", span: 2, rows: 2, accent: "300" },
  { title: "Два формата", text: "React и чистый JS — код и демо совпадают", accent: "230" },
  { title: "Крутилки", text: "Подобрал параметры — скопировал пропсы", accent: "150" },
  { title: "npx shadcn add", text: "Ставится одной командой", span: 2, accent: "60" },
];

/**
 * Bento-сетка: плитки разного размера в одной решётке, подсветка следует за курсором
 * (радиальный градиент по CSS-переменным, которые пишет один обработчик на всю сетку —
 * не по слушателю на каждой плитке). Перф: только фон, ни одного layout-чтения в кадре.
 */
export function BentoGrid({ cells = CELLS }: { cells?: BentoCell[] }) {
  const ref = useRef<HTMLDivElement>(null);

  // один обработчик на сетку: пишем координаты курсора в CSS-переменные активной плитки
  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
    const tile = (e.target as HTMLElement).closest<HTMLElement>(".bnt-cell");
    if (!tile) return;
    const r = tile.getBoundingClientRect();
    tile.style.setProperty("--bnt-x", `${e.clientX - r.left}px`);
    tile.style.setProperty("--bnt-y", `${e.clientY - r.top}px`);
  };

  return (
    <div ref={ref} onPointerMove={onMove} className="bnt-grid">
      <style>{`
        .bnt-grid {
          display: grid; gap: 12px; width: 100%;
          grid-template-columns: repeat(4, 1fr); grid-auto-rows: minmax(96px, auto);
          grid-auto-flow: row dense;   /* закрывает дыры от плиток на две колонки */
        }
        @media (max-width: 640px) { .bnt-grid { grid-template-columns: repeat(2, 1fr); } }
        .bnt-cell {
          --bnt-x: 50%; --bnt-y: 50%;
          position: relative; overflow: hidden; padding: 16px 18px; border-radius: 16px;
          border: 1px solid var(--ds-border, oklch(0.3 0.02 270)); background: var(--ds-surface, oklch(0.17 0.02 270));
          transition: border-color 0.25s, transform 0.25s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
        }
        .bnt-cell::before {
          content: ""; position: absolute; inset: 0; opacity: 0; transition: opacity 0.3s;
          background: radial-gradient(240px circle at var(--bnt-x) var(--bnt-y),
            oklch(0.65 0.17 var(--bnt-hue) / 0.22), transparent 70%);
        }
        .bnt-cell:hover { border-color: oklch(0.45 0.08 var(--bnt-hue)); transform: translateY(-2px); }
        .bnt-cell:hover::before { opacity: 1; }
        .bnt-title { position: relative; font-weight: 650; letter-spacing: -0.015em; font-size: 1rem;
          color: var(--ds-fg, oklch(0.95 0.01 265)); }
        .bnt-text { position: relative; margin-top: 6px; font-size: 0.82rem; line-height: 1.45;
          color: var(--ds-muted, oklch(0.72 0.02 265)); }
        .bnt-art { position: absolute; right: -14px; bottom: -14px; width: 92px; height: 92px;
          border-radius: 50%; filter: blur(2px); opacity: 0.5;
          background: radial-gradient(circle, oklch(0.7 0.18 var(--bnt-hue) / 0.55), transparent 68%); }
        @media (prefers-reduced-motion: reduce) { .bnt-cell { transition: none; } }
      `}</style>

      {cells.map((c) => (
        <div
          key={c.title}
          className="bnt-cell"
          style={
            {
              gridColumn: `span ${c.span ?? 1}`,
              gridRow: `span ${c.rows ?? 1}`,
              "--bnt-hue": c.accent ?? "280",
            } as CSSProperties
          }
        >
          <div className="bnt-title">{c.title}</div>
          <div className="bnt-text">{c.text}</div>
          {c.art ?? <div className="bnt-art" />}
        </div>
      ))}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0