벤토 그리드 레이아웃 호버 애니메이션 9종
포트폴리오나 제품 소개 페이지에서 크고 작은 네모 칸이 사진 콜라주처럼 이어 붙은 레이아웃을 본 적 있으신가요? 이런 벤토 그리드 레이아웃에 마우스를 올렸을 때 칸마다 살짝 반응하게 만드는 CSS 코드를 아홉 가지로 모았습니다. 전부 한 줄 복붙으로 붙여 쓸 수 있게 정리했습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 숨쉬듯 확대+그림자
- 02 보더 빔 타일
- 03 이미지 타일 줌
- 04 아이콘 플로트 타일
- 05 그라디언트 리빌 타일
- 06 통계 카운트 타일
- 07 호버 시 grid span 확장
- 08 스크롤 순차 등장 타일
- 09 인접 타일 글로우
아홉 개는 인기 순이 아니라 사용자가 그리드를 눈으로 훑는 순서를 따라갑니다. 먼저 칸 전체가 반응한다는 걸 보여주는 가벼운 신호(01~02)를 지나, 안에 든 사진과 아이콘이 반응하는 콘텐츠 단위 움직임(03~05)으로, 그다음은 숫자 같은 데이터가 바뀌는 순간(06)과 레이아웃 자체를 손대는 듯 보이는 과감한 시도(07)를 거쳐, 마지막은 화면에 들어오는 순간(08)과 그리드 전체를 아우르는 조명 효과(09)로 끝납니다. 칸마다 빛을 얹어 반응을 키우려면 CSS 글로우 효과가 그 재료를 주고, 칸이 움직이는 속도감 자체를 손보고 싶으면 이징 커브와 스프링이 곡선 목록입니다.
01숨쉬듯 확대+그림자
벤토 그리드 디자인에서 어디에 쓰나
마우스를 올리면 타일 전체가 아주 살짝 커지면서 그림자가 깊어져, 마치 숨을 쉬듯 반응합니다.

<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 · 숨쉬듯 확대+그림자 — 측정 누적면적 2.481% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 이렇게 만들었나
transform: scale(1.04)와 box-shadow만 함께 트랜지션해 레이아웃은 전혀 흔들리지 않습니다.
.bs-focal {
transition: transform 300ms $ease-spring, box-shadow 300ms $ease-spring;
box-shadow: $shadow-press;
}
.bs-focal:hover {
transform: scale(1.04);
box-shadow: $shadow-raised;
}
02보더 빔 타일
어떤 화면인가
강조하고 싶은 칸에 마우스를 올리면 테두리를 따라 얇은 빛줄기가 한 바퀴 돕니다.

<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 · 보더 빔 타일 — 측정 누적면적 5.527% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구현의 핵심
링 모양은 conic-gradient를 mask-composite: exclude로 도려내 만들고, 회전은 그 레이어의 transform만 돌립니다.
.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;
}
03이미지 타일 줌
무엇이 움직이나
썸네일이 든 칸에 마우스를 올리면 테두리는 그대로인 채 안쪽 사진 레이어만 확대됩니다.

<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 · 이미지 타일 줌 — 측정 누적면적 2.692% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
무엇이 핵심인가
overflow: hidden으로 액자를 고정해 두고 그 안의 미디어 레이어만 transform: scale로 움직이는 방식입니다.
.iz-tile--a { overflow: hidden; }
.iz-tile__media {
position: absolute;
inset: 0;
transition: transform 600ms linear;
}
.iz-focal:hover .iz-tile__media {
transform: scale(1.15);
}
04아이콘 플로트 타일
어디에 쓰나
기능을 소개하는 칸 안의 작은 아이콘이 평소엔 가만히 있다가, 마우스를 올려야 위아래로 두둥실 떠오르기 시작합니다.
<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 · 아이콘 플로트 타일 — 측정 누적면적 0.461% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
짚어 둘 점
호버하지 않는 나머지 시간에는 완전히 정지 상태라 눈이 산만해지지 않습니다.
.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); }
}
05그라디언트 리빌 타일
눈에 보이는 것
평소엔 사진만 보이던 칸에 마우스를 올리면 아래쪽 그라디언트 막이 서서히 짙어지며 제목이 살짝 위로 올라옵니다.

<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 · 그라디언트 리빌 타일 — 측정 누적면적 13.982% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떻게 만들었나
스크림은 opacity, 제목은 translateY만 움직여 사진 자체는 전혀 손대지 않습니다.
.gr-tile__scrim {
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); }
06통계 카운트 타일
어떤 상황인가
숫자로 된 성과를 보여주는 칸에 마우스를 올리면 0부터 목표값까지 숫자가 올라갑니다.

<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 · 통계 카운트 타일 — 측정 누적면적 0.54% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
자바스크립트 없이 @property로 정수 타입 커스텀 속성을 등록해 키프레임이 그 숫자를 직접 보간하는 방식입니다.
@property --num {
syntax: '<integer>';
inherits: true;
initial-value: 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;
}
07호버 시 grid span 확장
무슨 일이 일어나나
한 칸에 마우스를 올리면 옆 칸까지 덮으며 커지는 것처럼 보이지만, 실제로 grid-column이나 grid-row의 span 값이 바뀌는 건 아닙니다.

<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 확장 — 측정 누적면적 13.703% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드의 요점
그 값 자체는 트랜지션이 걸리지 않는 이산값이라, 대신 transform: scale과 z-index로 옆 칸 위에 포개는 방식으로 확장을 흉내 냅니다.
.he-focal {
transition: transform 600ms $ease-spring, box-shadow 600ms $ease-spring;
box-shadow: $shadow-press;
}
.he-focal:hover {
transform: scale(1.35);
box-shadow: $shadow-raised;
z-index: 5;
}
08스크롤 순차 등장 타일
화면에서 벌어지는 일
그리드가 화면에 들어오면 타일이 하나씩 차례로 떠오르며 나타납니다.

<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 · 스크롤 순차 등장 타일 — 측정 누적면적 36.644% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
동작 원리
IntersectionObserver가 타일마다 .in 클래스를 붙이고, 각 타일은 transition-delay만 다르게 둬서 같은 움직임이 시간차를 두고 반복됩니다.
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; }
09인접 타일 글로우
어떻게 보이나
그리드 위에서 마우스를 움직이면 지금 가리킨 칸뿐 아니라 이웃 칸까지 은은한 빛이 함께 번집니다.

<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 · 인접 타일 글로우 — 측정 누적면적 36.649% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
만드는 방식
좌표를 --mx·--my 커스텀 속성에 저장해 모든 타일이 공유하고, :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;
}
.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) + '%';
var y = ((e.clientY - r.top) / r.height * 100) + '%';
grid.style.setProperty('--mx', x);
grid.style.setProperty('--my', y);
});
벤토 그리드 레이아웃은 어디서 깨지는가
무엇이 문제인가
벤토 그리드 ui 를 만들며 아홉 개 중 가장 자주 걸려 넘어진 지점은 07번입니다. grid-column: span 2처럼 칸 크기 자체를 호버로 바꾸고 싶어질 때가 많은데, grid의 span 값은 이산값이라 브라우저가 중간 프레임을 만들 수 없어 트랜지션 자체가 걸리지 않습니다 — 켜졌다 꺼지듯 순간 이동만 합니다. 그래서 실제 칸 배치는 그대로 두고 transform: scale과 z-index로 옆 칸 위에 포개는 방식을 씁니다.
어떻게 피하나
칸 하나가 커질 때 옆 칸이 밀려나는 이 문제는 배치 자체를 설계하는 벤토 그리드 랜딩 부품에서 격자 규칙으로 먼저 풀어 둡니다.
09번의 :has() 셀렉터와 06번의 @property 카운터도 비교적 최근에 들어온 기능이라 Chrome·Edge·최신 Safari·Firefox 기준으로는 잘 동작하지만, 오래된 브라우저에서는 그저 정지된 최종 상태만 보입니다. 08번은 animation-timeline: view()라는, 스크롤 위치만으로 애니메이션을 구동하는 네이티브 CSS 기능도 있지만 브라우저마다 지원과 렌더링 결과가 갈려 이 데모에서는 쓰지 않았고, 대신 어디서나 동작하는 IntersectionObserver 방식을 실제 코드로 넣었습니다. zip 압축을 풀 때 필요한 비밀번호는 6s9wc2fb 자리에 본문 속 평범한 글자로 남겨 뒀으니 이 문단을 천천히 읽으면 찾을 수 있습니다.
접근성 — 아홉 개가 reduced-motion에서 남기는 것
reduced-motion 에서 남는 것
시스템에서 동작 줄이기를 켜면 아홉 개 전부 prefers-reduced-motion: reduce에서 animation: none !important로 움직임을 끕니다. 01·02·03·05·07·09처럼 장식적인 반응은 그냥 멈춘 상태로 보이면 충분하지만, 06의 카운트업과 08의 순차 등장은 정보 자체가 움직임으로 전달되므로 애니메이션을 끄는 대신 --num과 opacity·transform을 곧바로 최종값으로 고정해, 움직이지 않아도 숫자와 콘텐츠가 처음부터 다 보이게 했습니다.
이어서 볼 글
개별 타일이 아니라 카드 하나하나가 클릭에 반응하는 패턴을 찾는다면 CSS 흔들리는 애니메이션 9종 글도 참고할 만하고, 전체 목록은 CSS 애니메이션 카테고리에서 모아 볼 수 있습니다.
FAQ — 자주 묻는 질문
벤토 그리드 스타일에 꼭 CSS 그리드가 필요한가요?
네, display: grid와 grid-template-columns로 칸 크기를 다르게 배치하는 게 벤토 그리드의 핵심입니다. 이 글의 아홉 데모는 호버 반응만 보여주기 위해 3~4칸짜리 미니 그리드를 썼지만, 실제 페이지에서는 칸 수와 span 값만 늘리면 됩니다.
마우스가 없는 터치 기기에서는 호버가 안 되지 않나요?
맞습니다. 터치 기기는 :hover가 아예 안 걸리거나 탭 한 번으로 걸렸다 안 풀리는 경우가 많아, 01·03·05처럼 시각적으로만 화려한 반응은 터치에서 생략되어도 정보 손실이 없게 설계했고, 06처럼 숫자가 중요한 항목은 화면에 들어오는 시점(뷰포트 진입)으로 트리거를 바꿔 쓰는 걸 권합니다.
zip 안의 vanilla와 react 코드가 다른가요?
동작은 같습니다. vanilla는 SCSS 변수를 파일 맨 위에 두고, React 판은 같은 값을 컴포넌트 props로 받아 CSS 커스텀 속성으로 넘기는 구조라 결과 화면은 동일합니다. 칸을 작품 목록으로 채워 정렬과 확대까지 붙일 계획이면 포트폴리오 갤러리 UI가 다음 단계입니다.









