GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

벤토 그리드 레이아웃 호버 애니메이션 9종

포트폴리오나 제품 소개 페이지에서 크고 작은 네모 칸이 사진 콜라주처럼 이어 붙은 레이아웃을 본 적 있으신가요? 이런 벤토 그리드 레이아웃에 마우스를 올렸을 때 칸마다 살짝 반응하게 만드는 CSS 코드를 아홉 가지로 모았습니다. 전부 한 줄 복붙으로 붙여 쓸 수 있게 정리했습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 인기 순이 아니라 사용자가 그리드를 눈으로 훑는 순서를 따라갑니다. 먼저 칸 전체가 반응한다는 걸 보여주는 가벼운 신호(01~02)를 지나, 안에 든 사진과 아이콘이 반응하는 콘텐츠 단위 움직임(03~05)으로, 그다음은 숫자 같은 데이터가 바뀌는 순간(06)과 레이아웃 자체를 손대는 듯 보이는 과감한 시도(07)를 거쳐, 마지막은 화면에 들어오는 순간(08)과 그리드 전체를 아우르는 조명 효과(09)로 끝납니다. 칸마다 빛을 얹어 반응을 키우려면 CSS 글로우 효과가 그 재료를 주고, 칸이 움직이는 속도감 자체를 손보고 싶으면 이징 커브와 스프링이 곡선 목록입니다.

01숨쉬듯 확대+그림자

벤토 그리드 디자인에서 어디에 쓰나

마우스를 올리면 타일 전체가 아주 살짝 커지면서 그림자가 깊어져, 마치 숨을 쉬듯 반응합니다.

숨쉬듯 확대+그림자
새 창에서 열기

01 · 숨쉬듯 확대+그림자 — 측정 누적면적 2.481% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scalebox-shadow300ms

왜 이렇게 만들었나

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보더 빔 타일

어떤 화면인가

강조하고 싶은 칸에 마우스를 올리면 테두리를 따라 얇은 빛줄기가 한 바퀴 돕니다.

보더 빔 타일
새 창에서 열기

02 · 보더 빔 타일 — 측정 누적면적 5.527% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

conic-gradientmask1.6s

구현의 핵심

링 모양은 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이미지 타일 줌

무엇이 움직이나

썸네일이 든 칸에 마우스를 올리면 테두리는 그대로인 채 안쪽 사진 레이어만 확대됩니다.

이미지 타일 줌
새 창에서 열기

03 · 이미지 타일 줌 — 측정 누적면적 2.692% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scaleoverflow: hidden400ms

무엇이 핵심인가

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아이콘 플로트 타일

어디에 쓰나

기능을 소개하는 칸 안의 작은 아이콘이 평소엔 가만히 있다가, 마우스를 올려야 위아래로 두둥실 떠오르기 시작합니다.

아이콘 플로트 타일
새 창에서 열기

04 · 아이콘 플로트 타일 — 측정 누적면적 0.461% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateY@keyframes1.4s

짚어 둘 점

호버하지 않는 나머지 시간에는 완전히 정지 상태라 눈이 산만해지지 않습니다.

.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그라디언트 리빌 타일

눈에 보이는 것

평소엔 사진만 보이던 칸에 마우스를 올리면 아래쪽 그라디언트 막이 서서히 짙어지며 제목이 살짝 위로 올라옵니다.

그라디언트 리빌 타일
새 창에서 열기

05 · 그라디언트 리빌 타일 — 측정 누적면적 13.982% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

opacitytranslateY350ms

어떻게 만들었나

스크림은 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부터 목표값까지 숫자가 올라갑니다.

통계 카운트 타일
새 창에서 열기

06 · 통계 카운트 타일 — 측정 누적면적 0.54% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

@propertycounter()1.2s

코드 읽는 법

자바스크립트 없이 @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 값이 바뀌는 건 아닙니다.

호버 시 grid span 확장
새 창에서 열기

07 · 호버 시 grid span 확장 — 측정 누적면적 13.703% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scalez-indextransform-origin: top left

코드의 요점

그 값 자체는 트랜지션이 걸리지 않는 이산값이라, 대신 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스크롤 순차 등장 타일

화면에서 벌어지는 일

그리드가 화면에 들어오면 타일이 하나씩 차례로 떠오르며 나타납니다.

스크롤 순차 등장 타일
새 창에서 열기

08 · 스크롤 순차 등장 타일 — 측정 누적면적 36.644% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

IntersectionObservertransition-delayJS 8줄

동작 원리

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인접 타일 글로우

어떻게 보이나

그리드 위에서 마우스를 움직이면 지금 가리킨 칸뿐 아니라 이웃 칸까지 은은한 빛이 함께 번집니다.

인접 타일 글로우
새 창에서 열기

09 · 인접 타일 글로우 — 측정 누적면적 36.649% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

:has()radial-gradientJS 8줄

만드는 방식

좌표를 --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가 다음 단계입니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.