レイアウト
BentoGrid
CSS
Apple / Linear 風のベントーグリッド — サイズの異なる角丸タイルを敷き詰めた機能紹介。タイルごとのアクセント色、ホバーで浮き上がる光、スクロールで順に出現。コンテナクエリで 4 → 2 → 1 列。純粋な CSS。
高速ビルド
変更箇所だけを差分ビルド。大規模なサイトでも数秒で反映。
グローバル配信
世界中のエッジから最寄りの拠点で配信。
AI アシスト
コードの提案からレビューまで。
型安全
スキーマから型を自動生成。
分析
表示速度と離脱率をリアルタイムに計測。
自動スケール
急なアクセス増にも設定不要。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約28 KB)。
# BentoGrid をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: BentoGrid
- 説明: Apple / Linear 風のベントーグリッド — サイズの異なる角丸タイルを敷き詰めた機能紹介。タイルごとのアクセント色、ホバーで浮き上がる光、スクロールで順に出現。コンテナクエリで 4 → 2 → 1 列。純粋な CSS。
- 実行時に必要な npm パッケージ: lucide-react
- 構成ファイル: `components/bento-grid/index.tsx`, `components/bento-grid/BentoGrid.module.css`
## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。
## 手順
### 1. 依存パッケージを追加する
pnpm / yarn / bun を使っているプロジェクトでは、そのパッケージマネージャーのコマンドに置き換えてください。
```bash
npm install lucide-react
```
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/bento-grid/index.tsx`
```tsx
"use client";
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import styles from "./BentoGrid.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
/** Columns × rows a tile spans on the full-width grid. */
export type BentoSpan = "1x1" | "2x1" | "1x2" | "2x2";
export type BentoItem = {
title: string;
description?: string;
/** Shown in the tinted chip at the top-left of the tile. */
icon?: LucideIcon;
/** Default "1x1". Collapses on narrow containers (see the stylesheet). */
span?: BentoSpan;
/**
* Optional illustration area. It sits between the icon and the text (or
* to the right of the text on a 2x1 tile) and can read the tile's color
* as `var(--bento-accent)`.
*/
visual?: ReactNode;
/** Tile color for the inner gradient, icon chip and hover glow. */
accent?: string;
};
export type BentoGridProps = {
items: BentoItem[];
/** Maximum number of columns (1–6) when the container is wide. Default 4. */
columns?: number;
/** Space between tiles in px. Default 12. */
gap?: number;
/** Tile corner radius in px. Default 20. */
radius?: number;
/** Stagger-fade the tiles in the first time the grid scrolls into view. */
revealOnScroll?: boolean;
/** Accent for tiles that don't set their own. Default #a78bfa. */
accent?: string;
className?: string;
"aria-label"?: string;
};
const DEFAULT_COLUMNS = 4;
const DEFAULT_GAP = 12;
const DEFAULT_RADIUS = 20;
const DEFAULT_ACCENT = "#a78bfa";
/** Medium containers (see the container query) show at most this many columns. */
const MEDIUM_COLUMNS = 2;
/**
* Apple / Linear-style bento feature grid: dark tiles with mixed spans,
* each tinted by its own accent. Layout is pure CSS — container queries
* collapse the columns (full → 2 → 1) based on the grid's own width, so it
* behaves the same in a sidebar, a scaled preview or a full page.
*/
export function BentoGrid({
items,
columns = DEFAULT_COLUMNS,
gap,
radius,
revealOnScroll = true,
accent,
className,
"aria-label": ariaLabel,
}: BentoGridProps) {
const ref = useRef<HTMLElement>(null);
const cols = Math.min(6, Math.max(1, Math.round(columns) || DEFAULT_COLUMNS));
// The server markup already carries data-reveal="pending", and the
// stylesheet only hides pending tiles when scripting is on and motion is
// allowed — so there's no flash of visible-then-hidden tiles on hydrate,
// and no-JS / reduced-motion visitors simply see the grid.
useEffect(() => {
const el = ref.current;
if (!el || !revealOnScroll) return;
const show = () => {
el.dataset.reveal = "shown";
};
if (typeof IntersectionObserver === "undefined") {
show();
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
show();
// One-shot: tiles shouldn't fade out and back in on every scroll.
observer.disconnect();
}
},
// Trigger a little after the top edge enters, so the stagger is seen.
{ rootMargin: "0px 0px -8% 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [revealOnScroll]);
// The CSS module carries the defaults; custom properties are only set
// for non-default values so the default render matches the stylesheet.
const vars: CSSVars = {};
if (cols !== DEFAULT_COLUMNS) {
vars["--bento-cols"] = cols;
if (cols < MEDIUM_COLUMNS) vars["--bento-cols-md"] = cols;
}
if (gap !== undefined && gap !== DEFAULT_GAP) vars["--bento-gap"] = `${gap}px`;
if (radius !== undefined && radius !== DEFAULT_RADIUS) {
vars["--bento-radius"] = `${radius}px`;
}
if (accent && accent.toLowerCase() !== DEFAULT_ACCENT) {
vars["--bento-default-accent"] = accent;
}
return (
<section
ref={ref}
aria-label={ariaLabel}
className={className ? `${styles.root} ${className}` : styles.root}
// A one-column grid can't honour any column span, at any width.
data-single={cols === 1 ? "" : undefined}
data-reveal={revealOnScroll ? "pending" : undefined}
style={vars}
>
<ul className={styles.grid}>
{items.map((item, i) => {
const Icon = item.icon;
const cell: CSSVars = { "--bento-i": i };
if (item.accent) cell["--bento-accent"] = item.accent;
return (
<li
key={`${item.title}-${i}`}
className={styles.cell}
data-span={item.span ?? "1x1"}
data-visual={item.visual ? "" : undefined}
style={cell}
>
<div className={styles.tile}>
<span aria-hidden className={styles.grain} />
<div className={styles.body}>
{Icon && (
<span aria-hidden className={styles.chip}>
<Icon size={18} strokeWidth={1.8} />
</span>
)}
{item.visual && <div className={styles.visual}>{item.visual}</div>}
<div className={styles.text}>
<h3 className={styles.title}>{item.title}</h3>
{item.description && (
<p className={styles.description}>{item.description}</p>
)}
</div>
</div>
</div>
</li>
);
})}
</ul>
</section>
);
}
export default BentoGrid;
```
#### `components/bento-grid/BentoGrid.module.css`
```css
/* ==========================================================================
Bento grid — Apple / Linear-style feature tiles
Layout: .root is a size container; .grid is a CSS grid whose column
count comes from custom properties and is lowered by container queries
(full → 2 → 1), so it depends on the grid's own width, not the window.
Spans are data attributes (data-span="2x1"…), collapsed where the grid
has fewer columns than the span.
Reveal: .cell (the grid item) fades/slides in; .tile inside it lifts on
hover. Two elements so the stagger delay never applies to the hover.
Tile layers: surface gradient (static accent wash) → ::before (hover
wash, opacity only) → grain → content.
========================================================================== */
.root {
/* Spec values. The component only sets these inline for non-default
props, so the default render is exactly this stylesheet. */
--bento-cols: 4;
--bento-cols-md: 2;
--bento-gap: 12px;
--bento-radius: 20px;
--bento-row: 160px; /* minimum row height; rows grow with content */
--bento-default-accent: #a78bfa;
--bento-surface: #0c0c0f;
--bento-border: rgba(255, 255, 255, 0.08);
container: bento / inline-size;
width: 100%;
color: #ededef;
font-family:
-apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--bento-cols), minmax(0, 1fr));
grid-auto-rows: minmax(var(--bento-row), auto);
/* Back-fill holes left by wide tiles instead of leaving gaps. */
grid-auto-flow: row dense;
gap: var(--bento-gap);
margin: 0;
padding: 0;
list-style: none;
}
/* --- Spans ------------------------------------------------------------- */
.cell {
--bento-accent: var(--bento-default-accent);
position: relative;
display: flex;
min-width: 0;
}
.cell[data-span="2x1"] {
grid-column: span 2;
}
.cell[data-span="1x2"] {
grid-row: span 2;
}
.cell[data-span="2x2"] {
grid-column: span 2;
grid-row: span 2;
}
/* A one-column grid (columns={1}) can't span columns at any width:
`span 2` would create an implicit second column. */
.root[data-single] .cell {
grid-column: auto;
}
/* --- Tile -------------------------------------------------------------- */
.tile {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
isolation: isolate;
box-sizing: border-box;
border: 1px solid var(--bento-border);
border-radius: var(--bento-radius);
background:
/* Soft accent light from the top-left corner. */
radial-gradient(
120% 90% at 0% 0%,
color-mix(in srgb, var(--bento-accent) 13%, transparent),
transparent 55%
),
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 40%),
var(--bento-surface);
/* Hairline top highlight: reads as a lit edge even at rest. */
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
transition:
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.35s ease;
}
/* Hover wash: a stronger accent light plus a faint bottom-right echo.
Gradients can't transition, so this layer fades its opacity instead. */
.tile::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(
90% 70% at 0% 0%,
color-mix(in srgb, var(--bento-accent) 22%, transparent),
transparent 70%
),
radial-gradient(
70% 60% at 100% 100%,
color-mix(in srgb, var(--bento-accent) 9%, transparent),
transparent 70%
);
opacity: 0;
transition: opacity 0.35s ease;
pointer-events: none;
}
/* Film grain: keeps the large dark gradients from banding. */
.grain {
position: absolute;
inset: 0;
z-index: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.05;
mix-blend-mode: overlay;
pointer-events: none;
}
/* Hover only where hover exists (touch would leave tiles stuck "lit");
keyboard focus inside a tile (e.g. a link in its visual) lights it too. */
@media (hover: hover) {
.tile:hover {
transform: translateY(-3px);
border-color: color-mix(in srgb, var(--bento-accent) 45%, rgba(255, 255, 255, 0.1));
}
.tile:hover::before {
opacity: 1;
}
}
.tile:focus-within {
border-color: color-mix(in srgb, var(--bento-accent) 45%, rgba(255, 255, 255, 0.1));
}
.tile:focus-within::before {
opacity: 1;
}
/* --- Content ----------------------------------------------------------- */
/* Icon on top, visual in the flexible middle, text pinned to the bottom.
Without a visual the middle row is just a spacer. */
.body {
position: relative;
z-index: 1;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas:
"icon"
"visual"
"text";
gap: 12px;
box-sizing: border-box;
height: 100%;
padding: 18px;
}
/* A wide, one-row tile with a visual: text on the left, visual right. */
.cell[data-span="2x1"][data-visual] .body {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
grid-template-rows: auto minmax(0, 1fr);
grid-template-areas:
"icon visual"
"text visual";
column-gap: 16px;
}
.chip {
grid-area: icon;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: color-mix(in srgb, var(--bento-accent) 14%, rgba(255, 255, 255, 0.02));
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--bento-accent) 30%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
color: color-mix(in srgb, var(--bento-accent) 70%, white);
}
.visual {
grid-area: visual;
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.text {
grid-area: text;
align-self: end;
min-width: 0;
}
.title {
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.01em;
color: #f4f4f5;
}
.description {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.55;
color: rgba(237, 237, 239, 0.56);
}
/* --- Responsive (container queries) ------------------------------------ */
/* Medium: at most two columns. 2x2 / 2x1 still span both, so the big
tiles stay big; 1x2 keeps its height. */
@container bento (max-width: 639px) {
.grid {
grid-template-columns: repeat(var(--bento-cols-md), minmax(0, 1fr));
}
}
/* Narrow: a single column. Every span collapses to one cell; tiles with a
visual keep extra height so the illustration isn't squeezed to nothing. */
@container bento (max-width: 439px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
/* [data-span] matches the span rules' specificity so these win. */
.cell[data-span] {
grid-column: auto;
grid-row: auto;
}
.cell[data-visual] {
min-height: calc(var(--bento-row) * 1.6);
}
/* Same specificity as the 2x1 two-column body above, so this wins. */
.cell[data-span][data-visual] .body {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas:
"icon"
"visual"
"text";
}
}
/* --- Reveal on scroll ---------------------------------------------------
Pending tiles are hidden only when scripts run (the observer will show
them) and motion is allowed; otherwise the grid is simply visible. */
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
.root[data-reveal] .cell {
transition:
opacity 0.6s ease,
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
transition-delay: calc(min(var(--bento-i, 0), 10) * 70ms);
}
.root[data-reveal="pending"] .cell {
opacity: 0;
transform: translateY(18px) scale(0.98);
}
}
@media (prefers-reduced-motion: reduce) {
.tile,
.tile::before {
transition: none;
}
.tile:hover {
transform: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/bento-grid.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { BentoGrid } from "@/components/bento-grid";
import { Zap, Globe, Sparkles, ShieldCheck, ChartColumn, TrendingUp } from "lucide-react";
// TODO: items の title / description / icon / span をプロダクトの内容に差し替えてください(icon は lucide-react から選べます)
// TODO: プレビューのグラフ・地図・チャット・折れ線のイラストはデモ専用で、コードには含まれません。必要なら各 item の visual に任意の ReactNode を渡してください(var(--bento-accent) でタイルの色を参照できます)
<BentoGrid
columns={4}
gap={12}
radius={20}
accent="#a78bfa"
revealOnScroll={true}
items={[
{ title: "高速ビルド", description: "変更箇所だけを差分ビルド。大規模なサイトでも数秒で反映。", icon: Zap, span: "2x2" },
{ title: "グローバル配信", description: "世界中のエッジから最寄りの拠点で配信。", icon: Globe, span: "2x1", accent: "#38bdf8" },
{ title: "AI アシスト", description: "コードの提案からレビューまで。", icon: Sparkles, span: "1x2", accent: "#f472b6" },
{ title: "型安全", description: "スキーマから型を自動生成。", icon: ShieldCheck, span: "1x1", accent: "#818cf8" },
{ title: "分析", description: "表示速度と離脱率をリアルタイムに計測。", icon: ChartColumn, span: "2x1", accent: "#34d399" },
{ title: "自動スケール", description: "急なアクセス増にも設定不要。", icon: TrendingUp, span: "1x1", accent: "#fbbf24" },
]}
aria-label="機能一覧"
/>
```
### 4. 組み込み手順
1. タイルのデータを `items` に渡して置く: `<BentoGrid items={[{ title: "高速ビルド", description: "…", icon: Zap, span: "2x2" }, …]} aria-label="機能一覧" />`。`icon` はコンポーネント(関数)なので Server Component から Client Component へは渡せない。**BentoGrid を置くファイルを `"use client"` にする**か、items を組み立てる小さな Client Component(例: `FeatureBento.tsx`)を作ってページから読み込む。
2. `span` は `"1x1" | "2x1" | "1x2" | "2x2"`(列 × 行)。`grid-auto-flow: dense` で隙間を後続のタイルが埋めるので、並び順と span の合計を 4 列で割り切れるように組むと穴なく揃う(デモは 2x2・2x1・1x2・1x1・2x1・1x1 で 3 行)。
3. 列数はコンテナの幅で決まる(ウィンドウ幅ではない): 640px 以上で `columns`(既定 4)、440〜639px で最大 2、440px 未満で 1 列。サイドバーや狭いカラムに置いても自動で崩れる。
4. タイルの色は item ごとの `accent`、未指定のタイルはグリッドの `accent` prop(既定 #a78bfa)。`visual` に任意の ReactNode を渡すと、アイコンと文字の間(2x1 では右半分)にイラスト領域ができ、中から `var(--bento-accent)` でタイルの色を参照できる。
5. 行の最小高さや面の色はクラスから CSS 変数で上書きする: `--bento-row`(既定 160px)、`--bento-surface`(#0c0c0f)、`--bento-border`(白 8%)。
6. `npm run build` が通ることを確認し、「スクロールで画面に入るとタイルが順にフェードインする」「ホバーで少し浮き、枠線と光がアクセント色になる」「幅を狭めると 4 → 2 → 1 列に崩れ、横スクロールが出ない」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 1 列表示で横にはみ出す / 謎の 2 列目ができる | 1 列の grid に `grid-column: span 2` が残った | 狭いコンテナクエリと `columns={1}`(`data-single`)で span を `auto` に戻す規則を消さない。上書きするなら `.cell[data-span]` 以上の詳細度で |
| ホバーするとタイルがワンテンポ遅れて浮く | 出現の `transition-delay` をホバーするタイル自身に付けた | 出現は外側の `li`(.cell)、ホバーは内側の .tile に分ける現行構造を維持する |
| ハイドレーション直後に一瞬表示されてから消える | 出現前の非表示を JS(useLayoutEffect 等)で付けた | サーバー HTML に `data-reveal="pending"` を出し、CSS は `@media (scripting: enabled) and (prefers-reduced-motion: no-preference)` の中だけで隠す |
| 列数が変わらない / ウィンドウ幅で崩れる | `@media` や `vw` で列数を決めた | `container: bento / inline-size` のコンテナクエリで決める。グリッド自身はコンテナにできないので、ルート(section)がコンテナ、子の ul がグリッド |
| Tailwind の `grid-cols-*` を付けても効かない | CSS Modules はレイヤー外で、`@layer utilities` より強い | 列数は `columns` prop で渡す |
| `Functions cannot be passed directly to Client Components` エラー | Server Component(page.tsx など)で `icon: Zap` を含む items を書いた | items を組み立てるファイルを `"use client"` にする |
| スマホでタップしたタイルが光ったまま | `:hover` を素で書いた | ホバー効果は `@media (hover: hover)` の中に置く |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。依存は `lucide-react`(アイコンの型のみ)
- ファイル: `components/bento-grid/BentoGrid.tsx`(`"use client"`)+ `BentoGrid.module.css` + `index.ts`
- エクスポート: `BentoGrid`(default も同じ)、型 `BentoGridProps` / `BentoItem` / `BentoSpan`
- `BentoItem`: `title`、`description?`、`icon?: LucideIcon`、`span?: "1x1" | "2x1" | "1x2" | "2x2"`(既定 1x1)、`visual?: ReactNode`、`accent?: string`
- props: `items`、`columns`(最大列数 1–6、既定 4)、`gap`(12 px)、`radius`(20 px)、`revealOnScroll`(既定 true)、`accent`(既定 #a78bfa、個別 accent のないタイル用)、`className`、`aria-label`
- 既定値は CSS 変数(接頭辞 `--bento-`)でルートクラスに持ち、既定と異なる props だけ inline の style で上書きする。タイルごとの accent は各 li に `--bento-accent` として inline で渡す
### 見た目
- **構造**: `section.root`(コンテナ)> `ul.grid` > `li.cell`(グリッドアイテム・出現アニメ)> `div.tile`(面・ホバー)> グレイン + `div.body`
- **グリッド**: `repeat(var(--bento-cols), minmax(0, 1fr))`、`grid-auto-rows: minmax(var(--bento-row), auto)`(160px)、`grid-auto-flow: row dense`、`gap: var(--bento-gap)`
- **span**: `data-span` 属性で 2x1 → `grid-column: span 2`、1x2 → `grid-row: span 2`、2x2 → 両方
- **タイル**: `border: 1px solid rgba(255,255,255,.08)`、`border-radius: var(--bento-radius)`、背景は「左上からのアクセント光 `radial-gradient(120% 90% at 0 0, accent 13%, transparent 55%)`」+「上から白 3.5% → 透明」+ `#0c0c0f`。上辺に `inset 0 1px 0 白 5%` のハイライト
- **ホバーの光(.tile::before)**: 左上 accent 22% + 右下 accent 9% の放射グラデーション。`opacity` 0 → 1 でフェード(グラデーション自体は transition できないため)
- **グレイン**: SVG `feTurbulence`(fractalNoise, .9)のデータ URI、`opacity: .05`、`mix-blend-mode: overlay`
- **中身(.body)**: `padding: 18px`、grid-template-areas `"icon" "visual" "text"`、行は `auto minmax(0,1fr) auto`。visual が無ければ中段は余白で、文字は下端に揃う。2x1 で visual がある場合は `"icon visual" "text visual"`(左に文字、右にイラスト)
- **アイコンチップ**: 34px 角、角丸 10px、背景 `color-mix(accent 14%, 白 2%)`、`inset 0 0 0 1px accent 30%`、アイコン色 `color-mix(accent 70%, white)`、lucide 18px / stroke 1.8
- **文字**: タイトル 15px / 600 / #f4f4f5(h3)、説明 13px / 行間 1.55 / 白 56%。システムフォント(-apple-system, "Hiragino Sans", "Noto Sans JP", "Segoe UI"…)
### レスポンシブ(コンテナクエリ)
- ルートに `container: bento / inline-size`
- 639px 以下: `repeat(var(--bento-cols-md), …)`(既定 2、`columns` が 1 なら 1)。2x2 / 2x1 は 2 列ぶち抜きのまま
- 439px 以下: 1 列。全 span を `auto` に、visual 付きタイルは `min-height: calc(var(--bento-row) * 1.6)`、2x1 の左右レイアウトも縦積みに戻す
- `columns={1}` のときは幅に関係なく span を無効化(`data-single`)
### モーション
- **出現**: SSR の時点でルートに `data-reveal="pending"`。`@media (scripting: enabled) and (prefers-reduced-motion: no-preference)` の中だけで pending の .cell を `opacity: 0; transform: translateY(18px) scale(.98)` にする。IntersectionObserver(rootMargin 下 -8%)が最初に交差したら `data-reveal="shown"` にして disconnect(一度きり)。各 .cell に `--bento-i`(index)を渡し `transition-delay: min(i, 10) * 70ms`、`opacity .6s ease` / `transform .7s cubic-bezier(.22,1,.36,1)`。IntersectionObserver が無い環境では即 shown
- **ホバー**(`@media (hover: hover)` のみ): .tile が `translateY(-3px)`、枠線が `color-mix(accent 45%, 白 10%)`、::before が opacity 1。`.35s`。`:focus-within` でも枠線と光(浮き上がりなし)
- 常時アニメーションは無い
- `prefers-reduced-motion: reduce`: 出現アニメなしで最初から表示、ホバーの浮き上がりと transition を無効化(色の変化だけ残す)
### アクセシビリティ
- `section`(`aria-label` で名前付きランドマーク)> `ul` / `li` のリスト。タイトルは `h3`
- アイコンチップとグレインは `aria-hidden`。visual の意味づけは利用側に任せる(装飾なら利用側で `aria-hidden`)
- タイル自体はフォーカスを取らない。中にリンクがあれば `:focus-within` で点灯
### 受け入れ条件
- 4 列・既定のデモで、2x2 / 2x1 / 1x2 / 1x1 が隙間なく 3 行に収まる
- ホバーなしの初回描画でも、各タイルの左上がアクセント色でほんのり色づいている
- スクロールで画面に入るとタイルが順番にフェードインし、二度目以降は再生しない
- コンテナを狭めると 4 → 2 → 1 列になり、横スクロールが出ない
- JS 無効・視差効果を減らす設定では、最初からすべてのタイルが見えるインストール
npx shadcn@latest add https://anima-js.vercel.app/r/bento-grid.json生成コード
import { BentoGrid } from "@/components/bento-grid";
import { Zap, Globe, Sparkles, ShieldCheck, ChartColumn, TrendingUp } from "lucide-react";
// TODO: items の title / description / icon / span をプロダクトの内容に差し替えてください(icon は lucide-react から選べます)
// TODO: プレビューのグラフ・地図・チャット・折れ線のイラストはデモ専用で、コードには含まれません。必要なら各 item の visual に任意の ReactNode を渡してください(var(--bento-accent) でタイルの色を参照できます)
<BentoGrid
columns={4}
gap={12}
radius={20}
accent="#a78bfa"
revealOnScroll={true}
items={[
{ title: "高速ビルド", description: "変更箇所だけを差分ビルド。大規模なサイトでも数秒で反映。", icon: Zap, span: "2x2" },
{ title: "グローバル配信", description: "世界中のエッジから最寄りの拠点で配信。", icon: Globe, span: "2x1", accent: "#38bdf8" },
{ title: "AI アシスト", description: "コードの提案からレビューまで。", icon: Sparkles, span: "1x2", accent: "#f472b6" },
{ title: "型安全", description: "スキーマから型を自動生成。", icon: ShieldCheck, span: "1x1", accent: "#818cf8" },
{ title: "分析", description: "表示速度と離脱率をリアルタイムに計測。", icon: ChartColumn, span: "2x1", accent: "#34d399" },
{ title: "自動スケール", description: "急なアクセス増にも設定不要。", icon: TrendingUp, span: "1x1", accent: "#fbbf24" },
]}
aria-label="機能一覧"
/>