GODRICH

지도 ui 디자인 9종 — 누르면 핀이 떨어지는 위치 선택 화면

지도 ui 디자인은 배달·예약 화면에서 "어디로 보낼까"를 지도 위에서 고르게 만드는 화면 설계입니다. 아홉 개 전부 지도 API 없이 CSS 격자로 그린 가짜 지도 위에서 돌아가고, 첫 번째는 누르면 핀이 떨어지는 그 동작 하나만 담았습니다.

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

순서는 인기순이 아니라 위치를 고르는 사람이 실제로 밟는 단계입니다. 먼저 지도를 눌러 점을 찍고(01), 점이 너무 몰리면 숫자로 묶었다 펼치고(02), 목록과 지도를 눈으로 이어 붙입니다(03). 그다음은 범위를 좁히는 쪽입니다. 거리로 자르고(04), 주소를 쳐서 옮기고(05), 시작점을 내 위치로 잡습니다(06). 마지막 셋은 고른 다음입니다. 경로를 그려 보여주고(07), 자주 가는 곳을 저장하고(08), 좁은 화면에서 전체 지형을 곁눈질합니다(09). 지도판은 아홉 개 모두 같은 방식으로 그려집니다. 세로·가로 repeating-linear-gradient 두 겹으로 도로 격자를 깔고, background 목록에서 그보다 앞에 적은 공원 원과 강 띠가 그 위를 지나갑니다. 먼저 적은 층이 위에 깔리기 때문입니다. 그 배경은 고정해 두고 핀·링·카드만 움직입니다.

01클릭하면 떨어지는 핀 드롭

지도를 누른 자리로 핀이 62px 위에서 떨어져 한 번 튀고 앉으며, 그림자는 좁았다가 착지에 맞춰 번집니다. 클릭 좌표를 지도 상자 크기로 나눠 퍼센트로 바꾸기 때문에 화면 폭이 달라져도 같은 자리에 꽂힙니다. 같은 애니메이션을 다시 돌리려면 클래스를 뗐다 붙이는 사이에 리플로를 한 번 강제해야 합니다.

translateYcubic-bezierclick 좌표
function drop(nx, ny) {
  root.classList.remove('is-demo');
  x = Math.max(8, Math.min(92, nx));
  y = Math.max(14, Math.min(90, ny));
  parts.forEach(function (el) {
    el.style.left = x.toFixed(1) + '%';
    el.style.top = y.toFixed(1) + '%';
    el.classList.remove('is-drop');
    void el.offsetWidth; // 리플로를 강제해야 같은 애니메이션이 다시 돈다
    el.classList.add('is-drop');
  });
  val.textContent = names[Math.floor((x + y) / 38) % names.length];
  coord.textContent = (37.52 + y / 400).toFixed(4) + ', ' + (127.03 + x / 400).toFixed(4);
}

02숫자 배지 클러스터 확산

겹친 핀 자리에 숫자 배지가 떠 있다가 누르면 핀 여섯 개가 부채꼴로 흩어지고, 다시 누르면 배지 하나로 합쳐집니다. 핀마다 방향 계수 두 개(--kx·--ky)만 다르고 반지름(--rx 92px·--ry 40px)은 공통이라, 좁은 화면에서는 반지름 두 값만 줄이면 핀이 지도 밖으로 나가지 않습니다.

scaletransform-originaria-expanded
@keyframes cb-burst {
  0%        { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
  10%       { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
  32%       { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
  68%       { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
  86%, 100% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
}

03결과 카드와 핀의 양방향 하이라이트

카드에 마우스를 올리거나 Tab으로 포커스를 옮기면 지도의 같은 매장 핀이 1.42배로 커지며 링이 퍼지고, 반대로 핀에 마우스를 올리면 카드 테두리가 켜집니다. 양쪽을 잇는 건 data-id 하나뿐이라 카드와 핀의 DOM 순서가 달라도 상관없습니다.

mouseenterdata-idbox-shadow
function light(id) {
  root.classList.remove('is-demo'); // 사람이 만졌으니 자동 루프를 멈춘다
  cards.concat(pins).forEach(function (el) {
    var on = el.getAttribute('data-id') === id;
    el.classList.toggle('is-on', on);
    el.setAttribute('aria-pressed', String(on));
  });
  var c = cards.filter(function (el) { return el.getAttribute('data-id') === id; })[0];
  if (c) out.textContent = '지도 핀 강조 · ' + c.querySelector('.cl__name').textContent;
}

04반경 원 거리 슬라이더

슬라이더를 밀면 반경 원이 커지고 거리 라벨이 3.2km까지 올라가며, 원 안에 든 핀만 진해집니다. 원은 지도 높이와 같은 정사각형에 aspect-ratio: 1을 걸고 scale(var(--k))로만 키우기 때문에, 커지는 동안 지도판 레이아웃은 한 번도 다시 계산되지 않습니다.

input rangescaleopacity
function rsPaint() {
  var v = Number(rsRange.value);
  var t = (v / 100 * RS_MAX_KM).toFixed(1) + 'km';
  rsRing.style.setProperty('--k', v / 100);
  rsKm.textContent = t;
  rsRange.setAttribute('aria-valuetext', t);
  rsPins.forEach(function (p) {
    p.classList.toggle('is-in', Number(p.getAttribute('data-dist')) <= v);
  });
}

05주소 자동완성 드롭다운

글자를 치면 후보 네 개가 입력칸 위로 슬라이드해 올라오고, 위아래 화살표로 옮겨 Enter를 누르면 지도 핀이 그 후보의 좌표로 실제로 건너갑니다. 후보 좌표는 요소마다 data-x·data-y에 지도 상자 크기 기준 퍼센트로 박아 두었습니다.

role=comboboxaria-activedescendanttyping keyframes
function acPick(i) {
  var o = acOpts[i];
  acOpts.forEach(function (x, k) { x.setAttribute('aria-selected', String(k === i)); });
  acInput.value = o.lastElementChild.textContent;
  acPin.style.left = o.getAttribute('data-x');
  acPin.style.top = o.getAttribute('data-y');
  acOpen(false);
  acInput.focus({ preventScroll: true });
}

06현재 위치 크로스헤어 스캔

버튼을 누르면 링 세 개가 시차를 두고 바깥으로 퍼지고, 1.4초 뒤 스캔이 끝나면 그 자리에서 현재 위치 점이 맥박처럼 뛰기 시작합니다. 상태 문구는 aria-live 영역이라 화면을 보지 못하는 사람에게도 "위치를 찾는 중"에서 "현재 위치 · 12m"로 바뀐 게 읽힙니다.

ring keyframesaria-livereduced-motion
function lcScan() {
  lcRoot.classList.remove('is-demo');   // 사람이 누르면 자동 루프를 멈춘다
  lcRoot.classList.remove('is-locked');
  lcRoot.classList.add('is-scanning');
  lcSay.textContent = '위치를 찾는 중';
  clearTimeout(lcTimer);
  lcTimer = setTimeout(function () {
    lcRoot.classList.remove('is-scanning');
    lcRoot.classList.add('is-locked');
    lcSay.textContent = '현재 위치 · 12m';
  }, 1400);
}

07그려지는 경로선

출발 핀에서 도착 깃발까지 경로선이 붓으로 긋듯 채워지고, 다 그어지면 "12분 · 3.4km" 배지가 튀어나옵니다. pathLength="100"으로 길이를 100으로 정규화해 두면 stroke-dasharraystroke-dashoffset에 넣는 숫자가 곧 진행 퍼센트가 되어, 경로 좌표를 고쳐도 값을 다시 계산할 필요가 없습니다.

stroke-dashoffsetpathLengtharia-label
.rd__line--draw {
  stroke: $color;
  stroke-width: 5;
  stroke-dasharray: 100;              // pathLength="100" 이라 dash 값이 곧 %
  stroke-dashoffset: 100;
}
// 조작 모드에서는 transition 으로 긋는다
.rd:not(.is-demo) .rd__line--draw { transition: stroke-dashoffset 900ms $ease-out; }
.rd.is-drawn .rd__line--draw { stroke-dashoffset: 0; }

08즐겨찾기 핀 목록

핀의 하트를 누르면 듀오톤 아이콘의 뒤에 깔린 면이 켜지며 통통 튀고, 지도 아래 "내 장소" 줄에 그 가게 칩이 밀려 들어옵니다. 칩은 지우는 게 아니라 max-width와 좌우 여백을 0으로 접어 두는 방식이라, 다시 켜면 같은 자리로 펴집니다.

aria-pressedscale bouncechip stack
function sync() {
  pins.forEach(function (p, i) {
    var on = p.getAttribute('aria-pressed') === 'true';
    chips[i].classList.toggle('is-on', on);
    chips[i].setAttribute('aria-hidden', String(!on));
  });
}
pins.forEach(function (p) {
  p.addEventListener('click', function () {
    root.classList.remove('is-demo');
    p.setAttribute('aria-pressed', p.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
    sync();
  });
});

09눌러 확대되는 미니 지도

구석의 미니 지도 안에서 "지금 보는 영역" 사각형이 왕복하고, 미니 지도를 누르면 오른쪽 아래 모서리를 기준으로 큰 판이 자라납니다. transform-origin: 100% 100%으로 확대 방향을 묶어 두면 열리는 판이 지도 상자 밖으로 삐져나가지 않습니다.

transform-originoverlayaria-expanded
function toggle(on) {
  root.classList.remove('is-demo');
  root.classList.toggle('is-open', on);
  btn.setAttribute('aria-expanded', String(on));
}
btn.addEventListener('click', function () { toggle(!root.classList.contains('is-open')); });
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && root.classList.contains('is-open')) {
    toggle(false);
    btn.focus({ preventScroll: true });
  }
});

어디서 깨지는가 — 함정

지도 UI의 좌표는 거의 다 퍼센트입니다. 지도 폭이 얼마이든 "가로 82% 세로 38% 자리"는 같은 건물이어야 하니까요. 그런데 퍼센트 좌표를 그대로 left·top에 넣고 움직이면 브라우저는 그때마다 위치를 다시 계산합니다. 05번 핀이 후보를 고를 때 딱 그렇게 움직입니다. transform: translate()의 퍼센트는 지도가 아니라 움직이는 요소 자신의 크기 기준이라, 20px짜리 핀에 translate(82%)를 주면 16px만 밀리고 맙니다. 그래서 좌표는 퍼센트로 두되, 자주 움직이는 것(01의 낙하, 02의 확산, 03의 확대, 04의 원, 09의 확대)은 전부 transformopacity로만 움직이고, 위치 자체를 바꾸는 것은 사람이 한 번 고를 때로만 제한했습니다.

두 번째는 크기입니다. 이 데모들은 글 안에 480×300 iframe으로 박히는데, 폰에서는 320×200으로 줄어듭니다. 무대 여백을 빼면 세로로 쓸 수 있는 건 174px뿐입니다. 지도 높이를 clamp(104px, 34vw, 160px) 같은 clamp 나 aspect-ratio: 12 / 5로 묶어 둔 게 그래서입니다. 320px 폭에서 재어 보니 아홉 개의 부품 높이는 127px에서 155px 사이였고, 여기서 목록 간격을 4px씩만 더 벌려도 마지막 줄이 잘립니다. 아홉 벌 소스가 든 압축 파일의 비밀번호는 7ajdcn4a 이고, 지금 보고 있는 아홉 개의 SCSS와 React 판이 고친 값 그대로 들어 있습니다. 세 번째 함정은 02번에서 나왔습니다. 핀 여섯 개를 같은 반지름에 한 줄 이름표로 펼치면 480px에서도 이름표가 서로 겹쳐 못 읽습니다. 부채꼴을 170도에서 10도까지 벌리고 이름표를 두 줄로 엇갈리게 놓아야 읽힙니다.

접근성

아홉 개 전부 prefers-reduced-motion: reduce에서 자동 재생 루프를 끄되 상태는 남깁니다. 핀은 떨어지는 과정 없이 자리에 서고, 반경 원은 크기만 바뀌고, 06번의 스캔 링은 퍼지기를 멈춘 채 첫 번째 링 하나가 조준선 둘레에 그대로 서 있습니다. 조작 경로도 마우스에만 걸어 두지 않았습니다.

항목 마우스 키보드 상태 알림
01 핀 드롭 지도 클릭 화살표로 이동 · Enter로 제자리 낙하 role="status" 로 장소 이름만 (좌표는 화면에만)
03 카드↔핀 호버 Tab 포커스 · 화살표로 다음 항목 aria-pressed · role="status"
04 반경 슬라이더 드래그 화살표 · Home · End aria-valuetext에 거리
05 주소 클릭 ↑↓ · Enter · Escape aria-activedescendant
06 내 위치 버튼 클릭 Enter · Space aria-live="polite"
09 미니 지도 클릭 Enter · Space · Escape aria-expanded

aria-activedescendant처럼 포커스를 옮기지 않고 "지금 가리키는 줄"만 바꾸는 방식의 정의는 MDN의 combobox 역할 문서에 정리되어 있습니다. 05번이 주소를 고른 다음과 09번이 Escape로 접힌 다음, 포커스를 되돌릴 때 preventScroll: true 옵션을 함께 넘기는 건, 이 지도들이 글 안에 iframe으로 들어앉아 있어서입니다. 옵션 없이 부르면 읽던 화면이 지도 쪽으로 툭 끌려 내려갑니다. 같은 계열로 값을 고르는 부품은 상거래 카테고리에, 눌러서 반응하는 부품은 클릭 카테고리에 모여 있습니다.

FAQ

지도 API 없이 그린 가짜 지도를 진짜 지도로 바꿔도 그대로 쓸 수 있나요?

핀·링·카드·슬라이더 쪽은 그대로 쓸 수 있습니다. 아홉 개 전부 지도판을 "배경이 깔린 position: relative 상자" 하나로만 취급하고, 그 안의 좌표는 퍼센트로 계산합니다. 배경 그라디언트 자리에 실제 지도 라이브러리의 캔버스를 넣고, 퍼센트 좌표를 그 라이브러리의 좌표 변환 함수로 바꿔 끼우면 됩니다. 바꿔야 하는 건 배경과 좌표 변환 두 곳입니다.

핀이 겹칠 때 숫자로 묶는 02번은 몇 개부터 쓰는 게 좋나요?

핀 사이 거리가 핀 아이콘 폭보다 좁아지는 순간부터입니다. 개수가 아니라 화면 위 거리 기준이라, 같은 세 개라도 확대하면 풀리고 축소하면 묶입니다. 데모는 여섯 개를 한 배지로 묶어 놓았는데, 펼쳤을 때 이름표가 읽히는 한계가 대략 그 언저리였습니다.

반경 슬라이더는 원 안팎을 어떻게 가리나요?

핀마다 data-dist에 0부터 100 사이의 값을 넣어 두고 슬라이더 값과 크기를 비교할 뿐입니다. 실제 서비스라면 이 자리에 중심 좌표와 핀 좌표의 거리 계산이 들어가고, 계산 결과를 같은 0~100 눈금으로 바꿔 넣으면 나머지 코드는 손댈 필요가 없습니다.

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

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