벤토 그리드 레이아웃 호버 애니메이션 9종
포트폴리오나 제품 소개 페이지에서 크고 작은 네모 칸이 사진 콜라주처럼 이어 붙은 레이아웃을 본 적 있으신가요? 이런 벤토 그리드 레이아웃에 마우스를 올렸을 때 칸마다 살짝 반응하게 만드는 CSS 코드를 아홉 가지로 모았습니다. 전부 한 줄 복붙으로 붙여 쓸 수 있게 정리했습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 숨쉬듯 확대+그림자
- 02 보더 빔 타일
- 03 이미지 타일 줌
- 04 아이콘 플로트 타일
- 05 그라디언트 리빌 타일
- 06 통계 카운트 타일
- 07 호버 시 grid span 확장
- 08 스크롤 순차 등장 타일
- 09 인접 타일 글로우
아홉 개는 인기 순이 아니라 사용자가 그리드를 눈으로 훑는 순서를 따라갑니다. 먼저 칸 전체가 반응한다는 걸 보여주는 가벼운 신호(01~02)를 지나, 안에 든 사진과 아이콘이 반응하는 콘텐츠 단위 움직임(03~05)으로, 그다음은 숫자 같은 데이터가 바뀌는 순간(06)과 레이아웃 자체를 손대는 듯 보이는 과감한 시도(07)를 거쳐, 마지막은 화면에 들어오는 순간(08)과 그리드 전체를 아우르는 조명 효과(09)로 끝납니다.
01숨쉬듯 확대+그림자
마우스를 올리면 타일 전체가 아주 살짝 커지면서 그림자가 깊어져, 마치 숨을 쉬듯 반응합니다. 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보더 빔 타일
강조하고 싶은 칸에 마우스를 올리면 테두리를 따라 얇은 빛줄기가 한 바퀴 돕니다. 링 모양은 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이미지 타일 줌
썸네일이 든 칸에 마우스를 올리면 테두리는 그대로인 채 안쪽 사진 레이어만 확대됩니다. 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아이콘 플로트 타일
기능을 소개하는 칸 안의 작은 아이콘이 평소엔 가만히 있다가, 마우스를 올려야 위아래로 두둥실 떠오르기 시작합니다. 호버하지 않는 나머지 시간에는 완전히 정지 상태라 눈이 산만해지지 않습니다.
.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그라디언트 리빌 타일
평소엔 사진만 보이던 칸에 마우스를 올리면 아래쪽 그라디언트 막이 서서히 짙어지며 제목이 살짝 위로 올라옵니다. 스크림은 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부터 목표값까지 숫자가 올라갑니다. 자바스크립트 없이 @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 값이 바뀌는 건 아닙니다. 그 값 자체는 트랜지션이 걸리지 않는 이산값이라, 대신 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스크롤 순차 등장 타일
그리드가 화면에 들어오면 타일이 하나씩 차례로 떠오르며 나타납니다. 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인접 타일 글로우
그리드 위에서 마우스를 움직이면 지금 가리킨 칸뿐 아니라 이웃 칸까지 은은한 빛이 함께 번집니다. 좌표를 --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);
});
벤토 그리드 레이아웃은 어디서 깨지는가
아홉 개 중 가장 자주 걸려 넘어지는 지점은 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에서 남기는 것
시스템에서 동작 줄이기를 켜면 아홉 개 전부 prefers-reduced-motion: reduce에서 animation: none !important로 움직임을 끕니다. 01·02·03·05·07·09처럼 장식적인 반응은 그냥 멈춘 상태로 보이면 충분하지만, 06의 카운트업과 08의 순차 등장은 정보 자체가 움직임으로 전달되므로 애니메이션을 끄는 대신 --num과 opacity·transform을 곧바로 최종값으로 고정해, 움직이지 않아도 숫자와 콘텐츠가 처음부터 다 보이게 했습니다.
개별 타일이 아니라 카드 하나하나가 클릭에 반응하는 패턴을 찾는다면 CSS 흔들리는 애니메이션 9종 글도 참고할 만하고, 전체 목록은 CSS 애니메이션 카테고리에서 모아 볼 수 있습니다.
FAQ — 자주 묻는 질문
벤토 그리드에 꼭 그리드가 필요한가요?
네, display: grid와 grid-template-columns로 칸 크기를 다르게 배치하는 게 벤토 그리드의 핵심입니다. 이 글의 아홉 데모는 호버 반응만 보여주기 위해 3~4칸짜리 미니 그리드를 썼지만, 실제 페이지에서는 칸 수와 span 값만 늘리면 됩니다.
마우스가 없는 터치 기기에서는 호버가 안 되지 않나요?
맞습니다. 터치 기기는 :hover가 아예 안 걸리거나 탭 한 번으로 걸렸다 안 풀리는 경우가 많아, 01·03·05처럼 시각적으로만 화려한 반응은 터치에서 생략되어도 정보 손실이 없게 설계했고, 06처럼 숫자가 중요한 항목은 화면에 들어오는 시점(뷰포트 진입)으로 트리거를 바꿔 쓰는 걸 권합니다.
zip 안의 vanilla와 react 코드가 다른가요?
동작은 같습니다. vanilla는 SCSS 변수를 파일 맨 위에 두고, React 판은 같은 값을 컴포넌트 props로 받아 CSS 커스텀 속성으로 넘기는 구조라 결과 화면은 동일합니다.