이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어
이미지 확대 보기는 한 장의 그림을 크게 키우거나, 돌리거나, 한 부분만 짚어 보게 해 주는 화면 장치입니다. 아홉 개 모두 실제로 움직이며, 사진 파일은 한 장도 쓰지 않고 그라디언트로만 그렸습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 마우스를 따라가는 확대 렌즈
- 02 휠로 당기고 끌어서 옮기는 뷰어
- 03 지금 보는 자리를 알려 주는 미니맵
- 04 단계로 끊어 주는 확대율 조절 바
- 05 돌리고 뒤집는 툴바
- 06 썸네일 자리에서 자라나는 라이트박스
- 07 끌어서 한 바퀴 돌리는 360도 뷰
- 08 포인터를 따라 켜지는 스포트라이트
- 09 사진 위에 꽂힌 설명 핀
순서는 인기순이 아니라 한 사람이 그림을 들여다보는 순서입니다. 먼저 누르지 않고 한 부분만 크게 보고(01), 그다음 화면 전체를 당겨 끌어 옮깁니다(02). 당기고 나면 어디를 보고 있는지 놓치기 쉬우므로 미니맵이 붙고(03), 배율을 정확히 맞춰야 할 때 단계 컨트롤이 붙습니다(04). 방향이 틀어진 그림은 돌려 세우고(05), 작게 늘어놓은 것 중 하나는 크게 엽니다(06). 물건이라면 뒤까지 돌려 보고(07), 설명할 때는 한 곳만 밝히거나(08) 그 자리에 이름을 꽂습니다(09). 아홉 개 전부 바닐라 자바스크립트 예순 줄 안쪽이고, 안에 들어 있는 그림은 linear-gradient 와 radial-gradient 계열뿐입니다.
01마우스를 따라가는 확대 렌즈
원단 위에 뜬 동그란 렌즈 안에는 같은 배경을 2.4배 크기로 다시 깐 사본이 들어 있습니다. 포인터 좌표에 배율을 곱하고 렌즈 반지름만큼 뺀 값이 그대로 background-position 이 되므로, 두 겹이 어긋나지 않고 딱 맞물립니다. 렌즈가 그림 밖으로 나가지 않도록 좌표를 반지름과 그림 폭 사이로 잘라 냅니다.
var MG_ZOOM = 2.4;
function mgPaint() {
var box = mgPhoto.getBoundingClientRect();
var half = mgLens.offsetWidth / 2;
mgX = Math.min(Math.max(mgX, half), box.width - half);
mgY = Math.min(Math.max(mgY, half), box.height - half);
mgLens.style.transform = 'translate(' + (mgX - half) + 'px,' + (mgY - half) + 'px)';
mgLens.style.backgroundPosition = -(mgX * MG_ZOOM - half) + 'px ' + -(mgY * MG_ZOOM - half) + 'px';
}
mgPhoto.addEventListener('pointermove', function (e) {
var box = mgPhoto.getBoundingClientRect();
mgX = e.clientX - box.left;
mgY = e.clientY - box.top;
mgPaint();
});
02휠로 당기고 끌어서 옮기는 뷰어
휠을 굴리면 배율이 1배와 3배 사이에서 오르내리고, 끌면 지도가 손끝을 따라옵니다. 옮길 수 있는 거리는 (폭 × (배율 − 1)) ÷ 2 ÷ 배율 로 미리 구해 두고 그 밖으로는 못 나가게 막습니다. 이 한 줄이 없으면 조금만 세게 끌어도 그림이 빠져나가 검은 여백이 드러납니다.
function zpPaint() {
var box = zpView.getBoundingClientRect();
var maxX = box.width * (zpScale - 1) / 2 / zpScale;
var maxY = box.height * (zpScale - 1) / 2 / zpScale;
zpX = Math.min(Math.max(zpX, -maxX), maxX);
zpY = Math.min(Math.max(zpY, -maxY), maxY);
zpLayer.style.transform = 'scale(' + zpScale + ') translate(' + zpX + 'px,' + zpY + 'px)';
}
zpView.addEventListener('wheel', function (e) {
e.preventDefault();
zpScale = Math.min(Math.max(zpScale - e.deltaY / 400, 1), 3);
zpPaint();
}, { passive: false });
03지금 보는 자리를 알려 주는 미니맵
오른쪽 아래 미니맵은 같은 도면을 통째로 줄여 둔 것이고, 그 안의 흰 상자가 지금 화면에 담긴 범위입니다. 배율이 2.4배면 상자는 미니맵의 1÷2.4, 곧 41.67퍼센트 크기이고 남는 58.33퍼센트는 상자가 돌아다닐 길이 됩니다. 큰 화면과 상자는 같은 값 하나에서 갈라져 나오므로 둘이 어긋날 수 없습니다.
var MM_SCALE = 2.4;
function mmPaint() {
var view = mmView.getBoundingClientRect();
var map = mmMap.getBoundingClientRect();
var rest = (1 - 1 / MM_SCALE) / 2;
var tx = mmPx * view.width * (MM_SCALE - 1) / 2 / MM_SCALE;
var ty = mmPy * view.height * (MM_SCALE - 1) / 2 / MM_SCALE;
mmLayer.style.transform = 'scale(' + MM_SCALE + ') translate(' + tx + 'px,' + ty + 'px)';
mmBox.style.transform = 'translate(' + rest * map.width * (1 - mmPx) + 'px,'
+ rest * map.height * (1 - mmPy) + 'px)';
}
04단계로 끊어 주는 확대율 조절 바
배율은 배열에 적힌 네 값만 밟습니다. 끝에 닿으면 해당 버튼이 disabled 로 꺼져서, 눌러도 아무 반응이 없는 버튼을 계속 누르는 일이 없습니다. 숫자는 role="status" 안에 있어 화면을 못 보는 사람에게도 바뀐 배율이 한 번 전해집니다.
var ZL_STOPS = [1, 1.5, 2, 3];
function zlPaint() {
zlLayer.style.transform = 'scale(' + ZL_STOPS[zlStep] + ')';
zlPct.textContent = Math.round(ZL_STOPS[zlStep] * 100) + '%';
document.getElementById('zl-out').disabled = zlStep === 0;
document.getElementById('zl-in').disabled = zlStep === ZL_STOPS.length - 1;
}
document.getElementById('zl-in').addEventListener('click', function () {
zlStep = Math.min(zlStep + 1, ZL_STOPS.length - 1);
zlPaint();
});
05돌리고 뒤집는 툴바
각도는 90도씩 더하기만 하고 360도에서 0도로 되돌리지 않습니다. 값을 계속 키우면 회전이 늘 같은 방향으로 이어져 되감기는 장면이 안 나옵니다. 뒤집기는 scaleX 의 부호를 바꾸는 것이 전부이고, 그 상태를 aria-pressed 에 적어 두어 색을 못 보는 사람도 지금 뒤집혀 있다는 것을 알 수 있습니다.
var rfAngle = 0, rfMirror = 1;
function rfPaint() {
rfPaper.style.transform = 'rotate(' + rfAngle + 'deg) scaleX(' + rfMirror + ')';
rfFlip.setAttribute('aria-pressed', rfMirror === -1 ? 'true' : 'false');
}
document.getElementById('rf-turn').addEventListener('click', function () {
rfAngle += 90;
rfPaint();
});
rfFlip.addEventListener('click', function () {
rfMirror = -rfMirror;
rfPaint();
});
06썸네일 자리에서 자라나는 라이트박스
큰 판을 화면 한가운데에 그려 놓고, 눌린 썸네일과의 차이만큼 뒤로 되돌려 붙인 다음 그 되돌림을 풀어 줍니다. 되돌리는 동안에는 transition 을 잠시 꺼야 하고, 다시 켜는 시점은 requestAnimationFrame 한 번 뒤입니다. 같은 프레임 안에서 껐다 켜면 브라우저가 두 값을 한 번에 계산해 애니메이션이 통째로 사라집니다.
function lbOpen(i) {
lb.setAttribute('data-i', String(i));
lb.classList.add('is-open');
var from = lbThumbs[i - 1].getBoundingClientRect();
var to = lbPanel.getBoundingClientRect();
lbPanel.style.transition = 'none';
lbPanel.style.transform = 'translate(' + (from.left - to.left + (from.width - to.width) / 2) + 'px,'
+ (from.top - to.top + (from.height - to.height) / 2) + 'px) scale('
+ (from.width / to.width) + ',' + (from.height / to.height) + ')';
requestAnimationFrame(function () {
lbPanel.style.transition = '';
lbPanel.style.transform = '';
document.getElementById('lb-x').focus({ preventScroll: true });
});
}
07끌어서 한 바퀴 돌리는 360도 뷰
열두 칸이 한 줄로 붙어 있고 창은 한 칸만 보여 줍니다. 끌어 옮긴 거리를 18로 나눠 반올림한 값이 칸 번호가 되고, 나머지 연산을 두 번 겹쳐 음수까지 0에서 11 사이로 접습니다. 손잡이의 가로 위치와 눌린 정도는 칸마다 적어 둔 사인 값에서, 컵에 찍힌 로고가 눌리는 정도는 코사인 값에서 나오므로, 진짜 사진 열두 장 없이도 한 바퀴가 됩니다.
var SP_N = 12, SP_STEP = 18;
function spPaint() {
spI = ((spI % SP_N) + SP_N) % SP_N;
spTrack.style.transform = 'translateX(' + (-spI * 100 / SP_N) + '%)';
}
spWin.addEventListener('pointerdown', function (e) {
spDrag = { x: e.clientX, i: spI };
spWin.setPointerCapture(e.pointerId);
});
spWin.addEventListener('pointermove', function (e) {
if (!spDrag) return;
spI = spDrag.i - Math.round((e.clientX - spDrag.x) / SP_STEP);
spPaint();
});
08포인터를 따라 켜지는 스포트라이트
같은 그림을 두 겹 깔고 아래 겹만 회색으로 어둡게 만듭니다. 위 겹은 mask-image 에 넣은 원 안에서만 남으므로, 원의 자리인 mask-position 만 옮기면 밝은 곳이 따라옵니다. 원의 크기는 mask-size 로 고정해 두었기 때문에 자바스크립트는 좌표 두 개만 계산하면 됩니다.
.sl__lit {
-webkit-mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 128px 128px;
mask-size: 128px 128px;
-webkit-mask-position: 12px -6px;
mask-position: 12px -6px;
}
09사진 위에 꽂힌 설명 핀
핀은 <div> 가 아니라 <button> 이라 탭 키만으로 세 곳을 돌 수 있습니다. 열고 닫는 상태는 클래스가 아니라 aria-expanded 에 적고, 쪽지를 보여 주는 규칙도 그 속성을 선택자로 씁니다. 그러면 낭독기에 전해지는 값과 눈에 보이는 모습이 갈라질 자리가 없습니다.
function hsOpen(pin) {
var was = pin.getAttribute('aria-expanded') === 'true';
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
pin.setAttribute('aria-expanded', was ? 'false' : 'true');
}
hsPins.forEach(function (pin) {
pin.addEventListener('click', function () { hsOpen(pin); });
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
});
어디서 깨지는가 — 함정
뷰어에서 가장 자주 깨지는 곳은 자동 재생용 연출과 사람이 실제로 만졌을 때의 경로가 따로 노는 것입니다. 격자에 걸린 아홉 칸은 전부 자동 루프로 돌아가는데, 그 루프를 CSS 키프레임에만 맡겨 두면 마우스를 올려 루프가 꺼지는 순간 화면이 비어 버립니다. 01의 렌즈가 바로 그 자리였습니다. 키프레임이 transform 과 background-position 을 둘 다 쥐고 있었으므로, 루프를 끈 정지 상태에도 같은 두 값을 기본 규칙으로 먼저 적어 둔 다음 그 위에 루프를 덧씌웠습니다. 아홉 개 전부 루프를 뗀 화면을 따로 한 번씩 찍어 확인했습니다.
두 번째는 좌표 계산의 기준입니다. 마우스 좌표는 화면 왼쪽 위가 0이지만 그림 안의 좌표는 그림 왼쪽 위가 0이라, getBoundingClientRect 로 잰 상자의 left 와 top 을 빼 주지 않으면 렌즈와 스포트라이트가 손끝에서 몇십 픽셀씩 떨어져 따라옵니다. 06의 라이트박스도 같은 계산을 두 번 합니다. 썸네일의 상자와 큰 판의 상자를 각각 재서 그 차이를 되돌림 값으로 쓰고, 중심끼리 맞추려고 두 상자의 폭과 높이 차이를 2로 나눠 더합니다.
마지막 함정은 화면이 손바닥만 해질 때 나옵니다. 이 데모들은 글 안에 480×300 iframe 으로 들어가고 휴대폰에서는 320×200으로 줄어듭니다. 무대 여백을 뺀 자리는 가로 294px, 세로 174px뿐인데 320px에서 재어 보니 가로는 05를 뺀 여덟 개가 288px로 같았고 세로는 144px에서 168px 사이였습니다. 가장 큰 01과 02가 168px이니 남는 여유는 6px입니다. 그래서 좁은 화면 규칙에서는 간격을 올리지 않고 내리기만 했습니다. 아홉 벌을 한 폴더에 모아 두었고 그 압축 파일의 비밀번호는 ce7bbmt2 이며, 안에는 지금 보고 있는 바닐라 판과 같은 동작의 React 판이 함께 들어 있습니다.
접근성
아홉 개 전부 마우스가 없어도 쓸 수 있어야 뷰어입니다. 확대·이동은 화살표 키, 배율은 버튼, 열고 닫기는 Enter와 Escape에 걸어 두었고, prefers-reduced-motion: reduce 에서는 자동 재생만 멈추고 보여 주던 상태는 그대로 남깁니다. 움직임을 끈다고 해서 "지금 2배로 보고 있다"는 사실까지 사라지면 안 되기 때문입니다.
| 항목 | 마우스 | 키보드 | 낭독기 |
|---|---|---|---|
| 01 확대 렌즈 | 포인터 이동 | 화살표 키 16px씩 | role="img" 와 조작 안내 |
| 02 휠 줌·패닝 | 휠 · 드래그 | 화살표 키와 + - |
role="img" 와 조작 안내 |
| 03 미니맵 | 드래그 | 화살표 키 | role="img" 와 조작 안내 |
| 04 배율 컨트롤 | 버튼 세 개 | 탭 · Enter | aria-live 로 바뀐 배율 |
| 05 회전·반전 | 버튼 두 개 | 탭 · Enter | aria-pressed 로 뒤집힘 |
| 06 라이트박스 | 썸네일 클릭 | Escape · 좌우 화살표 | role="dialog" 와 닫기 버튼 |
| 07 360도 뷰 | 드래그 | 좌우 화살표 | role="img" 와 조작 안내 |
| 08 스포트라이트 | 포인터 이동 | 화살표 키 20px씩 | role="img" 와 조작 안내 |
| 09 설명 핀 | 핀 클릭 | 탭 · Enter · Escape | aria-expanded 로 열림 |
포커스를 옮기는 것은 아홉 개 중 06뿐입니다. 라이트박스가 열리면 닫기 버튼으로, 닫히면 눌렀던 썸네일로 돌아오는데 둘 다 focus({ preventScroll: true }) 로 넘깁니다. 데모가 iframe 안에 들어 있어 이 옵션이 없으면 바깥 페이지가 그 자리로 끌려 내려갑니다. mask-image 의 브라우저 지원과 접두사가 필요한 범위는 MDN의 mask-image 문서에 정리되어 있습니다. 같은 갈래의 화면은 미디어 카테고리에, 손을 올렸을 때 반응하는 것들은 호버 카테고리에 모여 있습니다.
FAQ
이미지 확대 보기에 라이브러리를 쓰는 게 낫지 않나요?
아홉 개 모두 라이브러리 없이 예순 줄 안쪽으로 끝났습니다. 뷰어 라이브러리는 대개 핀치 줌, 관성 스크롤, 여러 장 넘김을 한꺼번에 들고 오는데, 상품 사진 한 장에 렌즈 하나만 필요한 화면에서는 그 전부가 짐이 됩니다. 반대로 파일 형식 변환이나 회전한 원본 저장처럼 그림 자체를 바꿔야 하는 일이 끼면 그때는 전용 도구가 맞습니다.
확대할 때 그림이 흐려지는 건 어떻게 막나요?
여기 아홉 개는 그라디언트로 그린 그림이라 몇 배로 키워도 계산으로 다시 그려져 또렷합니다. 하지만 진짜 사진을 쓸 때는 화면에 보이는 크기보다 큰 원본을 따로 두고, 확대할 때만 그 원본으로 갈아 끼우는 방법밖에 없습니다. 01처럼 렌즈만 쓰는 화면이라면 렌즈 안 배경에만 큰 원본을 주고 바탕은 작은 파일로 두면 됩니다.
터치 화면에서도 같은 코드가 도나요?
pointermove 와 pointerdown 은 마우스·펜·손가락을 한 이름으로 받으므로 02·03·07은 그대로 끌립니다. 다만 손가락으로 끌 때 페이지가 함께 스크롤되지 않게 touch-action: none 을 상자에 걸어 두어야 하고, 두 손가락 핀치는 pointermove 두 개를 직접 세어야 해서 이 아홉 개에는 넣지 않았습니다. 대신 02는 + 와 - 키로, 04는 버튼으로 같은 배율을 만들 수 있게 해 두었습니다.