← каталог
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Что нужно в проекте до установки
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 { 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