Bento Grid Design: 9 Ready-to-Copy Sections
Bento grid design arranges a landing page into tiles of uneven sizes inside one grid, the way a bento box holds different-sized food in one tray.
The one-page mock below stitches all nine pieces into a single scrollable layout, built with the config further down, so you can watch how a big hero tile, a stat card, and a wide CTA band sit next to each other before you copy any one section on its own.

01 bento-grid-hero
02 bento-grid-nav
03 bento-grid-card-grid
04 bento-grid-features
05 bento-grid-pricing
06 bento-grid-testimonials
07 bento-grid-cta-band
08 bento-grid-footer
09 bento-grid-buttons-inputs
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Bento hero (2x2 grid)
- 02 Rounded pill navbar
- 03 Core bento card grid
- 04 Icon + number feature cards
- 05 Plans sized by tier
- 06 Quote cards mixed into the grid
- 07 Wide horizontal CTA tile
- 08 Link groups as their own tiles
- 09 Radius-12 pill buttons
Each part below is cut straight out of that page, in the order it appears top to bottom: hero first, then nav, then the sections a SaaS landing page usually stacks after it. If your screen runs dark instead, the Linear-style dark SaaS sections carry the same grid sensibility over a black background, and when a tile's copy has to survive the jump into an inbox, the marketing email templates cover that hand-off.
01Bento hero (2x2 grid)
What you see
The hero splits into a tall headline tile, a small stat tile, and a plain accent block instead of one wide banner — the headline tile alone spans both rows, so it reads first.

<main class="stage">
<section class="hero">
<div class="hero__tile hero__tile--main is-demo">
<p class="hero__eyebrow">Nectar</p>
<h2 class="hero__title">Your work and team, one screen</h2>
<button class="hero__cta" type="button">Start for free</button>
</div>
<div class="hero__tile hero__tile--stat is-demo">
<span class="hero__dot" aria-hidden="true"></span>
<span class="hero__stat-label">Real-time sync</span>
</div>
<div class="hero__tile hero__tile--visual is-demo" aria-hidden="true"></div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 카드가 뜨고 지는 한 주기
$easing: $ease-spring; // 등장 곡선
$color: $subject-blue; // 강조 색
@include stage($stage-ink);
.hero {
width: clamp(240px, 82vw, 380px);
height: clamp(160px, 46vw, 210px);
display: grid;
grid-template-columns: 1.4fr 1fr;
grid-template-rows: 1fr 1fr;
gap: clamp(8px, 2.4vw, 12px);
}
.hero__tile {
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
animation: heroIn $duration $easing infinite;
}
.hero__tile--main {
grid-row: 1 / 3;
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(6px, 1.6vw, 10px);
padding: clamp(12px, 3.6vw, 18px);
animation-delay: 0s;
}
.hero__tile--stat {
display: flex;
align-items: center;
gap: 8px;
padding: clamp(10px, 3vw, 14px);
animation-delay: -0.5s;
}
.hero__tile--visual {
background: $color;
opacity: .16;
animation-name: heroInFaint;
animation-delay: -1s;
}
.hero__eyebrow {
margin: 0;
color: $subject-blue-deep; // 밝은 면 위 파란 글자 — AA 4.5 (blog-design 글자 대비)
font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1;
letter-spacing: .04em;
text-transform: uppercase;
}
.hero__title {
margin: 0;
color: $subject-ink;
font-weight: 700; font-size: clamp(15px, 4vw, 19px); line-height: 1.25;
}
.hero__cta {
align-self: flex-start;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
padding: clamp(7px, 2vw, 9px) clamp(14px, 3.6vw, 18px);
font-weight: 600; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
}
.hero__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: $color;
flex: none;
}
.hero__stat-label {
color: $subject-ink;
font-weight: 600; font-size: clamp(10.5px, 2.6vw, 12.5px); line-height: 1.3;
}
// 데모: 세 칸이 살짝 다른 박자로 떠올랐다 가라앉는다 (WAAPI 시계가 잡을 수 있게 keyframes)
@keyframes heroIn {
0% { opacity: 0; transform: translateY(10px) scale(.97); }
22% { opacity: 1; transform: translateY(0) scale(1); }
78% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(10px) scale(.97); }
}
@keyframes heroInFaint {
0% { opacity: 0; transform: translateY(10px) scale(.97); }
22% { opacity: .16; transform: translateY(0) scale(1); }
78% { opacity: .16; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(10px) scale(.97); }
}
@media (prefers-reduced-motion: reduce) {
.hero__tile {
animation: none !important;
opacity: 1;
transform: none;
}
.hero__tile--visual { opacity: .16; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hero {
width: clamp(240px, 82vw, 380px);
height: clamp(160px, 46vw, 210px);
display: grid;
grid-template-columns: 1.4fr 1fr;
grid-template-rows: 1fr 1fr;
gap: clamp(8px, 2.4vw, 12px);
}
.hero__tile {
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
animation: heroIn 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.hero__tile--main {
grid-row: 1/3;
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(6px, 1.6vw, 10px);
padding: clamp(12px, 3.6vw, 18px);
animation-delay: 0s;
}
.hero__tile--stat {
display: flex;
align-items: center;
gap: 8px;
padding: clamp(10px, 3vw, 14px);
animation-delay: -0.5s;
}
.hero__tile--visual {
background: #2f6df6;
opacity: 0.16;
animation-name: heroInFaint;
animation-delay: -1s;
}
.hero__eyebrow {
margin: 0;
color: #0a4fe8;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.hero__title {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(15px, 4vw, 19px);
line-height: 1.25;
}
.hero__cta {
align-self: flex-start;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: clamp(7px, 2vw, 9px) clamp(14px, 3.6vw, 18px);
font-weight: 600;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
.hero__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #2f6df6;
flex: none;
}
.hero__stat-label {
color: #17141a;
font-weight: 600;
font-size: clamp(10.5px, 2.6vw, 12.5px);
line-height: 1.3;
}
@keyframes heroIn {
0% {
opacity: 0;
transform: translateY(10px) scale(0.97);
}
22% {
opacity: 1;
transform: translateY(0) scale(1);
}
78% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(10px) scale(0.97);
}
}
@keyframes heroInFaint {
0% {
opacity: 0;
transform: translateY(10px) scale(0.97);
}
22% {
opacity: 0.16;
transform: translateY(0) scale(1);
}
78% {
opacity: 0.16;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(10px) scale(0.97);
}
}
@media (prefers-reduced-motion: reduce) {
.hero__tile {
animation: none !important;
opacity: 1;
transform: none;
}
.hero__tile--visual {
opacity: 0.16;
}
}import type { CSSProperties } from "react";
import styles from "./BentoHero.module.scss";
export type BentoHeroProps = {
eyebrow?: string;
title?: string;
cta?: string;
stat?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BentoHero({
eyebrow = "Nectar",
title = "Your work and team, one screen",
cta = "Start for free",
stat = "Real-time sync",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BentoHeroProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.hero} style={vars}>
<div className={`${styles.tile} ${styles.tileMain}`}>
<p className={styles.eyebrow}>{eyebrow}</p>
<h2 className={styles.title}>{title}</h2>
<button type="button" className={styles.cta}>{cta}</button>
</div>
<div className={`${styles.tile} ${styles.tileStat}`}>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.statLabel}>{stat}</span>
</div>
<div className={`${styles.tile} ${styles.tileVisual}`} aria-hidden="true" />
</section>
);
}01 · Bento hero (2x2 grid) — Measured area 53.267% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a bento grid landing page design puts its hero
Reach for this shape on a SaaS landing page's first screen or a portfolio cover, anywhere a single flat banner would waste the extra width.
<section class="grid grid-cols-3 grid-rows-2 gap-4 py-16" style="min-height:340px">
<div class="col-span-2 row-span-2 flex flex-col justify-center gap-4 rounded-tile bg-white p-10 shadow-bento">
<p class="text-xs font-bold uppercase tracking-wide text-accent">Nectar</p>
<h1 class="text-4xl font-extrabold leading-tight">Work and team, one screen</h1>
<p class="max-w-md text-sm text-ink/70">No email required — your first workspace opens in three minutes.</p>
<button class="w-fit rounded-pill bg-ink px-6 py-3 text-sm font-bold text-cream">Start free</button>
</div>
<div class="flex items-center gap-3 rounded-tile bg-white p-6 shadow-bento">
<span class="h-3 w-3 rounded-full bg-accent"></span>
<span class="text-sm font-semibold">Live sync</span>
</div>
<div class="rounded-tile bg-accent/15"></div>
</section>
02Rounded pill navbar
The motion
The whole menu sits inside one pill-shaped track, and a filled inner chip slides underneath whichever item is active instead of a plain underline.

<main class="stage">
<nav class="navbar">
<span class="navbar__brand">Nectar</span>
<div class="navbar__pill is-demo">
<span class="navbar__indicator" aria-hidden="true"></span>
<span class="navbar__item">Home</span>
<span class="navbar__item">Features</span>
<span class="navbar__item">Pricing</span>
<span class="navbar__item">Contact</span>
</div>
<button class="navbar__cta" type="button">Start for free</button>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 표시등이 네 항목을 도는 한 주기
$easing: $ease-spring; // 이동 곡선
$color: $subject-blue; // 표시등 색
@include stage($stage-yellow);
.navbar {
width: clamp(260px, 84vw, 420px);
display: flex;
align-items: center;
gap: clamp(8px, 2vw, 14px);
background: $subject-cream;
border-radius: $r-pill;
padding: 8px 8px 8px 16px;
box-shadow: $shadow-raised;
}
.navbar__brand {
color: $subject-ink;
font-weight: 800; font-size: clamp(12px, 3vw, 14px); line-height: 1;
flex: none;
}
.navbar__pill {
position: relative;
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
background: rgba($subject-ink, .06);
border-radius: $r-pill;
padding: 4px;
}
.navbar__indicator {
position: absolute;
inset: 4px auto 4px 4px;
width: calc(25% - 4px);
border-radius: $r-pill;
background: $color;
animation: navSlide $duration $easing infinite;
}
.navbar__item {
position: relative;
z-index: 1;
text-align: center;
color: $subject-ink;
font-weight: 600; font-size: clamp(9.5px, 2.3vw, 11.5px); line-height: 1;
padding: clamp(6px, 1.6vw, 8px) 2px;
}
.navbar__cta {
flex: none;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
padding: clamp(7px, 1.8vw, 9px) clamp(12px, 2.6vw, 15px);
font-weight: 700; font-size: clamp(9.5px, 2.3vw, 11.5px); line-height: 1;
}
// 데모: 표시등이 홈 → 기능 → 요금제 → 문의 순으로 옮겨간 뒤 처음으로 돌아온다
@keyframes navSlide {
0% { transform: translateX(0%); }
12% { transform: translateX(0%); }
25% { transform: translateX(100%); }
37% { transform: translateX(100%); }
50% { transform: translateX(200%); }
62% { transform: translateX(200%); }
75% { transform: translateX(300%); }
87% { transform: translateX(300%); }
100% { transform: translateX(0%); }
}
@media (prefers-reduced-motion: reduce) {
.navbar__indicator {
animation: none !important;
transform: translateX(0%);
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.navbar {
width: clamp(260px, 84vw, 420px);
display: flex;
align-items: center;
gap: clamp(8px, 2vw, 14px);
background: #fff7e6;
border-radius: 999px;
padding: 8px 8px 8px 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.navbar__brand {
color: #17141a;
font-weight: 800;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
flex: none;
}
.navbar__pill {
position: relative;
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
background: rgba(23, 20, 26, 0.06);
border-radius: 999px;
padding: 4px;
}
.navbar__indicator {
position: absolute;
inset: 4px auto 4px 4px;
width: calc(25% - 4px);
border-radius: 999px;
background: #2f6df6;
animation: navSlide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.navbar__item {
position: relative;
z-index: 1;
text-align: center;
color: #17141a;
font-weight: 600;
font-size: clamp(9.5px, 2.3vw, 11.5px);
line-height: 1;
padding: clamp(6px, 1.6vw, 8px) 2px;
}
.navbar__cta {
flex: none;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
padding: clamp(7px, 1.8vw, 9px) clamp(12px, 2.6vw, 15px);
font-weight: 700;
font-size: clamp(9.5px, 2.3vw, 11.5px);
line-height: 1;
}
@keyframes navSlide {
0% {
transform: translateX(0%);
}
12% {
transform: translateX(0%);
}
25% {
transform: translateX(100%);
}
37% {
transform: translateX(100%);
}
50% {
transform: translateX(200%);
}
62% {
transform: translateX(200%);
}
75% {
transform: translateX(300%);
}
87% {
transform: translateX(300%);
}
100% {
transform: translateX(0%);
}
}
@media (prefers-reduced-motion: reduce) {
.navbar__indicator {
animation: none !important;
transform: translateX(0%);
}
}import type { CSSProperties } from "react";
import styles from "./BentoNav.module.scss";
export type BentoNavProps = {
brand?: string;
items?: string[];
cta?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BentoNav({
brand = "Nectar",
items = ["Home", "Features", "Pricing", "Contact"],
cta = "Start for free",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BentoNavProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<nav className={styles.navbar} style={vars}>
<span className={styles.brand}>{brand}</span>
<div className={styles.pill}>
<span className={styles.indicator} aria-hidden="true" />
{items.map((label) => (
<span key={label} className={styles.item}>{label}</span>
))}
</div>
<button type="button" className={styles.cta}>{cta}</button>
</nav>
);
}02 · Rounded pill navbar — Measured area 4.193% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It fits a landing page's top bar and works just as well for switching tabs inside an app, since the pill shape keeps four or five items readable without a full header bar.
<nav class="mx-auto flex max-w-5xl items-center gap-4 rounded-pill bg-white/80 backdrop-blur px-3 py-2 shadow-bento">
<span class="pl-3 font-display font-extrabold text-sm">Nectar</span>
<div class="relative flex flex-1 rounded-pill bg-ink/5 p-1">
<span class="absolute inset-y-1 left-1 w-1/4 rounded-pill bg-accent"></span>
<span class="relative z-10 flex-1 text-center text-xs font-semibold py-2">Home</span>
<span class="relative z-10 flex-1 text-center text-xs font-semibold py-2">Features</span>
<span class="relative z-10 flex-1 text-center text-xs font-semibold py-2">Pricing</span>
<span class="relative z-10 flex-1 text-center text-xs font-semibold py-2">Contact</span>
</div>
<button class="rounded-pill bg-accent px-4 py-2 text-xs font-bold text-cream">Start free</button>
</nav>
03Core bento card grid
What it does
This is the body of the whole style — four tiles with different column and row spans instead of a uniform three-column grid, so the eye lands on the tallest tile first and only then scans the smaller ones beside it.

<main class="stage">
<section class="grid">
<div class="grid__tile grid__tile--a is-demo">
<span class="grid__dot" aria-hidden="true"></span>
<p class="grid__label">Instant search</p>
</div>
<div class="grid__tile grid__tile--b is-demo">
<span class="grid__dot" aria-hidden="true"></span>
<p class="grid__label">Auto-organize</p>
</div>
<div class="grid__tile grid__tile--c is-demo">
<span class="grid__dot" aria-hidden="true"></span>
<p class="grid__label">Team chat</p>
</div>
<div class="grid__tile grid__tile--d is-demo">
<span class="grid__dot" aria-hidden="true"></span>
<p class="grid__label">Reports</p>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 네 칸을 도는 한 주기
$easing: $ease-pop; // 살짝 튀는 곡선
$color: $subject-blue; // 점 강조 색
@include stage($stage-orange);
.grid {
width: clamp(240px, 82vw, 380px);
height: clamp(160px, 46vw, 210px);
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: clamp(8px, 2.4vw, 12px);
}
.grid__tile {
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-press;
padding: clamp(10px, 3vw, 14px);
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 6px;
animation: tileWave $duration $easing infinite;
}
.grid__tile--a { grid-row: 1 / 3; animation-delay: 0s; }
.grid__tile--b { grid-column: 2; grid-row: 1; animation-delay: -0.5s; }
.grid__tile--c { grid-column: 3; grid-row: 1; animation-delay: -1s; }
.grid__tile--d { grid-column: 2 / 4; grid-row: 2; animation-delay: -1.5s; }
.grid__dot {
width: 16px;
height: 16px;
border-radius: 6px;
background: $color;
}
.grid__label {
margin: 0;
color: $subject-ink;
font-weight: 600; font-size: clamp(10.5px, 2.6vw, 12.5px); line-height: 1.3;
}
// 데모: 칸마다 살짝 떠올랐다 가라앉는 물결이 시차를 두고 지나간다
@keyframes tileWave {
0% { transform: translateY(0) scale(1); box-shadow: $shadow-press; }
15% { transform: translateY(-5px) scale(1.02); box-shadow: $shadow-raised; }
30% { transform: translateY(0) scale(1); box-shadow: $shadow-press; }
100% { transform: translateY(0) scale(1); box-shadow: $shadow-press; }
}
@media (prefers-reduced-motion: reduce) {
.grid__tile {
animation: none !important;
transform: none;
box-shadow: $shadow-press;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.grid {
width: clamp(240px, 82vw, 380px);
height: clamp(160px, 46vw, 210px);
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: clamp(8px, 2.4vw, 12px);
}
.grid__tile {
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
padding: clamp(10px, 3vw, 14px);
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 6px;
animation: tileWave 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.grid__tile--a {
grid-row: 1/3;
animation-delay: 0s;
}
.grid__tile--b {
grid-column: 2;
grid-row: 1;
animation-delay: -0.5s;
}
.grid__tile--c {
grid-column: 3;
grid-row: 1;
animation-delay: -1s;
}
.grid__tile--d {
grid-column: 2/4;
grid-row: 2;
animation-delay: -1.5s;
}
.grid__dot {
width: 16px;
height: 16px;
border-radius: 6px;
background: #2f6df6;
}
.grid__label {
margin: 0;
color: #17141a;
font-weight: 600;
font-size: clamp(10.5px, 2.6vw, 12.5px);
line-height: 1.3;
}
@keyframes tileWave {
0% {
transform: translateY(0) scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
15% {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
30% {
transform: translateY(0) scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
100% {
transform: translateY(0) scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.grid__tile {
animation: none !important;
transform: none;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}import type { CSSProperties } from "react";
import styles from "./BentoCardGrid.module.scss";
export type BentoCardGridProps = {
labels?: [string, string, string, string];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BentoCardGrid({
labels = ["Instant search", "Auto-organize", "Team chat", "Reports"],
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: BentoCardGridProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.tileA, styles.tileB, styles.tileC, styles.tileD];
return (
<section className={styles.grid} style={vars}>
{labels.map((label, i) => (
<div key={label} className={`${styles.tile} ${cls[i]}`}>
<span className={styles.dot} aria-hidden="true" />
<p className={styles.label}>{label}</p>
</div>
))}
</section>
);
}03 · Core bento card grid — Measured area 17.204% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
Use it for a feature showcase or a dashboard summary where one item genuinely deserves more room than the rest.
<div class="grid grid-cols-3 grid-rows-2 gap-4" style="min-height:260px">
<div class="row-span-2 flex flex-col justify-end gap-3 rounded-tile bg-white p-5 shadow-bento hover:shadow-bento-hover transition-shadow">
<div class="flex-1 rounded-[14px] bg-accent/10"></div>
<span class="h-4 w-4 rounded bg-accent"></span>
<p class="text-sm font-semibold">Fast search</p>
</div>
<div class="flex flex-col justify-end gap-2 rounded-tile bg-white p-5 shadow-bento hover:shadow-bento-hover transition-shadow">
<span class="h-4 w-4 rounded bg-accent"></span>
<p class="text-sm font-semibold">Auto sort</p>
</div>
<div class="flex flex-col justify-end gap-2 rounded-tile bg-white p-5 shadow-bento hover:shadow-bento-hover transition-shadow">
<span class="h-4 w-4 rounded bg-accent"></span>
<p class="text-sm font-semibold">Team chat</p>
</div>
<div class="col-span-2 flex flex-col justify-end gap-2 rounded-tile bg-white p-5 shadow-bento hover:shadow-bento-hover transition-shadow">
<span class="h-4 w-4 rounded bg-accent"></span>
<p class="text-sm font-semibold">Reports</p>
</div>
</div>
04Icon + number feature cards
What you see
Each card carries one icon block and one oversized number so a reader can scan the pair of tiles at a glance without reading the label under either.

<main class="stage">
<section class="feat">
<div class="feat__card is-demo">
<span class="feat__icon" aria-hidden="true"></span>
<p class="feat__num">12,000+</p>
<p class="feat__label">Connected apps</p>
<span class="feat__bar" aria-hidden="true"></span>
</div>
<div class="feat__card feat__card--b is-demo">
<span class="feat__icon" aria-hidden="true"></span>
<p class="feat__num">99.9%</p>
<p class="feat__label">Uptime</p>
<span class="feat__bar" aria-hidden="true"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 두 카드가 채워지는 한 주기
$easing: $ease-out; // 채우는 곡선
$color: $subject-blue; // 아이콘·바 색
@include stage($stage-paper);
.feat {
width: clamp(220px, 76vw, 340px);
display: flex;
gap: clamp(10px, 3vw, 16px);
}
.feat__card {
flex: 1;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
padding: clamp(12px, 3.4vw, 16px);
display: flex;
flex-direction: column;
gap: 6px;
}
.feat__icon {
width: 24px;
height: 24px;
border-radius: 8px;
background: $color;
margin-bottom: 4px;
}
.feat__num {
margin: 0;
color: $subject-ink;
font-weight: 800; font-size: clamp(17px, 4.6vw, 21px); line-height: 1.1;
}
.feat__label {
margin: 0;
color: $subject-ink;
opacity: .7;
font-weight: 600; font-size: clamp(10px, 2.4vw, 11.5px); line-height: 1.3;
}
.feat__bar {
display: block;
height: 4px;
border-radius: $r-pill;
background: $color;
margin-top: 4px;
transform-origin: left; // 규칙8: 왼쪽부터 차오르는 바
transform: scaleX(0);
animation: barFill $duration $easing infinite;
}
.feat__card--b .feat__bar { animation-delay: -1s; }
// 데모: 바가 왼쪽부터 차오르고, 다 차면 잠깐 머물다 비워진다
@keyframes barFill {
0% { transform: scaleX(0); }
40% { transform: scaleX(1); }
75% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.feat__bar {
animation: none !important;
transform: scaleX(1);
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.feat {
width: clamp(220px, 76vw, 340px);
display: flex;
gap: clamp(10px, 3vw, 16px);
}
.feat__card {
flex: 1;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: clamp(12px, 3.4vw, 16px);
display: flex;
flex-direction: column;
gap: 6px;
}
.feat__icon {
width: 24px;
height: 24px;
border-radius: 8px;
background: #2f6df6;
margin-bottom: 4px;
}
.feat__num {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(17px, 4.6vw, 21px);
line-height: 1.1;
}
.feat__label {
margin: 0;
color: #17141a;
opacity: 0.7;
font-weight: 600;
font-size: clamp(10px, 2.4vw, 11.5px);
line-height: 1.3;
}
.feat__bar {
display: block;
height: 4px;
border-radius: 999px;
background: #2f6df6;
margin-top: 4px;
transform-origin: left;
transform: scaleX(0);
animation: barFill 2s ease-out infinite;
}
.feat__card--b .feat__bar {
animation-delay: -1s;
}
@keyframes barFill {
0% {
transform: scaleX(0);
}
40% {
transform: scaleX(1);
}
75% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.feat__bar {
animation: none !important;
transform: scaleX(1);
}
}import type { CSSProperties } from "react";
import styles from "./BentoFeatures.module.scss";
export type BentoFeaturesProps = {
items?: { num: string; label: string }[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BentoFeatures({
items = [
{ num: "12,000+", label: "Connected apps" },
{ num: "99.9%", label: "Uptime" },
],
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: BentoFeaturesProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.feat} style={vars}>
{items.map((it, i) => (
<div key={it.label} className={i === 1 ? `${styles.card} ${styles.cardB}` : styles.card}>
<span className={styles.icon} aria-hidden="true" />
<p className={styles.num}>{it.num}</p>
<p className={styles.label}>{it.label}</p>
<span className={styles.bar} aria-hidden="true" />
</div>
))}
</section>
);
}04 · Icon + number feature cards — Measured area 0.728% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It suits a strip of stats sitting right above a pricing table, or a short product overview where two or three numbers make a stronger case than a paragraph does.
<section class="flex gap-4 py-10">
<div class="flex-1 rounded-tile bg-white p-6 shadow-bento">
<span class="mb-2 block h-6 w-6 rounded-lg bg-accent"></span>
<p class="text-2xl font-extrabold">12,000+</p>
<p class="text-xs font-semibold text-ink/70">connected apps</p>
<span class="mt-3 block h-1 w-full rounded-pill bg-accent"></span>
</div>
<div class="flex-1 rounded-tile bg-white p-6 shadow-bento">
<span class="mb-2 block h-6 w-6 rounded-lg bg-accent"></span>
<p class="text-2xl font-extrabold">99.9%</p>
<p class="text-xs font-semibold text-ink/70">uptime</p>
<span class="mt-3 block h-1 w-4/5 rounded-pill bg-accent"></span>
</div>
</section>
05Plans sized by tier
The motion
The recommended plan sits in a slightly wider tile with its own badge, so a reader can tell which plan you're pushing before comparing a single feature row.

<main class="stage">
<section class="pricing">
<div class="pricing__tile pricing__tile--basic">
<p class="pricing__plan">Free</p>
<p class="pricing__price">₩0</p>
</div>
<div class="pricing__tile pricing__tile--pro is-demo">
<span class="pricing__badge">Recommended</span>
<p class="pricing__plan">Pro</p>
<p class="pricing__price">$19/mo</p>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 추천 칸이 숨쉬는 한 주기
$easing: $ease-spring; // 배지가 튀는 곡선
$color: $subject-blue-deep; // 배지 색 (크림 글자가 얹히므로 진한 파랑)
@include stage($stage-ink);
.pricing {
width: clamp(220px, 76vw, 320px);
display: flex;
align-items: flex-end;
gap: clamp(10px, 3vw, 14px);
}
.pricing__tile {
flex: 1;
border-radius: $r-card;
padding: clamp(12px, 3.4vw, 16px);
display: flex;
flex-direction: column;
gap: 4px;
}
.pricing__tile--basic {
background: $subject-cream;
box-shadow: $shadow-press;
}
.pricing__tile--pro {
position: relative;
background: $subject-cream;
box-shadow: $shadow-raised;
padding-top: clamp(20px, 5vw, 26px);
transform-origin: bottom center; // 규칙8: 바닥 붙은 채 위로 숨쉼
animation: proBreathe $duration $easing infinite;
}
.pricing__badge {
position: absolute;
top: -10px;
left: clamp(12px, 3.4vw, 16px);
background: $color;
color: $subject-cream;
border-radius: $r-pill;
padding: 3px 10px;
font-weight: 700; font-size: clamp(9px, 2.2vw, 10.5px); line-height: 1.4;
animation: badgeBounce $duration $easing infinite;
}
.pricing__plan {
margin: 0;
color: $subject-ink;
font-weight: 700; font-size: clamp(12px, 3vw, 14px); line-height: 1.2;
}
.pricing__price {
margin: 0;
color: $subject-ink;
opacity: .75;
font-weight: 600; font-size: clamp(11px, 2.6vw, 12.5px); line-height: 1.3;
}
// 데모: 추천 칸이 살짝 커졌다 돌아오고, 배지는 그 위에서 한 번 더 튄다
@keyframes proBreathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.035); }
}
@keyframes badgeBounce {
0%, 100% { transform: translateY(0); }
45% { transform: translateY(-4px); }
55% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.pricing__tile--pro,
.pricing__badge {
animation: none !important;
transform: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pricing {
width: clamp(220px, 76vw, 320px);
display: flex;
align-items: flex-end;
gap: clamp(10px, 3vw, 14px);
}
.pricing__tile {
flex: 1;
border-radius: 16px;
padding: clamp(12px, 3.4vw, 16px);
display: flex;
flex-direction: column;
gap: 4px;
}
.pricing__tile--basic {
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.pricing__tile--pro {
position: relative;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding-top: clamp(20px, 5vw, 26px);
transform-origin: bottom center;
animation: proBreathe 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pricing__badge {
position: absolute;
top: -10px;
left: clamp(12px, 3.4vw, 16px);
background: #0a4fe8;
color: #fff7e6;
border-radius: 999px;
padding: 3px 10px;
font-weight: 700;
font-size: clamp(9px, 2.2vw, 10.5px);
line-height: 1.4;
animation: badgeBounce 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pricing__plan {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(12px, 3vw, 14px);
line-height: 1.2;
}
.pricing__price {
margin: 0;
color: #17141a;
opacity: 0.75;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 12.5px);
line-height: 1.3;
}
@keyframes proBreathe {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.035);
}
}
@keyframes badgeBounce {
0%, 100% {
transform: translateY(0);
}
45% {
transform: translateY(-4px);
}
55% {
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.pricing__tile--pro,
.pricing__badge {
animation: none !important;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./BentoPricing.module.scss";
export type BentoPricingProps = {
basicLabel?: string;
basicPrice?: string;
proLabel?: string;
proPrice?: string;
badge?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BentoPricing({
basicLabel = "Free",
basicPrice = "$0",
proLabel = "Pro",
proPrice = "$19/mo",
badge = "Recommended",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8",
}: BentoPricingProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.pricing} style={vars}>
<div className={`${styles.tile} ${styles.basic}`}>
<p className={styles.plan}>{basicLabel}</p>
<p className={styles.price}>{basicPrice}</p>
</div>
<div className={`${styles.tile} ${styles.pro}`}>
<span className={styles.badge}>{badge}</span>
<p className={styles.plan}>{proLabel}</p>
<p className={styles.price}>{proPrice}</p>
</div>
</section>
);
}05 · Plans sized by tier — Measured area 1.839% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It replaces a three-column comparison table on a pricing section, especially when there are only two or three tiers to show.
<div class="flex items-end gap-4">
<div class="flex-1 rounded-tile bg-white/60 p-6 shadow-[0_6px_12px_rgba(23,20,26,.08)]">
<p class="font-bold">Free</p>
<p class="mt-1 text-sm text-ink/70">$0</p>
</div>
<div class="relative flex-[1.1] rounded-tile bg-white pt-8 p-6 shadow-bento-hover">
<span class="absolute -top-3 left-6 rounded-pill bg-accent px-3 py-1 text-xs font-bold text-cream">Recommended</span>
<p class="font-bold">Pro</p>
<p class="mt-1 text-sm text-ink/70">$19 / month</p>
</div>
</div>
06Quote cards mixed into the grid
What it does
A wide quote tile sits next to two smaller cards and a full-width star row, so reviews read as part of the layout instead of a separate carousel bolted on underneath.

<main class="stage">
<section class="testi">
<div class="testi__tile testi__tile--quote is-demo">
<p class="testi__text">"Onboarding time dropped by half for us."</p>
<p class="testi__by">Kim Do-hyun · Product Manager</p>
</div>
<div class="testi__tile testi__tile--avatar is-demo" aria-hidden="true"></div>
<div class="testi__tile testi__tile--stars is-demo" aria-hidden="true">★★★★★</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 세 칸이 스치듯 들어오는 한 주기
$easing: $ease-spring; // 등장 곡선
$color: $subject-blue; // 별점 색
@include stage($stage-yellow);
.testi {
width: clamp(240px, 82vw, 360px);
display: grid;
grid-template-columns: 1.6fr 1fr;
grid-template-rows: 1fr 1fr;
gap: clamp(8px, 2.4vw, 12px);
}
.testi__tile {
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
padding: clamp(10px, 3vw, 14px);
animation: driftIn $duration $easing infinite;
}
.testi__tile--quote {
grid-row: 1 / 3;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
animation-delay: 0s;
}
.testi__tile--avatar {
position: relative;
animation-delay: -0.6s;
}
.testi__tile--avatar::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 26px;
height: 26px;
border-radius: 50%;
background: $color;
}
.testi__tile--stars {
display: grid;
place-items: center;
color: $subject-blue-deep; // 밝은 면 위 파란 글자 — AA 4.5 (blog-design 글자 대비)
letter-spacing: 2px;
font-size: clamp(11px, 2.8vw, 13px);
animation-delay: -1.2s;
}
.testi__text {
margin: 0;
color: $subject-ink;
font-weight: 600; font-size: clamp(11px, 2.8vw, 13px); line-height: 1.5;
}
.testi__by {
margin: 0;
color: $subject-ink;
opacity: .65;
font-weight: 500; font-size: clamp(9.5px, 2.2vw, 11px); line-height: 1.3;
}
// 데모: 세 칸이 아래서 살짝 미끄러져 들어왔다 멈췄다 사라진다 (시차)
@keyframes driftIn {
0% { opacity: 0; transform: translateY(12px); }
20% { opacity: 1; transform: translateY(0); }
70% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(12px); }
}
@media (prefers-reduced-motion: reduce) {
.testi__tile {
animation: none !important;
opacity: 1;
transform: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.testi {
width: clamp(240px, 82vw, 360px);
display: grid;
grid-template-columns: 1.6fr 1fr;
grid-template-rows: 1fr 1fr;
gap: clamp(8px, 2.4vw, 12px);
}
.testi__tile {
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: clamp(10px, 3vw, 14px);
animation: driftIn 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.testi__tile--quote {
grid-row: 1/3;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
animation-delay: 0s;
}
.testi__tile--avatar {
position: relative;
animation-delay: -0.6s;
}
.testi__tile--avatar::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 26px;
height: 26px;
border-radius: 50%;
background: #2f6df6;
}
.testi__tile--stars {
display: grid;
place-items: center;
color: #0a4fe8;
letter-spacing: 2px;
font-size: clamp(11px, 2.8vw, 13px);
animation-delay: -1.2s;
}
.testi__text {
margin: 0;
color: #17141a;
font-weight: 600;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.5;
}
.testi__by {
margin: 0;
color: #17141a;
opacity: 0.65;
font-weight: 500;
font-size: clamp(9.5px, 2.2vw, 11px);
line-height: 1.3;
}
@keyframes driftIn {
0% {
opacity: 0;
transform: translateY(12px);
}
20% {
opacity: 1;
transform: translateY(0);
}
70% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(12px);
}
}
@media (prefers-reduced-motion: reduce) {
.testi__tile {
animation: none !important;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./BentoTestimonials.module.scss";
export type BentoTestimonialsProps = {
quote?: string;
by?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BentoTestimonials({
quote = "Onboarding time dropped by half for us.",
by = "Kim Do-hyun · Product Manager",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BentoTestimonialsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.testi} style={vars}>
<div className={`${styles.tile} ${styles.quote}`}>
<p className={styles.text}>“{quote}”</p>
<p className={styles.by}>{by}</p>
</div>
<div className={`${styles.tile} ${styles.avatar}`} aria-hidden="true" />
<div className={`${styles.tile} ${styles.stars}`} aria-hidden="true">★★★★★</div>
</section>
);
}06 · Quote cards mixed into the grid — Measured area 33.467% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
Mix testimonials into the grid this way alongside partner logos, where a dedicated review section would otherwise feel like an ad break.
<div class="grid grid-cols-3 gap-4">
<div class="col-span-2 rounded-tile bg-white p-6 shadow-bento">
<p class="text-sm font-semibold leading-relaxed">“Onboarding time dropped by half.”</p>
<p class="mt-3 text-xs text-ink/60">Dana Cho · Product Manager</p>
</div>
<div class="rounded-tile bg-white p-6 shadow-bento">
<p class="text-sm font-semibold leading-relaxed">“Replaced three separate tools.”</p>
<p class="mt-3 text-xs text-ink/60">Seoyeon Park · Ops Lead</p>
</div>
<div class="col-span-2 flex items-center justify-center gap-1 rounded-tile bg-white p-6 text-accent shadow-bento" aria-hidden="true">
<span>★★★★★</span>
</div>
</div>
07Wide horizontal CTA tile
What you see
One tile stretches across the full row width, with a soft glow blurred into the corner behind the heading, so it reads as the last stop after a page full of smaller tiles rather than one more card among many.

<main class="stage">
<section class="cta">
<span class="cta__glow is-demo" aria-hidden="true"></span>
<h3 class="cta__title">Bring your team in today</h3>
<button class="cta__btn is-demo" type="button">Start for free</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 은은한 빛이 숨쉬는 한 주기
$easing: $ease-out; // 번짐 곡선
$color: $subject-blue; // 강조 색
@include stage($stage-orange);
.cta {
position: relative;
width: clamp(260px, 86vw, 400px);
overflow: hidden;
border-radius: $r-card;
background: $subject-ink;
padding: clamp(20px, 5.6vw, 28px) clamp(16px, 5vw, 24px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: clamp(10px, 3vw, 14px);
}
.cta__glow {
position: absolute;
top: -40%;
right: -20%;
width: 60%;
aspect-ratio: 1;
border-radius: 50%;
background: $color;
filter: blur(2px);
opacity: .3;
animation: glowPulse $duration $easing infinite;
}
.cta__title {
position: relative;
margin: 0;
color: $subject-cream;
font-weight: 700; font-size: clamp(15px, 4vw, 19px); line-height: 1.35;
max-width: 80%;
}
.cta__btn {
position: relative;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
padding: clamp(8px, 2.2vw, 10px) clamp(16px, 4vw, 20px);
font-weight: 700; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
animation: btnPulse $duration $easing infinite;
}
// 데모: 배경 빛이 커졌다 작아지고, 버튼이 그 박자에 맞춰 살짝 눌렸다 돌아온다
@keyframes glowPulse {
0%, 100% { transform: scale(1); opacity: .3; }
50% { transform: scale(1.25); opacity: .5; }
}
@keyframes btnPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
@media (prefers-reduced-motion: reduce) {
.cta__glow,
.cta__btn {
animation: none !important;
transform: none;
}
.cta__glow { opacity: .3; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cta {
position: relative;
width: clamp(260px, 86vw, 400px);
overflow: hidden;
border-radius: 16px;
background: #17141a;
padding: clamp(20px, 5.6vw, 28px) clamp(16px, 5vw, 24px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: clamp(10px, 3vw, 14px);
}
.cta__glow {
position: absolute;
top: -40%;
right: -20%;
width: 60%;
aspect-ratio: 1;
border-radius: 50%;
background: #2f6df6;
filter: blur(2px);
opacity: 0.3;
animation: glowPulse 2s ease-out infinite;
}
.cta__title {
position: relative;
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(15px, 4vw, 19px);
line-height: 1.35;
max-width: 80%;
}
.cta__btn {
position: relative;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
padding: clamp(8px, 2.2vw, 10px) clamp(16px, 4vw, 20px);
font-weight: 700;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
animation: btnPulse 2s ease-out infinite;
}
@keyframes glowPulse {
0%, 100% {
transform: scale(1);
opacity: 0.3;
}
50% {
transform: scale(1.25);
opacity: 0.5;
}
}
@keyframes btnPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
@media (prefers-reduced-motion: reduce) {
.cta__glow,
.cta__btn {
animation: none !important;
transform: none;
}
.cta__glow {
opacity: 0.3;
}
}import type { CSSProperties } from "react";
import styles from "./BentoCtaBand.module.scss";
export type BentoCtaBandProps = {
title?: string;
cta?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BentoCtaBand({
title = "Bring your team in today",
cta = "Start for free",
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: BentoCtaBandProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.cta} style={vars}>
<span className={styles.glow} aria-hidden="true" />
<h3 className={styles.title}>{title}</h3>
<button type="button" className={styles.btn}>{cta}</button>
</section>
);
}07 · Wide horizontal CTA tile — Measured area 19.051% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Put it right before a footer, wherever the page needs a final push toward a free trial or a sign-up.
<section class="py-10">
<div class="relative overflow-hidden rounded-tile bg-ink px-10 py-12">
<span class="absolute -right-16 -top-24 h-64 w-64 rounded-full bg-accent opacity-30 blur-sm"></span>
<h3 class="relative max-w-md text-3xl font-extrabold text-cream">Bring your team in today</h3>
<button class="relative mt-6 rounded-pill bg-cream px-6 py-3 text-sm font-bold text-ink">Start free</button>
</div>
</section>
08Link groups as their own tiles
The motion
Every footer column gets its own faint tile instead of sitting on bare background, so the bento pattern carries all the way to the bottom of the page rather than stopping at the last content section.

<main class="stage">
<footer class="footer">
<div class="footer__tile is-demo">
<p class="footer__head">Product</p>
<p class="footer__link">Features</p>
</div>
<div class="footer__tile is-demo">
<p class="footer__head">Company</p>
<p class="footer__link">About</p>
</div>
<div class="footer__tile is-demo">
<p class="footer__head">Support</p>
<p class="footer__link">Help center</p>
</div>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 세 칸이 차례로 밝아지는 한 주기
$easing: $ease-out; // 등장 곡선
$color: $subject-blue; // 링크 강조 색
@include stage($stage-paper);
.footer {
width: clamp(240px, 84vw, 380px);
display: flex;
gap: clamp(8px, 2.4vw, 12px);
}
.footer__tile {
flex: 1;
border-radius: $r-card;
background: rgba($subject-ink, .04);
padding: clamp(12px, 3.4vw, 16px);
display: flex;
flex-direction: column;
gap: 8px;
animation: tileReveal $duration $easing infinite;
}
.footer__tile:nth-child(2) { animation-delay: -0.66s; }
.footer__tile:nth-child(3) { animation-delay: -1.33s; }
.footer__head {
margin: 0;
color: $subject-ink;
opacity: .72; // AA 4.5 (blog-design 글자 대비)
font-weight: 700; font-size: clamp(9.5px, 2.2vw, 11px); line-height: 1;
text-transform: uppercase;
letter-spacing: .04em;
}
.footer__link {
margin: 0;
color: $subject-ink;
font-weight: 600; font-size: clamp(11px, 2.6vw, 12.5px); line-height: 1.3;
}
// 데모: 칸마다 살짝 떠오르며 나타났다가 가라앉는다 — 세 칸이 순서대로 (시차)
@keyframes tileReveal {
0% { opacity: .35; transform: translateY(8px) scale(.97); }
35% { opacity: 1; transform: translateY(0) scale(1); }
65% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: .35; transform: translateY(8px) scale(.97); }
}
@media (prefers-reduced-motion: reduce) {
.footer__tile {
animation: none !important;
opacity: 1;
transform: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.footer {
width: clamp(240px, 84vw, 380px);
display: flex;
gap: clamp(8px, 2.4vw, 12px);
}
.footer__tile {
flex: 1;
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
padding: clamp(12px, 3.4vw, 16px);
display: flex;
flex-direction: column;
gap: 8px;
animation: tileReveal 2s ease-out infinite;
}
.footer__tile:nth-child(2) {
animation-delay: -0.66s;
}
.footer__tile:nth-child(3) {
animation-delay: -1.33s;
}
.footer__head {
margin: 0;
color: #17141a;
opacity: 0.72;
font-weight: 700;
font-size: clamp(9.5px, 2.2vw, 11px);
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.footer__link {
margin: 0;
color: #17141a;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 12.5px);
line-height: 1.3;
}
@keyframes tileReveal {
0% {
opacity: 0.35;
transform: translateY(8px) scale(0.97);
}
35% {
opacity: 1;
transform: translateY(0) scale(1);
}
65% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0.35;
transform: translateY(8px) scale(0.97);
}
}
@media (prefers-reduced-motion: reduce) {
.footer__tile {
animation: none !important;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./BentoFooter.module.scss";
export type BentoFooterProps = {
groups?: { head: string; link: string }[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BentoFooter({
groups = [
{ head: "Product", link: "Features" },
{ head: "Company", link: "About" },
{ head: "Support", link: "Help center" },
],
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: BentoFooterProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<footer className={styles.footer} style={vars}>
{groups.map((g, i) => (
<div key={g.head} className={styles.tile} style={{ animationDelay: `${-i * 0.66}s` }}>
<p className={styles.head}>{g.head}</p>
<p className={styles.link}>{g.link}</p>
</div>
))}
</footer>
);
}08 · Link groups as their own tiles — Measured area 1.765% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It works best on a multi-column footer or a sitemap-style link block, where a plain list of links would otherwise look like an afterthought.
<footer class="grid grid-cols-3 gap-4 py-10">
<div class="rounded-tile bg-ink/[.04] p-5">
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-ink/50">Product</p>
<p class="text-sm font-semibold">Features</p>
</div>
<div class="rounded-tile bg-ink/[.04] p-5">
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-ink/50">Company</p>
<p class="text-sm font-semibold">About</p>
</div>
<div class="rounded-tile bg-ink/[.04] p-5">
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-ink/50">Support</p>
<p class="text-sm font-semibold">Help center</p>
</div>
</footer>
09Radius-12 pill buttons
What it does
The input and its submit button share the same 12px corner radius as the tiles around them, plus a matching focus ring, so a form dropped into the layout reads as one system rather than a separate widget with its own rounding.

<main class="stage">
<form class="form">
<label class="form__field is-demo">
<span class="form__ring" aria-hidden="true"></span>
<input class="form__input" type="email" placeholder="Email address" readonly>
</label>
<button class="form__btn is-demo" type="button">Sign up</button>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 포커스 링과 버튼이 도는 한 주기
$easing: $ease-spring; // 눌리는 곡선
$color: $subject-blue; // 포커스 색
@include stage($stage-ink);
.form {
width: clamp(220px, 76vw, 320px);
display: flex;
flex-direction: column;
gap: clamp(10px, 3vw, 14px);
}
.form__field {
position: relative;
display: block;
border-radius: $r-control;
background: $subject-cream;
}
.form__ring {
position: absolute;
inset: -3px;
border-radius: calc(#{$r-control} + 3px);
border: 2px solid $color;
opacity: 0;
animation: ringPulse $duration $easing infinite;
}
.form__input {
width: 100%;
box-sizing: border-box;
border: 0;
border-radius: $r-control;
background: transparent;
padding: clamp(11px, 3vw, 13px) clamp(14px, 3.6vw, 16px);
color: $subject-ink;
font-weight: 500; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
}
.form__input::placeholder { color: rgba($subject-ink, .45); }
.form__btn {
border: 0;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
padding: clamp(11px, 3vw, 13px);
font-weight: 700; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
animation: btnPress $duration $easing infinite;
}
// 데모: 입력창에 포커스 링이 떴다 사라지고, 이어서 버튼이 한 번 눌린다
@keyframes ringPulse {
0%, 20% { opacity: 0; }
32%, 55% { opacity: 1; }
68%, 100% { opacity: 0; }
}
@keyframes btnPress {
0%, 68% { transform: scale(1); }
78% { transform: scale(.96); }
88%, 100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.form__ring,
.form__btn {
animation: none !important;
transform: none;
}
.form__ring { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.form {
width: clamp(220px, 76vw, 320px);
display: flex;
flex-direction: column;
gap: clamp(10px, 3vw, 14px);
}
.form__field {
position: relative;
display: block;
border-radius: 12px;
background: #fff7e6;
}
.form__ring {
position: absolute;
inset: -3px;
border-radius: calc(12px + 3px);
border: 2px solid #2f6df6;
opacity: 0;
animation: ringPulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.form__input {
width: 100%;
box-sizing: border-box;
border: 0;
border-radius: 12px;
background: transparent;
padding: clamp(11px, 3vw, 13px) clamp(14px, 3.6vw, 16px);
color: #17141a;
font-weight: 500;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
.form__input::placeholder {
color: rgba(23, 20, 26, 0.45);
}
.form__btn {
border: 0;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
padding: clamp(11px, 3vw, 13px);
font-weight: 700;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
animation: btnPress 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ringPulse {
0%, 20% {
opacity: 0;
}
32%, 55% {
opacity: 1;
}
68%, 100% {
opacity: 0;
}
}
@keyframes btnPress {
0%, 68% {
transform: scale(1);
}
78% {
transform: scale(0.96);
}
88%, 100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.form__ring,
.form__btn {
animation: none !important;
transform: none;
}
.form__ring {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./BentoButtonsInputs.module.scss";
export type BentoButtonsInputsProps = {
placeholder?: string;
cta?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BentoButtonsInputs({
placeholder = "Email address",
cta = "Sign up",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BentoButtonsInputsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<form className={styles.form} style={vars}>
<label className={styles.field}>
<span className={styles.ring} aria-hidden="true" />
<input className={styles.input} type="email" placeholder={placeholder} readOnly />
</label>
<button type="button" className={styles.btn}>{cta}</button>
</form>
);
}09 · Radius-12 pill buttons — Measured area 2.1% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
Use this pair on any email capture form or search box that sits inside a bento section.
<form class="mx-auto flex max-w-md flex-col gap-3 rounded-tile bg-white p-6 shadow-bento sm:flex-row">
<label class="relative flex-1 rounded-[12px] bg-cream">
<input class="w-full rounded-[12px] bg-transparent px-4 py-3 text-sm outline-none ring-2 ring-accent/40" type="email" placeholder="Email address" readonly>
</label>
<button class="rounded-[12px] bg-accent px-5 py-3 text-sm font-bold text-cream" type="button">Sign up</button>
</form>
The tailwind.config
Every radius, shadow, color, and gap above comes from one config file rather than from values typed straight into each class. Drop this into a project's tailwind.config.js and every tile, badge, button, and bento grid hover shadow on the page pulls from the same four tokens.
tailwind.config = {
theme: {
extend: {
colors: {
cream: "#fff7e6",
ink: "#17141a",
accent: "#2f6df6",
line: "rgba(23,20,26,.08)",
},
fontFamily: {
display: ["'Plus Jakarta Sans'", "system-ui", "sans-serif"],
body: ["'Inter'", "system-ui", "sans-serif"],
},
borderRadius: {
tile: "20px",
pill: "999px",
},
boxShadow: {
bento: "0 14px 24px rgba(23,20,26,.10)",
"bento-hover": "0 20px 32px rgba(23,20,26,.16)",
},
spacing: {
gutter: "14px",
},
},
},
};
The vibe-coding prompt
Paste the config above into a project, then hand an agent a short, numbered list of rules rather than a vague "make it look bento." The fragment below is the part that keeps the result from drifting into a generic card grid — everything past it is in the zip's prompt.md.
1. Split the hero into several tiles of different sizes (a 2x2 asymmetric
mosaic) instead of one big block. Headline + CTA on the biggest tile,
one badge and one stat on the small ones.
2. Every card uses rounded-tile (20px) and shadow-bento. On hover, only
shadow-bento-hover grows — nothing shifts position.
3. Background is cream (#fff7e6) alone. Cards stay white or cream; accent
(#2f6df6) only marks what needs attention. No gradient card fills.
4. Pricing skips the comparison table — the recommended plan tile is just
1.1x larger than the rest.
Running that exact prompt against run/010/demo/page/index.html produced the page the sections above were cut from, checked at a 1280px viewport:

Where it breaks — the traps
When the tiles stop differing
Bento grids fall apart the moment every tile carries roughly the same amount of content, because the size differences that make the style work stop mapping to anything — a big tile next to three equally full small tiles just reads as an uneven three-column grid with extra whitespace. The pattern also breaks under real copy: swap the two-word feature labels above for a paragraph of actual marketing text, and the small tiles either overflow their fixed height or force every other tile in that row to grow to match, which erases the size contrast the whole layout depends on. Grab the full page, all nine sections, and the config file from the archive rather than rebuilding the grid math by hand — j7zbg3ef unlocks it, typed on this page exactly as it appears, no extra spaces. Carrying a tile's wording into the emails that follow signup is the same discipline at a smaller scale — the transactional email templates are where that copy lands next.
Accessibility
Contrast and reduced motion
Contrast holds up across the set because ink (#17141a) on cream (#fff7e6) and ink on plain white both clear body-text contrast by a wide margin, and the one place color alone carries meaning — the accent-colored recommended badge in 05 — also has the word "Recommended" printed on it, so the tile still reads correctly without color vision. Motion is limited to begin with: only the hero, the pro pricing tile and its badge, and the CTA glow move at all in the demos above, and every one of those honors prefers-reduced-motion by holding still rather than looping. MDN's reference on the prefers-reduced-motion media feature covers how operating systems expose that setting to a page. The grid itself also collapses to a single column under 640px rather than shrinking tiles down to an unreadable size, which matters more for this style than for a plain stack layout since a shrunk 2x2 hero gets cramped fast.
FAQ
Does bento grid design need CSS Grid, or can I fake it with flexbox?
CSS Grid is what makes the uneven tile spans possible — grid-column: span 2 and grid-row: span 2 are what let one tile occupy the space of several without absolute positioning. MDN's guide to line-based placement covers the span syntax the core grid in section 03 is built on. Flexbox can approximate a single row of uneven widths, as sections 04 and 05 above show, but it can't span two rows the way the hero tile does. Adding motion when a pointer lands on these tiles is the next layer, and the bento grid hover animations build exactly that on top of this layout.
How many tiles is too many in bento grid design examples?
Past six or seven tiles in a single grid, most readers stop distinguishing which tile is meant to matter most, since the size contrast that carries the hierarchy gets diluted across too many competing shapes. The core grid in section 03 above stays at four for exactly that reason — three normal tiles plus one obviously bigger one still reads at a glance.
Do bento grid design ideas work on a page that isn't a SaaS landing page?
Yes — the pattern is really just "uneven grid with matched radius and shadow," and it works for portfolios, changelogs, and dashboard summaries as well as it works for a product landing page. The nine sections here happen to be a landing page because that's the layout most of the reference sites for this trend used, but the internal-link roundup on the design category page covers non-landing uses too, and what this site does and doesn't sell is on the about page.









