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

Bento Grid

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

cssmouse-movehoverrevealbasic~2 КБreduced-motion ✓
↗

Пропсы

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

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

Установка

npx shadcn@latest add https://motiva.pages.dev/r/bento-grid.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";

const css = `.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; } }
`;
import { useRef, type CSSProperties, type ReactNode } from "react";

export interface BentoCell {
  title: string;
  text: string;
  /** Сколько колонок занимает плитка. */
  span?: number;
  /** Сколько строк занимает плитка. */
  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 href="motiva-bento-grid" precedence="medium">{css}</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