9 CSS Bento Grid Hover Animations to Copy-Paste
A bento grid is a layout built from big and small boxes fitted together like a photo collage, the shape you see on portfolio and product pages.
01 bento-grid-hover-breath-scale-shadow
02 bento-grid-hover-border-beam-tile
03 bento-grid-hover-image-zoom-tile
05 bento-grid-hover-gradient-reveal-tile
06 bento-grid-hover-stat-count-tile
07 bento-grid-hover-hover-expand-span
08 bento-grid-hover-scroll-stagger-tiles
09 bento-grid-hover-spotlight-neighbors
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Breathing scale + shadow
- 02 Border beam tile
- 03 Image tile zoom
- 04 Icon float tile
- 05 Gradient reveal tile
- 06 Stat count tile
- 07 Grid span expand on hover
- 08 Tiles reveal via scroll stagger
- 09 Neighboring tile glow
The nine below are ordered by how an eye actually scans a grid, not by popularity. 01 and 02 show that a whole tile can react before anything inside it does. 03 through 05 move something inside the tile instead — a photo layer, an icon, a caption. 06 changes what a number says, 07 fakes a bigger tile the boldest way in the set, 08 handles the grid's first entrance on scroll, and 09 closes with a glow the whole grid shares at once. Adding light to each tile so the response reads louder is what the CSS glow effects supply, and reshaping how fast a tile moves at all is the list of curves in CSS easing and springs.
01Breathing scale + shadow
Where this fits in a bento grid design ui
Hovering a tile scales the whole card up slightly, from 1 to 1.04, while its box-shadow deepens from a flatter, pressed-in shadow to a taller, lifted one on the same transition.

<main class="stage">
<div class="bs-grid is-demo">
<div class="bs-tile bs-tile--a bs-focal">
<span class="bs-tile__tag">Project</span>
<span class="bs-tile__num">01</span>
</div>
<div class="bs-tile bs-tile--b">
<span class="bs-tile__tag">New</span>
</div>
<div class="bs-tile bs-tile--c">
<span class="bs-tile__tag">42%</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.bs-grid {
width: min(340px, 90vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: $sp-2;
}
.bs-tile {
border-radius: $r-sm;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: $sp-3;
box-sizing: border-box;
color: $subject-cream;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
}
.bs-tile--a {
grid-row: 1 / 3;
background: linear-gradient(160deg, $color, rgba(23, 20, 26, .85));
}
.bs-tile--b, .bs-tile--c {
background: rgba(255, 255, 255, .08);
}
.bs-tile__tag { opacity: .8; font-size: 11px; }
.bs-tile__num { font-size: 22px; font-weight: 700; margin-top: $sp-1; }
// 실사용: 진짜 마우스 호버로 트랜지션
.bs-focal {
transition: transform $duration $easing, box-shadow $duration $easing;
box-shadow: $shadow-press;
}
.bs-focal:hover {
transform: scale(1.04);
box-shadow: $shadow-raised;
}
// 데모 루프: is-demo 는 같은 모양을 keyframes 로 무한 재생 (캡처용 — 헤드리스는 :hover 를 못 건다)
@keyframes bs-breathe {
0%, 100% { transform: scale(1); box-shadow: $shadow-press; }
50% { transform: scale(1.04); box-shadow: $shadow-raised; }
}
.bs-grid.is-demo .bs-focal { animation: bs-breathe $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.bs-focal:hover,
.bs-grid.is-demo .bs-focal { animation: none !important; transform: none !important; box-shadow: $shadow-press !important; }
}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);
}
.bs-grid {
width: min(340px, 90vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
}
.bs-tile {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
box-sizing: border-box;
color: #fff7e6;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
}
.bs-tile--a {
grid-row: 1/3;
background: linear-gradient(160deg, #2f6df6, rgba(23, 20, 26, 0.85));
}
.bs-tile--b, .bs-tile--c {
background: rgba(255, 255, 255, 0.08);
}
.bs-tile__tag {
opacity: 0.8;
font-size: 11px;
}
.bs-tile__num {
font-size: 22px;
font-weight: 700;
margin-top: 4px;
}
.bs-focal {
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.bs-focal:hover {
transform: scale(1.04);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
@keyframes bs-breathe {
0%, 100% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
50% {
transform: scale(1.04);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
.bs-grid.is-demo .bs-focal {
animation: bs-breathe 300ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.bs-focal:hover,
.bs-grid.is-demo .bs-focal {
animation: none !important;
transform: none !important;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32) !important;
}
}import { CSSProperties } from "react";
import styles from "./BreathScaleShadow.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface BreathScaleShadowProps {
duration?: string;
easing?: string;
color?: string;
}
export default function BreathScaleShadow({
duration = "300ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: BreathScaleShadowProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.grid} style={style}>
<div className={`${styles.tile} ${styles.tileA} ${styles.focal}`}>
<span className={styles.tag}>Project</span>
<span className={styles.num}>01</span>
</div>
<div className={`${styles.tile} ${styles.tileB}`}>
<span className={styles.tag}>New</span>
</div>
<div className={`${styles.tile} ${styles.tileC}`}>
<span className={styles.tag}>42%</span>
</div>
</div>
);
}01 · Breathing scale + shadow — Measured area 2.481% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
Nothing about the tile's position or size in the grid changes — only how tall and lit it looks.
.bs-focal {
transition: transform $duration $easing, box-shadow $duration $easing;
box-shadow: $shadow-press;
}
.bs-focal:hover {
transform: scale(1.04);
box-shadow: $shadow-raised;
}
02Border beam tile
What you see
On hover, a thin ring of light spins once around a tile's border while the border's own thickness never moves.

<main class="stage">
<div class="bb-grid is-demo">
<div class="bb-tile bb-tile--a bb-focal">
<span class="bb-tile__tag">Featured</span>
<span class="bb-tile__sub">Pro plan</span>
</div>
<div class="bb-tile bb-tile--b">
<span class="bb-tile__tag">+12</span>
</div>
<div class="bb-tile bb-tile--c">
<span class="bb-tile__tag">Live</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.6s;
$easing: linear;
$color: $subject-blue;
@include stage($stage-yellow);
.bb-grid {
width: min(340px, 90vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: $sp-2;
}
.bb-tile {
border-radius: $r-sm;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: $sp-3;
box-sizing: border-box;
color: $subject-cream;
background: $subject-ink;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
position: relative;
overflow: hidden;
}
.bb-tile--a { grid-row: 1 / 3; }
.bb-tile--b, .bb-tile--c { background: rgba(23, 20, 26, .78); }
.bb-tile__tag { font-size: 12px; opacity: .85; }
.bb-tile__sub { font-size: 18px; font-weight: 700; margin-top: $sp-1; }
// 빛줄기 — 두께 $sp-1 만큼만 링으로 보이게 mask. 회전은 transform 만 (그라디언트 각도 자체는 애니메이트하지 않는다)
.bb-focal::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: $sp-1;
box-sizing: border-box;
background: conic-gradient(from 0deg, transparent 0 70%, $color 85%, transparent 100%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
pointer-events: none;
opacity: 0;
}
// 실사용: 진짜 마우스 호버
.bb-focal:hover::before { opacity: 1; animation: bb-spin $duration $easing infinite; }
@keyframes bb-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// 데모 루프: is-demo 는 같은 회전을 무한 재생 (캡처용)
.bb-grid.is-demo .bb-focal::before { opacity: 1; animation: bb-spin $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.bb-focal:hover::before,
.bb-grid.is-demo .bb-focal::before { animation: none !important; }
}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);
}
.bb-grid {
width: min(340px, 90vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
}
.bb-tile {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
box-sizing: border-box;
color: #fff7e6;
background: #17141a;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
position: relative;
overflow: hidden;
}
.bb-tile--a {
grid-row: 1/3;
}
.bb-tile--b, .bb-tile--c {
background: rgba(23, 20, 26, 0.78);
}
.bb-tile__tag {
font-size: 12px;
opacity: 0.85;
}
.bb-tile__sub {
font-size: 18px;
font-weight: 700;
margin-top: 4px;
}
.bb-focal::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 4px;
box-sizing: border-box;
background: conic-gradient(from 0deg, transparent 0 70%, #2f6df6 85%, transparent 100%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
pointer-events: none;
opacity: 0;
}
.bb-focal:hover::before {
opacity: 1;
animation: bb-spin 1.6s linear infinite;
}
@keyframes bb-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.bb-grid.is-demo .bb-focal::before {
opacity: 1;
animation: bb-spin 1.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.bb-focal:hover::before,
.bb-grid.is-demo .bb-focal::before {
animation: none !important;
}
}import { CSSProperties } from "react";
import styles from "./BorderBeamTile.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface BorderBeamTileProps {
duration?: string;
easing?: string;
color?: string;
}
export default function BorderBeamTile({
duration = "1.6s",
easing = "linear",
color = "#2f6df6",
}: BorderBeamTileProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.grid} style={style}>
<div className={`${styles.tile} ${styles.tileA} ${styles.focal}`}>
<span className={styles.tag}>Featured</span>
<span className={styles.sub}>Pro plan</span>
</div>
<div className={`${styles.tile} ${styles.tileB}`}>
<span className={styles.tag}>+12</span>
</div>
<div className={`${styles.tile} ${styles.tileC}`}>
<span className={styles.tag}>Live</span>
</div>
</div>
);
}02 · Border beam tile — Measured area 5.527% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
The ring is a conic-gradient cut down to a thin strip with mask-composite: exclude, and only that ring layer's transform: rotate() spins — the gradient's colors themselves stay fixed.
.bb-focal::before {
content: "";
position: absolute;
inset: 0;
padding: $sp-1;
background: conic-gradient(from 0deg, transparent 0 70%, $color 85%, transparent 100%);
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
opacity: 0;
}
.bb-focal:hover::before {
opacity: 1;
animation: bb-spin 1.6s linear infinite;
}
@keyframes bb-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
03Image tile zoom
Where it fits
The tile's outer frame stays exactly where it is — overflow: hidden clips anything that spills past its edge — while only the photo layer inside scales up on hover, so the zoom reads as happening inside a fixed picture frame rather than the frame itself growing.

<main class="stage">
<div class="iz-grid is-demo">
<div class="iz-tile iz-tile--a iz-focal">
<div class="iz-tile__media"></div>
<span class="iz-tile__label">View</span>
</div>
<div class="iz-tile iz-tile--b">
<span class="iz-tile__tag">Gallery</span>
</div>
<div class="iz-tile iz-tile--c">
<span class="iz-tile__tag">24 photos</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: linear;
$color: $subject-blue;
@include stage($stage-orange);
.iz-grid {
width: min(340px, 90vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: $sp-2;
}
.iz-tile {
border-radius: $r-sm;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: $sp-3;
box-sizing: border-box;
color: $subject-cream;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
position: relative;
}
.iz-tile--a { grid-row: 1 / 3; overflow: hidden; }
.iz-tile--b, .iz-tile--c { background: $subject-ink; }
// 바깥 타일은 고정, 안쪽 media 레이어만 확대된다
.iz-tile__media {
position: absolute;
inset: 0;
background:
radial-gradient(65px circle at 28% 66%, #fff, transparent 48%),
linear-gradient(160deg, $color, $subject-ink);
}
.iz-tile__label { position: relative; z-index: 1; font-size: 12px; opacity: .9; }
// 실사용: 진짜 마우스 호버
.iz-tile__media { transition: transform $duration $easing; }
.iz-focal:hover .iz-tile__media { transform: scale(1.15); }
// 데모 루프: is-demo 는 같은 확대를 keyframes 로 무한 재생 (캡처용)
@keyframes iz-zoom {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
.iz-grid.is-demo .iz-focal .iz-tile__media { animation: iz-zoom $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.iz-focal:hover .iz-tile__media,
.iz-grid.is-demo .iz-focal .iz-tile__media { animation: none !important; transform: none !important; }
}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);
}
.iz-grid {
width: min(340px, 90vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
}
.iz-tile {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
box-sizing: border-box;
color: #fff7e6;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
position: relative;
}
.iz-tile--a {
grid-row: 1/3;
overflow: hidden;
}
.iz-tile--b, .iz-tile--c {
background: #17141a;
}
.iz-tile__media {
position: absolute;
inset: 0;
background: radial-gradient(65px circle at 28% 66%, #fff, transparent 48%), linear-gradient(160deg, #2f6df6, #17141a);
}
.iz-tile__label {
position: relative;
z-index: 1;
font-size: 12px;
opacity: 0.9;
}
.iz-tile__media {
transition: transform 600ms linear;
}
.iz-focal:hover .iz-tile__media {
transform: scale(1.15);
}
@keyframes iz-zoom {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
.iz-grid.is-demo .iz-focal .iz-tile__media {
animation: iz-zoom 600ms linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.iz-focal:hover .iz-tile__media,
.iz-grid.is-demo .iz-focal .iz-tile__media {
animation: none !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./ImageZoomTile.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface ImageZoomTileProps {
duration?: string;
easing?: string;
color?: string;
}
export default function ImageZoomTile({
duration = "400ms",
easing = "ease-out",
color = "#2f6df6",
}: ImageZoomTileProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.grid} style={style}>
<div className={`${styles.tile} ${styles.tileA} ${styles.focal}`}>
<div className={styles.media} />
<span className={styles.label}>View</span>
</div>
<div className={`${styles.tile} ${styles.tileB}`}>
<span className={styles.tag}>Gallery</span>
</div>
<div className={`${styles.tile} ${styles.tileC}`}>
<span className={styles.tag}>24 photos</span>
</div>
</div>
);
}03 · Image tile zoom — Measured area 2.692% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
It's the same trick a lightbox thumbnail uses to zoom without shifting any layout around it.
.iz-tile--a { overflow: hidden; }
.iz-tile__media {
position: absolute;
inset: 0;
transition: transform $duration $easing;
}
.iz-focal:hover .iz-tile__media {
transform: scale(1.15);
}
04Icon float tile
What moves
The icon inside a feature tile — a rounded square drawn purely in CSS with a dot inset, no emoji or image file involved — sits completely still until a hover starts a translateY keyframe loop that gently bobs it up and down.
<main class="stage">
<div class="if-grid is-demo">
<div class="if-tile if-tile--a if-focal">
<span class="if-icon"></span>
<span class="if-tile__label">Fast</span>
</div>
<div class="if-tile if-tile--b">
<span class="if-tile__tag">Beta</span>
</div>
<div class="if-tile if-tile--c">
<span class="if-tile__tag">v2</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.4s;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.if-grid {
width: min(340px, 90vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: $sp-2;
}
.if-tile {
border-radius: $r-sm;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
gap: $sp-2;
padding: $sp-3;
box-sizing: border-box;
color: $subject-ink;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
background: rgba(23, 20, 26, .05);
}
.if-tile--a { grid-row: 1 / 3; justify-content: center; align-items: center; }
.if-tile--b, .if-tile--c { justify-content: flex-end; align-items: flex-start; }
.if-tile__tag { opacity: .7; }
.if-tile__label { opacity: .8; }
// 아이콘 — 이모지 대신 CSS 로 그린 둥근 사각형 + 안쪽 점
.if-icon {
position: relative;
width: $sp-8;
height: $sp-8;
border-radius: $r-control;
background: $color;
display: block;
}
.if-icon::after {
content: "";
position: absolute;
inset: $sp-2;
border-radius: $r-pill;
background: $subject-cream;
}
// 실사용: 진짜 마우스 호버 — 평소엔 가만히, 호버해야 떠오른다
.if-focal:hover .if-icon { animation: if-bob $duration $easing infinite; }
@keyframes if-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
// 데모 루프: is-demo 는 같은 동작을 무한 재생 (캡처용)
.if-grid.is-demo .if-focal .if-icon { animation: if-bob $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.if-focal:hover .if-icon,
.if-grid.is-demo .if-focal .if-icon { animation: none !important; transform: none !important; }
}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);
}
.if-grid {
width: min(340px, 90vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
}
.if-tile {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
gap: 8px;
padding: 12px;
box-sizing: border-box;
color: #17141a;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
background: rgba(23, 20, 26, 0.05);
}
.if-tile--a {
grid-row: 1/3;
justify-content: center;
align-items: center;
}
.if-tile--b, .if-tile--c {
justify-content: flex-end;
align-items: flex-start;
}
.if-tile__tag {
opacity: 0.7;
}
.if-tile__label {
opacity: 0.8;
}
.if-icon {
position: relative;
width: 32px;
height: 32px;
border-radius: 12px;
background: #2f6df6;
display: block;
}
.if-icon::after {
content: "";
position: absolute;
inset: 8px;
border-radius: 999px;
background: #fff7e6;
}
.if-focal:hover .if-icon {
animation: if-bob 1.4s ease-out infinite;
}
@keyframes if-bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
.if-grid.is-demo .if-focal .if-icon {
animation: if-bob 1.4s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.if-focal:hover .if-icon,
.if-grid.is-demo .if-focal .if-icon {
animation: none !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./IconFloatTile.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface IconFloatTileProps {
duration?: string;
easing?: string;
color?: string;
}
export default function IconFloatTile({
duration = "1.4s",
easing = "ease-out",
color = "#2f6df6",
}: IconFloatTileProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.grid} style={style}>
<div className={`${styles.tile} ${styles.tileA} ${styles.focal}`}>
<span className={styles.icon} />
<span className={styles.label}>Fast</span>
</div>
<div className={`${styles.tile} ${styles.tileB}`}>
<span className={styles.tag}>Beta</span>
</div>
<div className={`${styles.tile} ${styles.tileC}`}>
<span className={styles.tag}>v2</span>
</div>
</div>
);
}04 · Icon float tile — Measured area 0.461% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
It stops the moment the cursor leaves, so the tile never drifts on its own.
.if-icon {
position: relative;
width: $sp-8;
height: $sp-8;
border-radius: $r-control;
background: $color;
}
.if-icon::after {
content: "";
position: absolute;
inset: $sp-2;
border-radius: $r-pill;
background: $subject-cream;
}
.if-focal:hover .if-icon {
animation: if-bob 1.4s $ease-out infinite;
}
@keyframes if-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
05Gradient reveal tile
The moment it fires
A photo-only tile hides a dark gradient scrim underneath at rest; hovering fades that scrim in with opacity while the title text slides up with translateY, so the caption reads clearly only once the photo behind it has dimmed enough.

<main class="stage">
<div class="gr-grid is-demo">
<div class="gr-tile gr-tile--a gr-focal">
<span class="gr-tile__scrim"></span>
<span class="gr-tile__label">City lights</span>
</div>
<div class="gr-tile gr-tile--b">
<span class="gr-tile__tag">Trending</span>
</div>
<div class="gr-tile gr-tile--c">
<span class="gr-tile__tag">New</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 350ms;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.gr-grid {
width: min(340px, 90vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: $sp-2;
}
.gr-tile {
border-radius: $r-sm;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: $sp-3;
box-sizing: border-box;
color: $subject-cream;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
position: relative;
overflow: hidden;
}
.gr-tile--a { grid-row: 1 / 3; background: linear-gradient(160deg, $color, rgba(255, 255, 255, .2)); }
.gr-tile--b, .gr-tile--c { background: rgba(255, 255, 255, .08); }
.gr-tile__tag { opacity: .8; }
// 평소엔 사진만: 스크림은 opacity 0, 제목은 아래로 8px 밀려 있다
.gr-tile__scrim {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, .75), transparent 65%);
opacity: 0;
transition: opacity $duration $easing;
}
.gr-tile__label {
position: relative;
z-index: 1;
transform: translateY($sp-2);
transition: transform $duration $easing;
}
// 실사용: 진짜 마우스 호버
.gr-focal:hover .gr-tile__scrim { opacity: 1; }
.gr-focal:hover .gr-tile__label { transform: translateY(0); }
// 데모 루프: is-demo 는 같은 등장을 keyframes 로 무한 재생 (캡처용)
@keyframes gr-scrim-in { 0%, 100% { opacity: 0; } 50% { opacity: 1; } }
@keyframes gr-label-up { 0%, 100% { transform: translateY($sp-2); } 50% { transform: translateY(0); } }
.gr-grid.is-demo .gr-focal .gr-tile__scrim { animation: gr-scrim-in $duration $easing infinite; }
.gr-grid.is-demo .gr-focal .gr-tile__label { animation: gr-label-up $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.gr-focal:hover .gr-tile__scrim, .gr-focal:hover .gr-tile__label,
.gr-grid.is-demo .gr-focal .gr-tile__scrim, .gr-grid.is-demo .gr-focal .gr-tile__label {
animation: none !important; transition: none !important;
}
}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);
}
.gr-grid {
width: min(340px, 90vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
}
.gr-tile {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
box-sizing: border-box;
color: #fff7e6;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
position: relative;
overflow: hidden;
}
.gr-tile--a {
grid-row: 1/3;
background: linear-gradient(160deg, #2f6df6, rgba(255, 255, 255, 0.2));
}
.gr-tile--b, .gr-tile--c {
background: rgba(255, 255, 255, 0.08);
}
.gr-tile__tag {
opacity: 0.8;
}
.gr-tile__scrim {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent 65%);
opacity: 0;
transition: opacity 350ms ease-out;
}
.gr-tile__label {
position: relative;
z-index: 1;
transform: translateY(8px);
transition: transform 350ms ease-out;
}
.gr-focal:hover .gr-tile__scrim {
opacity: 1;
}
.gr-focal:hover .gr-tile__label {
transform: translateY(0);
}
@keyframes gr-scrim-in {
0%, 100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
@keyframes gr-label-up {
0%, 100% {
transform: translateY(8px);
}
50% {
transform: translateY(0);
}
}
.gr-grid.is-demo .gr-focal .gr-tile__scrim {
animation: gr-scrim-in 350ms ease-out infinite;
}
.gr-grid.is-demo .gr-focal .gr-tile__label {
animation: gr-label-up 350ms ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.gr-focal:hover .gr-tile__scrim, .gr-focal:hover .gr-tile__label,
.gr-grid.is-demo .gr-focal .gr-tile__scrim, .gr-grid.is-demo .gr-focal .gr-tile__label {
animation: none !important;
transition: none !important;
}
}import { CSSProperties } from "react";
import styles from "./GradientRevealTile.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface GradientRevealTileProps {
duration?: string;
easing?: string;
color?: string;
}
export default function GradientRevealTile({
duration = "350ms",
easing = "ease-out",
color = "#2f6df6",
}: GradientRevealTileProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.grid} style={style}>
<div className={`${styles.tile} ${styles.tileA} ${styles.focal}`}>
<span className={styles.scrim} />
<span className={styles.label}>City lights</span>
</div>
<div className={`${styles.tile} ${styles.tileB}`}>
<span className={styles.tag}>Trending</span>
</div>
<div className={`${styles.tile} ${styles.tileC}`}>
<span className={styles.tag}>New</span>
</div>
</div>
);
}05 · Gradient reveal tile — Measured area 13.982% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
Neither the photo nor the tile's own size is touched — just the two layers stacked on top of it.
.gr-tile__scrim {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, .75), transparent 65%);
opacity: 0;
transition: opacity 350ms $ease-out;
}
.gr-tile__label {
transform: translateY($sp-2);
transition: transform 350ms $ease-out;
}
.gr-focal:hover .gr-tile__scrim { opacity: 1; }
.gr-focal:hover .gr-tile__label { transform: translateY(0); }
06Stat count tile
What the user notices
A number tile counts up from 0 to its target the moment a cursor lands on it, with no JavaScript running the count — @property registers a custom property as an integer, and the digits shown actually come from a ::after pseudo-element reading counter(num), not from any text sitting in the tile itself.

<main class="stage">
<div class="sc-grid is-demo">
<div class="sc-tile sc-tile--a sc-focal">
<span class="sc-tile__tag">Conversion</span>
<span class="sc-stat"><span class="sc-stat__value"></span><span class="sc-stat__unit">%</span></span>
</div>
<div class="sc-tile sc-tile--b">
<span class="sc-tile__tag">Live</span>
</div>
<div class="sc-tile sc-tile--c">
<span class="sc-tile__tag">+9 today</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.2s;
$easing: $ease-out;
$target: 87;
@include stage($stage-yellow);
// 카운트업에 쓰는 커스텀 속성 — 정수 타입으로 등록해야 키프레임이 숫자를 보간한다
// 지원: Chromium·Safari 16.4+·Firefox 128+ (그 밖은 카운트업 없이 목표값이 바로 보인다)
@property --num {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
.sc-grid {
width: min(340px, 90vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: $sp-2;
}
.sc-tile {
border-radius: $r-sm;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: $sp-3;
box-sizing: border-box;
color: $subject-ink;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
background: $subject-cream;
}
.sc-tile--a { grid-row: 1 / 3; }
.sc-tile--b, .sc-tile--c { background: rgba(23, 20, 26, .08); }
.sc-tile__tag { opacity: .7; }
.sc-stat { margin-top: $sp-1; font-size: 30px; font-weight: 700; }
// 실제로 보이는 숫자는 ::after 의 counter() 다 — value 자신의 텍스트는 비워둔다
.sc-stat__value {
--num: 0;
}
.sc-stat__value::after {
counter-reset: num var(--num);
content: counter(num);
}
.sc-stat__unit { margin-left: $sp-1; font-size: 16px; opacity: .7; }
@keyframes sc-countup {
to { --num: #{$target}; }
}
// 실사용: 진짜 마우스 호버 — JS 없이 CSS 만으로 카운트업
.sc-focal:hover .sc-stat__value { animation: sc-countup $duration $easing forwards; }
// 데모 루프: is-demo 는 같은 카운트업을 forwards 로 (헤드리스 캡처는 currentTime 을 직접 놓아 0→목표를 그대로 읽는다)
.sc-grid.is-demo .sc-focal .sc-stat__value { animation: sc-countup $duration $easing forwards; }
@media (prefers-reduced-motion: reduce) {
.sc-focal:hover .sc-stat__value,
.sc-grid.is-demo .sc-focal .sc-stat__value { animation: none !important; --num: #{$target}; }
}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);
}
@property --num {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.sc-grid {
width: min(340px, 90vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
}
.sc-tile {
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
box-sizing: border-box;
color: #17141a;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
background: #fff7e6;
}
.sc-tile--a {
grid-row: 1/3;
}
.sc-tile--b, .sc-tile--c {
background: rgba(23, 20, 26, 0.08);
}
.sc-tile__tag {
opacity: 0.7;
}
.sc-stat {
margin-top: 4px;
font-size: 30px;
font-weight: 700;
}
.sc-stat__value {
--num: 0;
}
.sc-stat__value::after {
counter-reset: num var(--num);
content: counter(num);
}
.sc-stat__unit {
margin-left: 4px;
font-size: 16px;
opacity: 0.7;
}
@keyframes sc-countup {
to {
--num: 87;
}
}
.sc-focal:hover .sc-stat__value {
animation: sc-countup 1.2s ease-out forwards;
}
.sc-grid.is-demo .sc-focal .sc-stat__value {
animation: sc-countup 1.2s ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
.sc-focal:hover .sc-stat__value,
.sc-grid.is-demo .sc-focal .sc-stat__value {
animation: none !important;
--num: 87;
}
}import { CSSProperties } from "react";
import styles from "./StatCountTile.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--target"?: number;
};
export interface StatCountTileProps {
duration?: string;
easing?: string;
target?: number;
}
// 지원: Chromium·Safari 16.4+·Firefox 128+. 미지원 브라우저는 카운트업 없이 목표값이 바로 보인다
export default function StatCountTile({
duration = "1.2s",
easing = "ease-out",
target = 87,
}: StatCountTileProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--target": target,
};
return (
<div className={styles.grid} style={style}>
<div className={`${styles.tile} ${styles.tileA} ${styles.focal}`}>
<span className={styles.tag}>Conversion</span>
<span className={styles.stat}>
<span className={styles.value} />
<span className={styles.unit}>%</span>
</span>
</div>
<div className={`${styles.tile} ${styles.tileB}`}>
<span className={styles.tag}>Live</span>
</div>
<div className={`${styles.tile} ${styles.tileC}`}>
<span className={styles.tag}>+9 today</span>
</div>
</div>
);
}06 · Stat count tile — Measured area 0.54% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
That registration has to say inherits: true; writing it as inherits: false, the way most examples online do, measures out as a counter that never updates at all in this render pipeline.
@property --num {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
.sc-stat__value {
--num: 0;
}
.sc-stat__value::after {
counter-reset: num var(--num);
content: counter(num);
}
@keyframes sc-countup {
to { --num: 87; }
}
.sc-focal:hover .sc-stat__value {
animation: sc-countup 1.2s $ease-out forwards;
}
07Grid span expand on hover
Where this shows up
A tile looks like it grows to cover the cell next to it on hover, but the grid itself never actually changes — grid-column and grid-row span values are discrete, so a browser has no in-between frame to draw and can't tween them.

<main class="stage">
<div class="he-grid is-demo">
<div class="he-tile he-focal">
<span class="he-tile__tag">Live</span>
<span class="he-tile__num">128</span>
</div>
<div class="he-tile">
<span class="he-tile__tag">Users</span>
</div>
<div class="he-tile">
<span class="he-tile__tag">Sessions</span>
</div>
<div class="he-tile">
<span class="he-tile__tag">Errors</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$scale: 1.35;
@include stage($stage-orange);
.he-grid {
width: min(320px, 88vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: $sp-2;
}
.he-tile {
border-radius: $r-sm;
padding: $sp-3;
box-sizing: border-box;
color: $subject-cream;
background: rgba(23, 20, 26, .55);
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
display: flex;
flex-direction: column;
justify-content: flex-end;
position: relative;
transform-origin: top left; // 규칙8: 그리드 좌상단 칸 안쪽 확장
}
.he-focal { background: $subject-ink; }
.he-tile__tag { opacity: .8; }
.he-tile__num { font-size: 24px; font-weight: 700; margin-top: $sp-1; }
// grid-column/row 의 span 값 자체는 트랜지션이 걸리지 않는다(이산값이라 중간 프레임이 없다) —
// 그래서 실제 칸 배치는 그대로 두고 transform: scale + z-index 로 옆 칸을 덮어 "확장"을 흉내 낸다
.he-focal {
transition: transform $duration $easing, box-shadow $duration $easing;
box-shadow: $shadow-press;
}
.he-focal:hover {
transform: scale($scale);
box-shadow: $shadow-raised;
z-index: 5;
}
@keyframes he-expand {
0%, 100% { transform: scale(1); box-shadow: $shadow-press; z-index: 1; }
50% { transform: scale($scale); box-shadow: $shadow-raised; z-index: 5; }
}
.he-grid.is-demo .he-focal { animation: he-expand $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.he-focal:hover,
.he-grid.is-demo .he-focal { animation: none !important; transform: none !important; box-shadow: $shadow-press !important; z-index: 1 !important; }
}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);
}
.he-grid {
width: min(320px, 88vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 8px;
}
.he-tile {
border-radius: 8px;
padding: 12px;
box-sizing: border-box;
color: #fff7e6;
background: rgba(23, 20, 26, 0.55);
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
display: flex;
flex-direction: column;
justify-content: flex-end;
position: relative;
transform-origin: top left;
}
.he-focal {
background: #17141a;
}
.he-tile__tag {
opacity: 0.8;
}
.he-tile__num {
font-size: 24px;
font-weight: 700;
margin-top: 4px;
}
.he-focal {
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.he-focal:hover {
transform: scale(1.35);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 5;
}
@keyframes he-expand {
0%, 100% {
transform: scale(1);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
z-index: 1;
}
50% {
transform: scale(1.35);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 5;
}
}
.he-grid.is-demo .he-focal {
animation: he-expand 600ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.he-focal:hover,
.he-grid.is-demo .he-focal {
animation: none !important;
transform: none !important;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32) !important;
z-index: 1 !important;
}
}import { CSSProperties } from "react";
import styles from "./HoverExpandSpan.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--scale"?: number;
};
export interface HoverExpandSpanProps {
duration?: string;
easing?: string;
scale?: number;
}
// grid-column/row 의 span 값은 트랜지션이 걸리지 않아 transform: scale + z-index 로 확장을 흉내 낸다
export default function HoverExpandSpan({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
scale = 1.35,
}: HoverExpandSpanProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--scale": scale,
};
return (
<div className={styles.grid} style={style}>
<div className={`${styles.tile} ${styles.focal}`}>
<span className={styles.tag}>Live</span>
<span className={styles.num}>128</span>
</div>
<div className={styles.tile}>
<span className={styles.tag}>Users</span>
</div>
<div className={styles.tile}>
<span className={styles.tag}>Sessions</span>
</div>
<div className={styles.tile}>
<span className={styles.tag}>Errors</span>
</div>
</div>
);
}07 · Grid span expand on hover — Measured area 13.703% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
The illusion comes entirely from transform: scale(1.35) paired with a raised z-index, which lets the enlarged tile visually overlap its neighbor without moving a single grid line.
.he-focal {
transition: transform $duration $easing, box-shadow $duration $easing;
box-shadow: $shadow-press;
}
.he-focal:hover {
transform: scale(1.35);
box-shadow: $shadow-raised;
z-index: 5;
}
08Tiles reveal via stagger
What happens on screen
As the grid scrolls into view, a real IntersectionObserver adds an .in class to each tile the moment it crosses the viewport, and every tile shares the exact same opacity/transform transition — only transition-delay differs per tile, so they rise into place one after another instead of all at once.

<main class="stage">
<div class="ss-grid is-demo">
<div class="ss-tile"><span class="ss-tile__tag">Design</span></div>
<div class="ss-tile"><span class="ss-tile__tag">Build</span></div>
<div class="ss-tile"><span class="ss-tile__tag">Ship</span></div>
<div class="ss-tile"><span class="ss-tile__tag">Scale</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$stagger: 160ms;
@include stage($stage-paper);
.ss-grid {
width: min(320px, 88vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: $sp-2;
}
.ss-tile {
border-radius: $r-sm;
padding: $sp-3;
box-sizing: border-box;
color: $subject-ink;
background: rgba(23, 20, 26, .06);
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
display: flex;
align-items: flex-end;
// 진짜 등장 전 상태 — transition 경로와 keyframes 경로가 같은 시작점을 공유한다
opacity: 0;
transform: translateY($sp-4);
}
.ss-tile__tag { opacity: .8; }
// 실사용: 진짜 IntersectionObserver 가 뷰포트에 들어올 때 .in 을 붙인다(위 <script>). 타일마다 delay 만 다르다
.ss-tile {
transition: opacity 400ms $easing, transform 400ms $easing;
}
.ss-tile.in { opacity: 1; transform: translateY(0); }
.ss-tile:nth-child(1).in { transition-delay: 0ms; }
.ss-tile:nth-child(2).in { transition-delay: $stagger; }
.ss-tile:nth-child(3).in { transition-delay: $stagger * 2; }
.ss-tile:nth-child(4).in { transition-delay: $stagger * 3; }
// 데모 루프: is-demo 는 transition 경로를 쓰지 않는다 — scroll-timeline·view() 대신 keyframes 로 같은 순차 등장을 무한 재생한다(캡처용)
@keyframes ss-tile-in {
0% { opacity: 0; transform: translateY($sp-4); }
30% { opacity: 1; transform: translateY(0); }
100% { opacity: 1; transform: translateY(0); }
}
.ss-grid.is-demo .ss-tile {
animation: ss-tile-in $duration $easing infinite;
animation-fill-mode: both;
}
.ss-grid.is-demo .ss-tile:nth-child(1) { animation-delay: 0ms; }
.ss-grid.is-demo .ss-tile:nth-child(2) { animation-delay: $stagger; }
.ss-grid.is-demo .ss-tile:nth-child(3) { animation-delay: $stagger * 2; }
.ss-grid.is-demo .ss-tile:nth-child(4) { animation-delay: $stagger * 3; }
@media (prefers-reduced-motion: reduce) {
.ss-tile, .ss-tile.in,
.ss-grid.is-demo .ss-tile {
animation: none !important; transition: none !important; opacity: 1 !important; transform: none !important;
}
}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);
}
.ss-grid {
width: min(320px, 88vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 8px;
}
.ss-tile {
border-radius: 8px;
padding: 12px;
box-sizing: border-box;
color: #17141a;
background: rgba(23, 20, 26, 0.06);
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
display: flex;
align-items: flex-end;
opacity: 0;
transform: translateY(16px);
}
.ss-tile__tag {
opacity: 0.8;
}
.ss-tile {
transition: opacity 400ms ease-out, transform 400ms ease-out;
}
.ss-tile.in {
opacity: 1;
transform: translateY(0);
}
.ss-tile:nth-child(1).in {
transition-delay: 0ms;
}
.ss-tile:nth-child(2).in {
transition-delay: 160ms;
}
.ss-tile:nth-child(3).in {
transition-delay: 320ms;
}
.ss-tile:nth-child(4).in {
transition-delay: 480ms;
}
@keyframes ss-tile-in {
0% {
opacity: 0;
transform: translateY(16px);
}
30% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.ss-grid.is-demo .ss-tile {
animation: ss-tile-in 2s ease-out infinite;
animation-fill-mode: both;
}
.ss-grid.is-demo .ss-tile:nth-child(1) {
animation-delay: 0ms;
}
.ss-grid.is-demo .ss-tile:nth-child(2) {
animation-delay: 160ms;
}
.ss-grid.is-demo .ss-tile:nth-child(3) {
animation-delay: 320ms;
}
.ss-grid.is-demo .ss-tile:nth-child(4) {
animation-delay: 480ms;
}
@media (prefers-reduced-motion: reduce) {
.ss-tile, .ss-tile.in,
.ss-grid.is-demo .ss-tile {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
}var tiles = document.querySelectorAll('.ss-tile');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
}, { threshold: 0.3 });
tiles.forEach(function (t) { io.observe(t); });import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./ScrollStaggerTiles.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--stagger"?: string;
};
export interface ScrollStaggerTilesProps {
duration?: string;
easing?: string;
stagger?: string;
}
const LABELS = ["Design", "Build", "Ship", "Scale"];
// 실사용: 진짜 IntersectionObserver 가 그리드 전체가 뷰포트에 들어올 때 in 을 켠다. 타일마다 transition-delay 만 다르다.
// scroll-timeline / view() 는 쓰지 않는다 — 브라우저마다 렌더가 갈린다
export default function ScrollStaggerTiles({
duration = "2s",
easing = "ease-out",
stagger = "160ms",
}: ScrollStaggerTilesProps) {
const gridRef = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = gridRef.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setInView(true); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(el);
return () => io.disconnect();
}, []);
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--stagger": stagger,
};
return (
<div className={styles.grid} style={style} ref={gridRef}>
{LABELS.map((label, i) => (
<div
key={label}
className={`${styles.tile} ${inView ? styles.in : ""}`}
style={{ transitionDelay: inView ? `calc(${stagger} * ${i})` : undefined }}
>
<span className={styles.tag}>{label}</span>
</div>
))}
</div>
);
}08 · Tiles reveal via scroll stagger — Measured area 36.644% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
This runs on IntersectionObserver rather than the newer animation-timeline: view(), since scroll-driven CSS animations still render inconsistently enough across browsers to skip for now.
var tiles = document.querySelectorAll('.ss-tile');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) {
e.target.classList.add('in');
io.unobserve(e.target);
}
});
}, { threshold: 0.3 });
tiles.forEach(function (t) { io.observe(t); });
.ss-tile {
transition: opacity 400ms $ease-out, transform 400ms $ease-out;
}
.ss-tile.in { opacity: 1; transform: translateY(0); }
.ss-tile:nth-child(2).in { transition-delay: 160ms; }
.ss-tile:nth-child(3).in { transition-delay: 320ms; }
.ss-tile:nth-child(4).in { transition-delay: 480ms; }
09Neighboring tile glow
What it looks like
Moving the cursor anywhere over the grid writes its position into --mx and --my, two custom properties shared by every tile through CSS inheritance, so a radial-gradient glow can read the same coordinate on all of them.

<main class="stage">
<div class="sl-grid is-demo">
<div class="sl-tile"><span class="sl-tile__tag">Product</span></div>
<div class="sl-tile"><span class="sl-tile__tag">Design</span></div>
<div class="sl-tile"><span class="sl-tile__tag">Growth</span></div>
<div class="sl-tile"><span class="sl-tile__tag">Support</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue;
@include stage($stage-ink);
// 커서 좌표 — 퍼센트 타입으로 등록해야 is-demo 의 키프레임이 값을 보간한다. 그리드 전체에 상속돼 타일마다 같은 지점을 읽는다
@property --mx { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --my { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
.sl-grid {
width: min(320px, 88vw);
aspect-ratio: 340 / 190;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: $sp-2;
--mx: 50%;
--my: 50%;
}
.sl-tile {
border-radius: $r-sm;
padding: $sp-3;
box-sizing: border-box;
color: $subject-cream;
background: $subject-ink;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
position: relative;
overflow: hidden;
display: flex;
align-items: flex-end;
}
.sl-tile__tag { position: relative; z-index: 1; opacity: .85; }
// 글로우 레이어 — 좌표는 공유하지만 opacity 는 :has() 로 "그리드 안 어딘가 호버 중"일 때만 켠다
.sl-tile::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(140px circle at var(--mx) var(--my), rgba($color, .6), transparent 70%);
opacity: 0;
transition: opacity 200ms $easing;
pointer-events: none;
}
// 실사용: 진짜 pointermove 가 --mx·--my 를 갱신한다(위 <script>). 호버 중인 타일이 그리드 안에 있으면 전부 켠다
.sl-grid:has(.sl-tile:hover) .sl-tile::before { opacity: 1; }
// 데모 루프: is-demo 는 :has(:hover) 를 못 거니 opacity 를 직접 켜고, --mx·--my 는 keyframes 로 원을 그리며 돈다(캡처용)
@keyframes sl-drift {
0% { --mx: 10%; --my: 15%; }
25% { --mx: 90%; --my: 10%; }
50% { --mx: 85%; --my: 90%; }
75% { --mx: 10%; --my: 85%; }
100% { --mx: 10%; --my: 15%; }
}
.sl-grid.is-demo { animation: sl-drift $duration $easing infinite; }
.sl-grid.is-demo .sl-tile::before { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.sl-grid.is-demo { animation: none !important; }
.sl-tile::before { transition: none !important; }
}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);
}
@property --mx {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --my {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.sl-grid {
width: min(320px, 88vw);
aspect-ratio: 340/190;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 8px;
--mx: 50%;
--my: 50%;
}
.sl-tile {
border-radius: 8px;
padding: 12px;
box-sizing: border-box;
color: #fff7e6;
background: #17141a;
font: 600 12px/1 -apple-system, "Segoe UI", sans-serif;
position: relative;
overflow: hidden;
display: flex;
align-items: flex-end;
}
.sl-tile__tag {
position: relative;
z-index: 1;
opacity: 0.85;
}
.sl-tile::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(140px circle at var(--mx) var(--my), rgba(47, 109, 246, 0.6), transparent 70%);
opacity: 0;
transition: opacity 200ms linear;
pointer-events: none;
}
.sl-grid:has(.sl-tile:hover) .sl-tile::before {
opacity: 1;
}
@keyframes sl-drift {
0% {
--mx: 10%;
--my: 15%;
}
25% {
--mx: 90%;
--my: 10%;
}
50% {
--mx: 85%;
--my: 90%;
}
75% {
--mx: 10%;
--my: 85%;
}
100% {
--mx: 10%;
--my: 15%;
}
}
.sl-grid.is-demo {
animation: sl-drift 2s linear infinite;
}
.sl-grid.is-demo .sl-tile::before {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.sl-grid.is-demo {
animation: none !important;
}
.sl-tile::before {
transition: none !important;
}
}var grid = document.querySelector('.sl-grid');
grid.addEventListener('pointermove', function (e) {
var r = grid.getBoundingClientRect();
var x = ((e.clientX - r.left) / r.width * 100).toFixed(1) + '%';
var y = ((e.clientY - r.top) / r.height * 100).toFixed(1) + '%';
grid.style.setProperty('--mx', x);
grid.style.setProperty('--my', y);
});import { CSSProperties, PointerEvent, useCallback, useRef } from "react";
import styles from "./SpotlightNeighbors.module.scss";
type Vars = CSSProperties & { "--color"?: string; "--mx"?: string; "--my"?: string };
export interface SpotlightNeighborsProps {
color?: string;
}
const LABELS = ["Product", "Design", "Growth", "Support"];
// 실사용: 진짜 pointermove 로 --mx·--my 를 갱신한다. 켜고 끄는 건 CSS :has() 가 한다
export default function SpotlightNeighbors({ color = "#2f6df6" }: SpotlightNeighborsProps) {
const gridRef = useRef<HTMLDivElement>(null);
const handleMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
const el = gridRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const x = (((e.clientX - r.left) / r.width) * 100).toFixed(1) + "%";
const y = (((e.clientY - r.top) / r.height) * 100).toFixed(1) + "%";
el.style.setProperty("--mx", x);
el.style.setProperty("--my", y);
}, []);
const style: Vars = { "--color": color };
return (
<div className={styles.grid} style={style} ref={gridRef} onPointerMove={handleMove}>
{LABELS.map((label) => (
<div key={label} className={styles.tile}>
<span className={styles.tag}>{label}</span>
</div>
))}
</div>
);
}09 · Neighboring tile glow — Measured area 36.649% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
A :has() selector checks whether any tile in the grid is currently hovered and, if so, switches every tile's glow on together — which is why the light spreads into tiles the cursor was never actually over.
@property --mx { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --my { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
.sl-tile::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(140px circle at var(--mx) var(--my), rgba($color, .6), transparent 70%);
opacity: 0;
transition: opacity 200ms $easing;
}
.sl-grid:has(.sl-tile:hover) .sl-tile::before { opacity: 1; }
grid.addEventListener('pointermove', function (e) {
var r = grid.getBoundingClientRect();
var x = ((e.clientX - r.left) / r.width * 100).toFixed(1) + '%';
var y = ((e.clientY - r.top) / r.height * 100).toFixed(1) + '%';
grid.style.setProperty('--mx', x);
grid.style.setProperty('--my', y);
});
Where does grid-column span break on hover?
What goes wrong
The one place in a bento grid layout css build most likely to trip someone up is item 07. grid-column: span 2 looks like any other CSS value, so it's tempting to add a :hover rule that changes the span and expect a smooth expansion — but span values are discrete integers, not a range a browser can interpolate between, so the instant a hover rule flips a 1 into a 2, the layout snaps rather than animating. That's exactly why item 07 never touches grid-column or grid-row on hover at all: the two-column, two-row layout stays fixed the whole time, and transform: scale(1.35) with a raised z-index is what makes the tile look like it's claiming the cell beside it.
How to avoid it
One tile growing while its neighbors get pushed aside is settled at the grid level in the bento grid landing sections, where the layout rules come first.
Item 08 runs into a related decision — CSS now has animation-timeline: view(), a way to drive an animation purely off scroll position with no JavaScript at all, but it still renders differently enough across browsers that this whole scroll-timeline family gets skipped here on principle, which is why item 08's staggered entrance runs through a plain IntersectionObserver and transition-delay instead. The full SCSS and React source for all nine tiles, the exact IntersectionObserver and pointermove scripts included, sits behind one zip that opens with 6s9wc2fb, typed exactly as it appears in this sentence.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Bigger lift | Item 01's scale(1.04) raised to scale(1.08) |
Reads like a firmer press instead of a slow breath |
| Gentler overlap | Item 07's scale(1.35) lowered to scale(1.15) |
The tile nudges over its neighbor instead of fully covering it |
| Wider glow | Item 09's 140px circle widened to 220px circle |
The light reaches two neighbors at once instead of staying close to the cursor |
Accessibility
What reduced motion leaves
All nine turn their animation off under prefers-reduced-motion: reduce, but what's left on screen differs depending on how much the motion was carrying the message. 01, 02, 03, 05, 07, and 09 just hold their resting look — a flat tile, a hidden ring, an unzoomed photo — since nothing about their content depended on movement to be understood. 06 and 08 are different: 06 jumps --num straight to its target value instead of freezing at 0, and 08 sets every tile's opacity and transform to their finished state right away, because a stat that never counts up or a tile that never appears would hide real content, not just skip an effect.
What else to check
Two features here also depend on the browser: @property, which items 06 and 09 both need to animate a custom property smoothly, reaches Chrome/Edge 85+ and Safari 16.4+ but only arrived in Firefox 128; :has(), which item 09 uses to detect a hover anywhere in the grid, needs Chrome 105+, Safari 15.4+, or Firefox 121+. Older browsers just show the static end state instead of erroring out. More layout tricks for the same kind of grid live in Trend-Driven, Ready-to-Copy Bento Grid Design, and the rest of what this site has published sits in the CSS category hub.
FAQ
Do I need JavaScript to build a bento grid layout?
No — display: grid with grid-template-columns and grid-template-rows is all a bento layout needs. Every hover effect in this set sits on top of that grid and is mostly pure CSS, with real JavaScript running behind only items 08 and 09.
Why don't css grid hover effects transition grid-column smoothly?
Span values like grid-column: span 2 are discrete, not a range a browser can tween between, so any transition on them jumps instead of animating. Item 07 works around this with transform: scale and z-index instead of touching the grid at all.
Does hover even work on a touchscreen?
Not reliably — many touch browsers either skip :hover entirely or toggle it on with the first tap and never release it. Purely decorative motion like 01, 03, and 05 loses nothing important without it, but a number that matters, like item 06's counter, is worth triggering off scrolling into view instead of off hover on a touch layout. Filling those tiles with a body of work, complete with sorting and zoom, continues in the portfolio gallery UI patterns.









