포트폴리오 갤러리 UI 9종 — 드래그 정렬부터 모프 전환까지
포트폴리오 갤러리는 사진이나 작업물을 격자로 늘어놓고, 마우스를 올리거나 클릭했을 때 확대·정렬·전환이 일어나는 화면입니다. 핀터레스트에 저장해 둔 그 포트폴리오 갤러리를 아홉 가지 방식으로 나눠 그대로 코드로 드립니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 무한 스크롤 사진 벽
- 02 폴라로이드 스택 갤러리
- 03 상품 갤러리 호버 확대
- 04 영상 썸네일 그리드 · 재생 오버레이
- 05 태그 필터 포트폴리오 그리드
- 06 드래그로 순서 바꾸는 사진 그리드
- 07 풀스크린 슬라이드쇼 · 썸네일 스트립
- 08 스크림 캡션 리빌 갤러리
- 09 그리드→단일 뷰 모프 전환
아홉 개는 인기 순서가 아니라 갤러리를 만나는 사람이 실제로 거치는 순서로 놓았습니다. 처음엔 그냥 지나가며 보고(01), 손으로 한 장씩 넘겨 보고(02), 마음에 드는 하나를 골라 확대해 보고(03), 영상이면 재생 버튼을 찾고(04), 사진이 많으면 태그로 걸러 보고(05), 주인이라면 직접 순서를 정리하고(06), 대표작 몇 장을 크게 훑어보고(07), 사연이 궁금해 캡션을 읽고(08), 마지막엔 하나에 몰입해서 큰 화면으로 봅니다(09).
| 번호 | 이름 | 무엇이 움직이나 | JS |
|---|---|---|---|
| 01 | 무한 스크롤 사진 벽 | 트랙 translateX |
없음 |
| 02 | 폴라로이드 스택 갤러리 | 카드 rotate·translate |
없음 |
| 03 | 상품 갤러리 호버 확대 | 사진 scale, 가격바 translateY |
없음 |
| 04 | 영상 썸네일 그리드 · 재생 오버레이 | 버튼 scale, 파동 scale·opacity |
없음 |
| 05 | 태그 필터 포트폴리오 그리드 | 카드 opacity·scale |
13줄 (자동 순환만) |
| 06 | 드래그로 순서 바꾸는 사진 그리드 | 타일 translateX |
34줄 |
| 07 | 풀스크린 슬라이드쇼 · 썸네일 스트립 | 트랙 translateX, 썸네일 테두리 |
19줄 |
| 08 | 스크림 캡션 리빌 갤러리 | 스크림 opacity, 캡션 translateY |
없음 |
| 09 | 그리드→단일 뷰 모프 전환 | 타일 translate·scale |
24줄 |
01무한 스크롤 사진 벽
작은 정사각 사진들이 가로로 끊임없이 흘러갑니다. 줄을 통째로 복제해 뒤에 이어 붙여 두면, 트랙이 자기 폭의 절반(-50%)만큼 움직인 순간 화면에 보이는 모양이 처음과 완전히 같아져 이어붙인 자리가 티가 나지 않습니다.
.wall-track {
display: flex;
width: max-content; // 사진 5장 × 2벌 = 10칸
animation: wall-loop $duration linear infinite;
}
.wall-tile {
flex: 0 0 auto;
margin-right: $sp-2; // gap 이 아니라 margin — 안 그러면 -50%가 어긋난다
}
@keyframes wall-loop {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
02폴라로이드 스택 갤러리
사진 네 장이 폴라로이드처럼 조금씩 다른 각도로 겹쳐 쌓여 있다가, 맨 위 장이 옆으로 밀려나며 회전해 물러나면 다음 장이 드러나는 걸 순서대로 반복합니다. 여행 사진첩처럼 한 장씩 넘겨 보여주고 싶은 갤러리에 맞습니다.
.polaroid--top {
transform: rotate(2deg) translate(1px, -4px);
z-index: 4;
animation: shuffle $duration $easing infinite;
}
@keyframes shuffle {
0%, 10%, 55%, 100% { transform: rotate(2deg) translate(1px, -4px); opacity: 1; }
30% { transform: rotate(16deg) translate(56px, -26px) scale(.94); opacity: .25; }
}
03상품 갤러리 호버 확대
상품 사진 칸에 커서를 올리면 액자 크기는 그대로인 채 안쪽 사진만 확대되고, 동시에 아래에서 가격표가 슬라이드로 올라옵니다. 가격을 은근히 보여주고 싶은 쇼핑몰 상품 목록에 씁니다.
.pcard { overflow: hidden; } // 액자 크기는 고정
.pcard__img {
transition: transform $duration $easing;
}
.pcard__price {
transform: translateY(100%); // 평소엔 밑에 숨어 있다가
transition: transform $duration $easing;
}
.pcard:hover .pcard__img { transform: scale(1.14); }
.pcard:hover .pcard__price { transform: translateY(0); }
04영상 썸네일 그리드 · 재생 오버레이
영상 썸네일 칸마다 가운데 재생 버튼이 있고, 커서가 올라가면 버튼이 커지면서 둘레로 파동 링이 한 번 퍼져 나가 재생 가능한 영상임을 알려줍니다. 유튜브 영상을 모아 보여주는 미디어 페이지에 어울립니다.
.vcard__ring {
border: 2px solid $subject-cream;
opacity: 0;
animation: ring-pulse $duration $easing infinite;
}
@keyframes ring-pulse {
0%, 15% { transform: scale(1); opacity: 0; }
30% { opacity: .9; }
60% { transform: scale(1.9); opacity: 0; }
100% { transform: scale(1); opacity: 0; }
}
05태그 필터 포트폴리오 그리드
카드마다 카테고리 태그 칩이 붙어 있고, 위쪽 태그 칩을 클릭하면 자바스크립트 없이 :has() 선택자만으로 해당하지 않는 카드가 옅어지며 줄어듭니다. 웹·앱·브랜딩을 나눠 보여주는 에이전시 소개 페이지에 맞습니다.
.pf-frame:has(.pf-check:checked) .pf-card {
opacity: .18;
transform: scale(.92);
}
.pf-frame:has(input[data-cat="web"]:checked) .pf-card[data-cat="web"] {
opacity: 1;
transform: scale(1);
}
06드래그로 순서 바꾸는 사진 그리드
사진 칸을 손가락이나 마우스로 눌러 옆 칸 쪽으로 끌면 두 칸의 자리가 서로 바뀌고, 놓는 순간 옮긴 칸만 새 자리로 부드럽게 붙습니다. 대표 사진을 직접 고르는 상품 이미지 관리 화면에 씁니다.
handle.addEventListener('pointerdown', e => {
dragging = true; startX = e.clientX;
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', e => {
if (!dragging) return;
dx = e.clientX - startX;
row.style.transform = `translateX(${dx}px)`;
});
handle.addEventListener('pointerup', () => {
dragging = false;
const target = dx > 30 ? row.nextElementSibling
: dx < -30 ? row.previousElementSibling : null;
row.style.transform = '';
if (target) list.insertBefore(dx > 0 ? target : row, dx > 0 ? row : target);
dx = 0;
});
07풀스크린 슬라이드쇼 · 썸네일 스트립
큰 사진 한 장이 화면 너비를 꽉 채운 채 자동으로 넘어가고, 아래 썸네일 줄에서 지금 보이는 사진의 테두리만 밝게 켜집니다. 대표작 몇 장을 순서대로 보여주는 포트폴리오 히어로에 맞습니다.
.ss-track {
width: 500%; // 실제 4장 + 1번 슬라이드 클론 = 5칸
animation: slide-loop $duration linear infinite;
}
@keyframes slide-loop {
0%, 18% { transform: translateX(0%); }
20%, 38% { transform: translateX(-20%); }
40%, 58% { transform: translateX(-40%); }
60%, 78% { transform: translateX(-60%); }
80%, 100% { transform: translateX(-80%); } // 클론 위치 = 1번과 같은 그림
}
08스크림 캡션 리빌 갤러리
평소엔 사진만 보이다가 커서를 올리면 사진 전체 위로 어두운 스크림이 번지듯 깔리며 가운데에 제목 글자가 함께 떠오릅니다. 사진 아래쪽 캡션 띠만 살짝 올라오는 다른 편(이미지 갤러리 CSS 9종 08번)의 방식과 달리, 사진 전체가 어두워지는 방식이라 사진 자체가 더 잘 보입니다.
.cr-card__scrim {
background: radial-gradient(120% 120% at 50% 65%, transparent 0%, rgba($stage-ink, .86) 100%);
opacity: 0;
transition: opacity $duration $easing;
}
.cr-card:hover .cr-card__scrim { opacity: 1; }
.cr-card:hover .cr-card__caption { opacity: 1; transform: translateY(0); }
09그리드→단일 뷰 모프 전환
칸 하나를 클릭하면 모달이 뜨는 대신 그리드 자체가 다시 짜여, 고른 칸은 자리에서 커지고 나머지 칸은 옅어지며 그 자리에서 작아집니다. 상세 설명 없이 사진 자체로 승부하는 갤러리에 씁니다.
.g-grid:has(.g-radio:checked) .g-tile:not(:has(.g-radio:checked)) {
opacity: .22;
transform: scale(.88);
}
// 칸마다 격자에서의 자리가 고정이라, 커질 때 이동량도 칸별로 고정값이다
.g-grid .g-tile:nth-child(1):has(.g-radio:checked) {
transform: translate(64px, 32px) scale(3.29, 2.14);
}
어디서 깨지는가 — 함정 하나
05(태그 필터)와 07(썸네일 스트립)을 처음 폰 크기(320px 폭)에서 렌더링했을 때 세로로 내용이 넘쳐 칸이 잘렸습니다. 두 데모 모두 카드 폭을 min(240px, 82vw)처럼 뷰포트 폭 기준으로만 줄였는데, 가로 폭이 줄어도 세로 칸 수(2행)와 카드 비율(4:3)은 그대로라 실제 세로 높이가 폰 화면 200px 예산을 훌쩍 넘겼기 때문입니다. 압축 파일 비밀번호 bhpgj3rm 로 열리는 vanilla 폴더 안에서 05는 카드를 2행에서 1행 4칸으로 펴고 세로로 긴 3:4 비율로 바꿨고, 07은 전체 폭을 200px로 한 단계 더 줄여서야 여백 안에 들어왔습니다. 가로 폭만 줄이는 min()·vw 반응형은 세로가 긴 레이아웃에서는 따로 검증해야 한다는 뜻입니다.
접근성
모션을 줄이는 설정(prefers-reduced-motion: reduce)을 켜면 아홉 항목 모두 움직임만 사라지고 마지막 상태는 그대로 보이게 맞췄습니다. 01은 트랙이 멈춰 첫 줄 그대로 보이고, 02·04·08은 순환 애니메이션 없이 기본 자리에 고정되고, 03·06·09는 확대·이동이 순간적으로 적용되며, 07은 슬라이드가 전환 없이 그 자리에서 바뀝니다. 06의 사진 타일은 마우스 드래그뿐 아니라 좌우 화살표 키로도 순서를 바꿀 수 있고, 09의 칸도 라디오 버튼이라 Tab과 화살표 키만으로 골라 크게 볼 수 있습니다. 포인터 이벤트 표준은 MDN Pointer Events에서, :has() 선택자 지원 범위는 MDN :has()에서 확인할 수 있습니다. 같은 gallery 갈래의 다른 두 편, 이미지 갤러리 CSS 9종과 이미지 비교 슬라이더 UI 9종도 함께 보면 좋습니다.
FAQ
05의 :has() 필터는 모든 브라우저에서 됩니까?
Chrome·Edge·Safari는 오래전부터 지원하고 Firefox도 121부터 지원합니다. 지원하지 않는 아주 오래된 브라우저에서는 선택자가 그냥 무시되어 필터만 작동하지 않고 카드는 전부 그대로 보이므로, 레이아웃이 깨지지는 않습니다.
09에서 사진 비율이 원본과 다르게 늘어나 보이는 이유는 무엇입니까?
칸이 커질 때 scale()을 가로·세로 다른 값(3.29, 2.14)으로 주기 때문입니다. 정사각 칸이 격자 전체의 가로로 긴 영역을 채우려면 이렇게 비율을 다르게 늘려야 하는데, 실제 사진을 쓸 때는 .g-tile__img에 object-fit: cover를 걸어 사진 자체는 원래 비율을 유지하게 하는 걸 권합니다.
06의 드래그를 터치스크린에서도 쓸 수 있습니까?
됩니다. pointerdown·pointermove·pointerup은 마우스·터치·펜을 한 API로 묶어서 다루므로 손가락으로 눌러 드래그하는 동작도 그대로 작동하고, touch-action: none을 타일에 걸어 두어 드래그 중 화면이 함께 스크롤되는 것도 막았습니다.