GODRICH

CSS 이미지 슬라이드 애니메이션 9종

사진 위에 마우스를 올리면 커튼처럼 열리거나 원이 점점 커지며 드러나는 걸 본 적 있으신가요? 이런 CSS 이미지 슬라이드 애니메이션을 한 줄 복붙으로 쓸 수 있게 아홉 가지로 모았고, 그중 하나는 자바스크립트 없이 필터 탭까지 만듭니다.

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

아홉 개는 화려한 순서가 아니라 갤러리를 둘러보는 방문자가 실제로 거치는 순서로 배치했습니다. 먼저 사진 한 장이 덮개·커튼·원형 마스크로 드러나는 단일 리빌 기법(01~03)을 지나, 여러 장이 한꺼번에 놓인 그리드가 순서대로 나타나거나(04) 확대되어 보이는 순간(05, 07)을 거칩니다. 그다음은 개별 칸에 정보를 얹는 호버(06)와 스크롤을 따라 드러나는 큰 사진(08)을 지나, 마지막은 여러 장을 카테고리별로 골라보는 필터(09)로 마무리됩니다.

01좌우 와이프

사진을 덮은 판이 오른쪽 끝을 축으로 폭이 점점 줄어들면서, 왼쪽부터 오른쪽으로 사진이 드러납니다. transform-origin을 오른쪽에 고정하고 scaleX만 움직이는 게 핵심입니다.

scaleXtransform-origin500ms
.wipe-cover {
  position: absolute;
  inset: 0;
  background: #fff7e6;
  transform-origin: 100% 50%;
  transform: scaleX(1);
  transition: transform 600ms ease-out;
}
.wipe-focal:hover .wipe-cover {
  transform: scaleX(0);
}

02커튼 분할 리빌

반으로 나뉜 두 패널이 사진을 덮고 있다가, 마우스를 올리면 왼쪽은 왼쪽으로 오른쪽은 오른쪽으로 완전히 밀려나며 무대 커튼처럼 열립니다.

translateX50vw600ms
.curtain-l { left: 0; }
.curtain-r { left: 50%; }
.curtain-l, .curtain-r {
  position: absolute;
  top: 0; bottom: 0;
  width: 50%;
  transform: translateX(0);
  transition: transform 600ms cubic-bezier(.2, .8, .2, 1);
}
.gallery-focal:hover .curtain-l { transform: translateX(-100%); }
.gallery-focal:hover .curtain-r { transform: translateX(100%); }

03원형 마스크 확장

사진 정중앙에서 시작한 원이 반지름 0%에서 75%까지 커지며, 카메라 조리개가 열리듯 사진을 드러냅니다.

clip-pathcircle()550ms
.circle-photo {
  clip-path: circle(0% at 50% 50%);
  transition: clip-path 600ms ease-out;
}
.circle-focal:hover .circle-photo {
  clip-path: circle(75% at 50% 50%);
}

04메이슨리 순차 페이드

높낮이가 다른 사진 타일이 벽돌처럼 쌓인 그리드가 화면에 들어오면, 타일마다 순서대로 나타납니다. 타일마다 다른 건 transition-delay 값 하나뿐입니다.

columnstransition-delayJS 7줄
var tiles = document.querySelectorAll('.masonry-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); });

05dialog 라이트박스

썸네일 버튼을 누르면 네이티브 dialogshowModal()로 열립니다. 닫기 버튼은 페이지가 갑자기 스크롤되지 않도록 preventScroll 옵션과 함께 포커스를 받습니다.

dialogshowModalJS 6줄
var dlg = document.querySelector('.lb-dialog');
var btn = document.querySelector('.lb-close');
thumb.addEventListener('click', function () {
  dlg.showModal();
  btn.focus({ preventScroll: true });
});
btn.addEventListener('click', function () { dlg.close(); });

06호버 캡션 그리드

세 장이 나란히 놓인 그리드에서 지금 가리킨 칸의 캡션만 아래에서 위로 올라오고 서서히 나타납니다. 나머지 칸은 그대로 있어 어느 사진을 보고 있는지 헷갈리지 않습니다.

translateYopacity300ms
.caption-bar {
  transform: translateY(100%);
  opacity: 0;
  transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.gallery-tile:hover .caption-bar {
  transform: translateY(0);
  opacity: 1;
}

07썸네일→라이트박스 모핑

썸네일을 클릭하면 그 위치를 getBoundingClientRect()로 잰 뒤, 오버레이 패널을 그 자리에 먼저 놓고 transform을 지워 확대된 자리까지 부드럽게 전환합니다. FLIP이라 불리는 이 기법은 transform만 쓰므로 브라우저를 가리지 않습니다.

getBoundingClientRectFLIPJS 9줄
var t = thumb.getBoundingClientRect();
var p = panel.getBoundingClientRect();
panel.style.transform =
  'translate(' + (t.left - p.left) + 'px,' + (t.top - p.top) + 'px) ' +
  'scale(' + (t.width / p.width) + ',' + (t.height / p.height) + ')';
panel.classList.add('is-open');
requestAnimationFrame(function () {
  panel.style.transform = 'translate(0,0) scale(1,1)';
});

08스크롤 마스크 리빌

사진이 화면에 들어오면 mask-image가 위치를 오른쪽에서 왼쪽으로 쓸며 지나가, 딱딱한 경계 없이 부드러운 가장자리로 드러납니다. 트리거는 IntersectionObserver가 붙이는 클래스 하나뿐입니다.

mask-imagemask-positionJS 7줄
.mask-photo {
  mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
  mask-size: 260% 100%;
  mask-position: 0 0;
  transition: mask-position 700ms ease-out;
}
.mask-frame.in .mask-photo {
  mask-position: 100% 0;
}

09:has 기반 필터 탭

숨겨진 라디오 버튼 3개와 :has() 셀렉터만으로, 탭을 고르면 다른 카테고리 타일이 옅어지고 살짝 줄어듭니다. 자바스크립트 없이도 진짜로 동작하는 필터입니다.

:has()translateXJS 8줄
.gallery:has(#f-photo:checked) .tile:not([data-cat="photo"]) {
  opacity: .15;
  transform: scale(.94);
  pointer-events: none;
}
.gallery:has(#f-video:checked) .tile:not([data-cat="video"]) {
  opacity: .15;
  transform: scale(.94);
  pointer-events: none;
}

CSS 이미지 슬라이드 애니메이션은 어디서 깨지는가

가장 자주 오해하는 지점은 05번과 07번입니다. 라이트박스를 만들 때 dialog가 열리는 순간에 @starting-style로 멋진 등장 트랜지션을 넣고 싶어지지만, 이 기능은 Safari와 Firefox의 지원이 아직 들쭉날쭉해 이 글의 데모에서는 쓰지 않았습니다 — 대신 dialog는 브라우저 기본 방식으로 즉시 열리고, ::backdrop도 애니메이션 없이 정적인 반투명 막 하나로만 남겨 뒀습니다. 07번 역시 이름과 달리 document.startViewTransition() API를 쓰지 않는데, 이 API도 브라우저마다 지원과 결과 화면이 갈리기 때문입니다. 대신 클릭한 순간의 좌표를 재서 transform만으로 확대되는 자리까지 옮기는 FLIP 기법을 썼습니다. 04번과 08번은 둘 다 실제 상호작용은 IntersectionObserver가 맡고, 캡처용 반복 재생만 별도의 @keyframes 경로로 흉내 낸다는 공통점이 있으며, 아홉 항목의 vanilla 코드와 zip 압축 비밀번호는 63vswp74 이니 이 문단을 천천히 읽으면 찾을 수 있습니다.

접근성 — 아홉 개가 reduced-motion에서 남기는 것

시스템에서 동작 줄이기를 켜면 아홉 개 전부 애니메이션과 트랜지션을 끕니다. 01·02·03·06·07처럼 사진을 꾸며서 보여주는 반응은 움직임 없이 최종 모습(사진이 드러난 상태)으로 곧장 고정해도 정보 손실이 없습니다. 04(메이슨리 순차 등장)와 08(스크롤 마스크)도 순서나 경계가 움직임 자체에 담긴 정보는 아니라서, 타일과 사진을 처음부터 전부 드러난 상태로 두면 충분합니다. 다만 09(필터 탭)는 다릅니다 — 어떤 탭이 눌렸는지 자체가 정보라서 애니메이션을 꺼도 opacity·transform 값은 즉시 바뀌어야 하고, 실제로 이 항목은 :has()가 상태를 판정하는 구조라 트랜지션만 없앨 뿐 필터링 자체는 항상 그대로 작동합니다.

리빌 말고 사진 자체가 반응하는 호버 효과를 찾는다면 CSS 이미지 호버 효과 글도 참고할 만하고, 전체 목록은 CSS 애니메이션 카테고리에서 모아 볼 수 있습니다. clip-path의 세부 문법은 MDN clip-path 문서에, :has() 셀렉터의 브라우저 지원 범위는 MDN :has() 문서에 정리돼 있습니다.

FAQ — 자주 묻는 질문

왜 사진이 그냥 나타나면 안 되고 굳이 리빌 효과를 써야 하나요?

꼭 써야 하는 건 아니지만, 사진이 서서히 혹은 흥미로운 방식으로 드러나면 방문자의 시선이 그 순간에 머물러 포트폴리오나 상품 갤러리에서 클릭·호버를 유도하는 신호가 됩니다. 01~03처럼 단일 사진에 쓰는 가벼운 효과부터 07처럼 클릭까지 이어지는 효과까지, 화면 목적에 맞춰 고르면 됩니다.

마우스가 없는 터치 기기에서는 어떻게 동작하나요?

01·02·03·06처럼 :hover로 트리거되는 항목은 터치 기기에서 생략돼도 사진 자체는 이미 보이는 상태라 정보 손실이 없습니다. 반면 05·07·09는 클릭이나 탭으로 트리거되는 구조라 마우스가 없는 기기에서도 그대로 동작합니다.

zip 안의 vanilla와 react 코드가 다른가요?

동작은 같습니다. vanilla는 SCSS 변수를 파일 맨 위에 두고 실사용 경로와 캡처용 is-demo 경로를 함께 담고 있고, React 판은 is-demo 없이 실사용 경로만 컴포넌트 props로 받아 동일한 화면을 만듭니다.

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

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