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

이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어

이미지 확대 보기는 한 장의 그림을 크게 키우거나, 돌리거나, 한 부분만 짚어 보게 해 주는 화면 장치입니다. 아홉 개 모두 실제로 움직이며, 사진 파일은 한 장도 쓰지 않고 그라디언트로만 그렸습니다.

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

순서는 인기순이 아니라 한 사람이 그림을 들여다보는 순서입니다. 먼저 누르지 않고 한 부분만 크게 보고(01), 그다음 화면 전체를 당겨 끌어 옮깁니다(02). 당기고 나면 어디를 보고 있는지 놓치기 쉬우므로 미니맵이 붙고(03), 배율을 정확히 맞춰야 할 때 단계 컨트롤이 붙습니다(04). 방향이 틀어진 그림은 돌려 세우고(05), 작게 늘어놓은 것 중 하나는 크게 엽니다(06). 물건이라면 뒤까지 돌려 보고(07), 설명할 때는 한 곳만 밝히거나(08) 그 자리에 이름을 꽂습니다(09). 돋보기 확대 이미지부터 아홉 개 전부 바닐라 자바스크립트 예순 줄 안쪽이고, 안에 들어 있는 그림은 linear-gradient 와 radial-gradient 계열뿐입니다. 이 뷰어의 결과물을 나열해 꾸미는 판은 포트폴리오 갤러리 UI이고, 확대 축을 세로로 세우면 마우스를 따라오는 CSS 3D 회전 장면이 됩니다. 렌즈 안 배율은 2배와 3배 두 단계로만 묶어 사이값을 없앴고, 미니맵의 뷰 박스는 렌즈가 아니라 커서를 따라가게 했습니다. 배율 단계가 많아지면 손이 멈춘 뒤에도 눈이 쫓아가야 해서 두 단계에서 끊었고, 휠 한 칸에 배율이 한 단계씩만 오르내리도록 조임도 넣었습니다.

01마우스를 따라가는 확대 렌즈

css 이미지 확대에서 무엇이 보이나

원단 위에 뜬 동그란 렌즈 안에는 같은 배경을 2.4배 크기로 다시 깐 사본이 들어 있습니다. 포인터 좌표에 배율을 곱하고 렌즈 반지름만큼 뺀 값이 그대로 background-position 이 되므로, 두 겹이 어긋나지 않고 딱 맞물립니다.

마우스를 따라가는 확대 렌즈
새 창에서 열기

01 · 마우스를 따라가는 확대 렌즈 — 측정 누적면적 20.113% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

background-positionbackground-sizepointermove

코드에서 볼 곳

렌즈가 그림 밖으로 나가지 않도록 좌표를 반지름과 그림 폭 사이로 잘라 냅니다.

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 ÷ 배율 로 미리 구해 두고 그 밖으로는 못 나가게 막습니다.

휠로 당기고 끌어서 옮기는 뷰어
새 창에서 열기

02 · 휠로 당기고 끌어서 옮기는 뷰어 — 측정 누적면적 33.417% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

transform-originwheelMath.min

코드 읽는 법

이 한 줄이 없으면 조금만 세게 끌어도 그림이 빠져나가 검은 여백이 드러납니다.

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퍼센트는 상자가 돌아다닐 길이 됩니다.

지금 보는 자리를 알려 주는 미니맵
새 창에서 열기

03 · 지금 보는 자리를 알려 주는 미니맵 — 측정 누적면적 30.806% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getBoundingClientRectaspect-ratiotranslate

어디를 바꾸면 되나

큰 화면과 상자는 같은 값 하나에서 갈라져 나오므로 둘이 어긋날 수 없습니다.

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 로 꺼져서, 눌러도 아무 반응이 없는 버튼을 계속 누르는 일이 없습니다.

단계로 끊어 주는 확대율 조절 바
새 창에서 열기

04 · 단계로 끊어 주는 확대율 조절 바 — 측정 누적면적 21.867% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

steps(1, end)scalearia-live

코드에서 볼 곳

숫자는 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도로 되돌리지 않습니다. 값을 계속 키우면 회전이 늘 같은 방향으로 이어져 되감기는 장면이 안 나옵니다.

돌리고 뒤집는 툴바
새 창에서 열기

05 · 돌리고 뒤집는 툴바 — 측정 누적면적 11.597% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rotatescaleXaria-pressed

코드 읽는 법

뒤집기는 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 한 번 뒤입니다.

썸네일 자리에서 자라나는 라이트박스
새 창에서 열기

06 · 썸네일 자리에서 자라나는 라이트박스 — 측정 누적면적 33.357% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getBoundingClientRectrequestAnimationFramekeydown

어디를 바꾸면 되나

같은 프레임 안에서 껐다 켜면 브라우저가 두 값을 한 번에 계산해 애니메이션이 통째로 사라집니다.

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 사이로 접습니다.

끌어서 한 바퀴 돌리는 360도 뷰
새 창에서 열기

07 · 끌어서 한 바퀴 돌리는 360도 뷰 — 측정 누적면적 5.815% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

steps(12, end)translateXpointerdown

코드에서 볼 곳

손잡이의 가로 위치와 눌린 정도는 칸마다 적어 둔 사인 값에서, 컵에 찍힌 로고가 눌리는 정도는 코사인 값에서 나오므로, 진짜 사진 열두 장 없이도 한 바퀴가 됩니다.

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 만 옮기면 밝은 곳이 따라옵니다.

포인터를 따라 켜지는 스포트라이트
새 창에서 열기

08 · 포인터를 따라 켜지는 스포트라이트 — 측정 누적면적 19.468% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

mask-imageradial-gradientpointermove

코드 읽는 법

원의 크기는 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 에 적고, 쪽지를 보여 주는 규칙도 그 속성을 선택자로 씁니다.

사진 위에 꽂힌 설명 핀
새 창에서 열기

09 · 사진 위에 꽂힌 설명 핀 — 측정 누적면적 7.31% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-expandedscalefocus-visible

어디를 바꾸면 되나

그러면 낭독기에 전해지는 값과 눈에 보이는 모습이 갈라질 자리가 없습니다.

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는 버튼으로 같은 배율을 만들 수 있게 해 두었습니다.

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

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