지도 ui 디자인 9종 — 누르면 핀이 떨어지는 위치 선택 화면
지도 ui 디자인은 배달·예약 화면에서 "어디로 보낼까"를 지도 위에서 고르게 만드는 화면 설계입니다. 아홉 개 전부 지도 API 없이 CSS 격자로 그린 가짜 지도 위에서 돌아가고, 첫 번째는 누르면 핀이 떨어지는 그 동작 하나만 담았습니다.
01 map-pin-drop
02 map-cluster-burst
03 map-card-pin-link
04 map-radius-slider
05 map-address-autocomplete
06 map-locate-crosshair
07 map-route-draw
08 map-favorite-pins
09 map-minimap-expand
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 클릭하면 떨어지는 핀 드롭
- 02 숫자 배지 클러스터 확산
- 03 결과 카드와 핀의 양방향 하이라이트
- 04 반경 원 거리 슬라이더
- 05 주소 자동완성 드롭다운
- 06 현재 위치 크로스헤어 스캔
- 07 그려지는 경로선
- 08 즐겨찾기 핀 목록
- 09 눌러 확대되는 미니 지도
순서는 인기순이 아니라 위치를 고르는 사람이 실제로 밟는 단계입니다. 먼저 지도를 눌러 점을 찍고(01), 점이 너무 몰리면 숫자로 묶었다 펼치고(02), 목록과 지도를 눈으로 이어 붙입니다(03). 그다음은 범위를 좁히는 쪽입니다. 거리로 자르고(04), 주소를 쳐서 옮기고(05), 시작점을 내 위치로 잡습니다(06). 마지막 셋은 고른 다음입니다. 경로를 그려 보여주고(07), 자주 가는 곳을 저장하고(08), 좁은 화면에서 전체 지형을 곁눈질합니다(09). 지도 ui/ux 의 바탕이 되는 지도판은 아홉 개 모두 같은 방식으로 그려집니다. 세로·가로 repeating-linear-gradient 두 겹으로 도로 격자를 깔고, background 목록에서 그보다 앞에 적은 공원 원과 강 띠가 그 위를 지나갑니다. 먼저 적은 층이 위에 깔리기 때문입니다. 그 배경은 고정해 두고 핀·링·카드만 움직입니다. 선택이 시각이라면 css 달력 만들기가 짝이고, 시각이 아니라 구간이라면 시간 선택 ui가 그 역할을 이어받습니다. 핀이 떨어지는 높이는 지도 축척과 무관하게 픽셀 하나로 고정했고, 클러스터 숫자가 세 자리를 넘으면 배지가 아니라 카드로 승격시키는 규칙까지 정했습니다. 눈이 숫자를 세는 동안 지도는 멈춰 있어야 해서 02번의 확산 애니메이션은 숫자가 바뀌는 순간에만 돌아갑니다.
01클릭하면 떨어지는 핀 드롭
모바일 지도 ui 는 무엇이 보이나
지도를 누른 자리로 핀이 62px 위에서 떨어져 한 번 튀고 앉으며, 그림자는 좁았다가 착지에 맞춰 번집니다. 클릭 좌표를 지도 상자 크기로 나눠 퍼센트로 바꾸기 때문에 화면 폭이 달라져도 같은 자리에 꽂힙니다.

<main class="stage">
<div class="pd is-demo">
<button class="pd__map" type="button" id="pd-map" aria-label="지도를 눌러 위치를 고르세요" aria-describedby="pd-val">
<span class="pd__shadow" id="pd-shadow" style="left:52%;top:58%" aria-hidden="true"></span>
<span class="pd__pin" id="pd-pin" style="left:52%;top:58%" aria-hidden="true">
<span class="pd__pin-i">
<svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
</span>
</span>
</button>
<div class="pd__bar">
<span class="pd__icon" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2"/><path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></svg></span>
<span class="pd__txt">
<span class="pd__cap">선택한 위치</span>
<span class="pd__val" id="pd-val" role="status" aria-live="polite">성수동 카페거리</span>
</span>
<span class="pd__coord" id="pd-coord">37.5458, 127.0552</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// 가짜 지도 — 외부 타일·이미지·폰트 0. 그라디언트만 겹친다. 이 색들은 토큰에 없는 지도 전용색이다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.pd {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex; flex-direction: column; gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
// ── 지도판: 공원 원 → 강 띠 → 세로 도로 → 가로 도로 → 땅 순으로 겹친다. 배경 자체는 절대 움직이지 않는다 ──
.pd__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
margin: 0; padding: 0;
border: 0;
border-radius: $r-card;
cursor: crosshair;
box-shadow: $shadow-raised;
background:
radial-gradient(circle at 78% 24%, $map-park 0 38px, transparent 38px),
linear-gradient(72deg, transparent 0 10%, $map-water 10% 17%, transparent 17%),
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 29px),
repeating-linear-gradient(0deg, $map-road 0 7px, transparent 7px 27px),
$map-land;
}
// 블록 두 장 — 도로 격자만 있으면 벽지처럼 보인다
.pd__map::before, .pd__map::after {
content: "";
position: absolute;
border-radius: $r-xs;
background: $map-block;
pointer-events: none;
}
.pd__map::before { left: 13%; top: 17%; width: 64px; height: 30px; }
.pd__map::after { left: 31%; top: 63%; width: 88px; height: 26px; }
.pd__map:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pd__pin {
position: absolute;
width: 34px; height: 34px;
margin: 0;
transform: translate(-50%, -100%);
color: $color;
pointer-events: none;
}
.pd__pin-i { display: block; }
.pd__pin svg { display: block; }
.pd__shadow {
position: absolute;
width: 26px; height: 8px;
margin: 0;
border-radius: 50%;
background: rgba($subject-ink, .5);
transform: translate(-50%, -50%) scale(1);
opacity: .3;
pointer-events: none;
}
.pd__bar {
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-3;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: $r-control;
background: rgba(255, 255, 255, .06);
}
.pd__icon { display: inline-flex; color: $color; }
.pd__txt { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
.pd__cap {
font-size: 9px; line-height: 12px; font-weight: 700;
letter-spacing: .04em;
color: rgba(255, 255, 255, .55);
}
.pd__val {
font-size: 12px; line-height: 16px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pd__coord {
font-size: 10px; line-height: 14px; font-weight: 600;
color: rgba(255, 255, 255, .55);
white-space: nowrap;
}
// ── 사람이 누른 뒤: 한 번 떨어져 통통 튀고 앉는다 (.is-demo 는 JS 가 이미 뗐다) ──
.pd:not(.is-demo) .pd__pin.is-drop .pd__pin-i {
animation-name: pd-drop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
.pd:not(.is-demo) .pd__shadow.is-drop {
animation-name: pd-shadow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes pd-drop {
0% { opacity: 0; transform: translateY(-62px) scale(.92); animation-timing-function: cubic-bezier(.5, 0, .9, .4); }
14% { opacity: 1; transform: translateY(-52px) scale(.94); animation-timing-function: cubic-bezier(.5, 0, .9, .4); }
46% { opacity: 1; transform: translateY(0) scale(1.04); }
64% { opacity: 1; transform: translateY(-13px) scale(.98); }
82% { opacity: 1; transform: translateY(0) scale(1.02); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes pd-shadow {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
14% { opacity: .16; transform: translate(-50%, -50%) scale(.46); }
46% { opacity: .4; transform: translate(-50%, -50%) scale(1.18); }
64% { opacity: .22; transform: translate(-50%, -50%) scale(.72); }
82% { opacity: .36; transform: translate(-50%, -50%) scale(1.06); }
100% { opacity: .3; transform: translate(-50%, -50%) scale(1); }
}
// ── 자동 루프: 떨어짐 → 튐 → 유지 → 90% 안에서 fade-out+상승. 0% 와 100% 가 같은 상태라 이어 붙어도 안 튄다 ──
.pd.is-demo .pd__pin-i {
animation-name: pd-loop;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.pd.is-demo .pd__shadow {
animation-name: pd-shadow-loop;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pd-loop {
0% { opacity: 0; transform: translateY(-62px) scale(.92); }
6% { opacity: 1; transform: translateY(-62px) scale(.92); animation-timing-function: cubic-bezier(.5, 0, .9, .4); }
30% { opacity: 1; transform: translateY(0) scale(1.05); animation-timing-function: $easing; }
42% { opacity: 1; transform: translateY(-14px) scale(.97); }
54% { opacity: 1; transform: translateY(0) scale(1.03); }
62% { opacity: 1; transform: translateY(0) scale(1); }
80% { opacity: 1; transform: translateY(0) scale(1); }
90%, 100% { opacity: 0; transform: translateY(-62px) scale(.92); }
}
@keyframes pd-shadow-loop {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
6% { opacity: .16; transform: translate(-50%, -50%) scale(.44); }
30% { opacity: .4; transform: translate(-50%, -50%) scale(1.2); }
42% { opacity: .2; transform: translate(-50%, -50%) scale(.7); }
54% { opacity: .36; transform: translate(-50%, -50%) scale(1.08); }
62% { opacity: .3; transform: translate(-50%, -50%) scale(1); }
80% { opacity: .3; transform: translate(-50%, -50%) scale(1); }
90%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
}
@media (prefers-reduced-motion: reduce) {
.pd.is-demo .pd__pin-i,
.pd.is-demo .pd__shadow,
.pd:not(.is-demo) .pd__pin.is-drop .pd__pin-i,
.pd:not(.is-demo) .pd__shadow.is-drop { animation: none !important; }
.pd__map, .pd__bar { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pd {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.pd__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
margin: 0;
padding: 0;
border: 0;
border-radius: 16px;
cursor: crosshair;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 78% 24%, #c9e0bd 0 38px, transparent 38px), linear-gradient(72deg, transparent 0 10%, #bcd9ea 10% 17%, transparent 17%), repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 29px), repeating-linear-gradient(0deg, #fffdf7 0 7px, transparent 7px 27px), #efe9dc;
}
.pd__map::before, .pd__map::after {
content: "";
position: absolute;
border-radius: 4px;
background: #e2dac9;
pointer-events: none;
}
.pd__map::before {
left: 13%;
top: 17%;
width: 64px;
height: 30px;
}
.pd__map::after {
left: 31%;
top: 63%;
width: 88px;
height: 26px;
}
.pd__map:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pd__pin {
position: absolute;
width: 34px;
height: 34px;
margin: 0;
transform: translate(-50%, -100%);
color: #2f6df6;
pointer-events: none;
}
.pd__pin-i {
display: block;
}
.pd__pin svg {
display: block;
}
.pd__shadow {
position: absolute;
width: 26px;
height: 8px;
margin: 0;
border-radius: 50%;
background: rgba(23, 20, 26, 0.5);
transform: translate(-50%, -50%) scale(1);
opacity: 0.3;
pointer-events: none;
}
.pd__bar {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
}
.pd__icon {
display: inline-flex;
color: #2f6df6;
}
.pd__txt {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
}
.pd__cap {
font-size: 9px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.55);
}
.pd__val {
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pd__coord {
font-size: 10px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.55);
white-space: nowrap;
}
.pd:not(.is-demo) .pd__pin.is-drop .pd__pin-i {
animation-name: pd-drop;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
.pd:not(.is-demo) .pd__shadow.is-drop {
animation-name: pd-shadow;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes pd-drop {
0% {
opacity: 0;
transform: translateY(-62px) scale(0.92);
animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.4);
}
14% {
opacity: 1;
transform: translateY(-52px) scale(0.94);
animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.4);
}
46% {
opacity: 1;
transform: translateY(0) scale(1.04);
}
64% {
opacity: 1;
transform: translateY(-13px) scale(0.98);
}
82% {
opacity: 1;
transform: translateY(0) scale(1.02);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes pd-shadow {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
14% {
opacity: 0.16;
transform: translate(-50%, -50%) scale(0.46);
}
46% {
opacity: 0.4;
transform: translate(-50%, -50%) scale(1.18);
}
64% {
opacity: 0.22;
transform: translate(-50%, -50%) scale(0.72);
}
82% {
opacity: 0.36;
transform: translate(-50%, -50%) scale(1.06);
}
100% {
opacity: 0.3;
transform: translate(-50%, -50%) scale(1);
}
}
.pd.is-demo .pd__pin-i {
animation-name: pd-loop;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.pd.is-demo .pd__shadow {
animation-name: pd-shadow-loop;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pd-loop {
0% {
opacity: 0;
transform: translateY(-62px) scale(0.92);
}
6% {
opacity: 1;
transform: translateY(-62px) scale(0.92);
animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.4);
}
30% {
opacity: 1;
transform: translateY(0) scale(1.05);
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
42% {
opacity: 1;
transform: translateY(-14px) scale(0.97);
}
54% {
opacity: 1;
transform: translateY(0) scale(1.03);
}
62% {
opacity: 1;
transform: translateY(0) scale(1);
}
80% {
opacity: 1;
transform: translateY(0) scale(1);
}
90%, 100% {
opacity: 0;
transform: translateY(-62px) scale(0.92);
}
}
@keyframes pd-shadow-loop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
6% {
opacity: 0.16;
transform: translate(-50%, -50%) scale(0.44);
}
30% {
opacity: 0.4;
transform: translate(-50%, -50%) scale(1.2);
}
42% {
opacity: 0.2;
transform: translate(-50%, -50%) scale(0.7);
}
54% {
opacity: 0.36;
transform: translate(-50%, -50%) scale(1.08);
}
62% {
opacity: 0.3;
transform: translate(-50%, -50%) scale(1);
}
80% {
opacity: 0.3;
transform: translate(-50%, -50%) scale(1);
}
90%, 100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
}
@media (prefers-reduced-motion: reduce) {
.pd.is-demo .pd__pin-i,
.pd.is-demo .pd__shadow,
.pd:not(.is-demo) .pd__pin.is-drop .pd__pin-i,
.pd:not(.is-demo) .pd__shadow.is-drop {
animation: none !important;
}
.pd__map, .pd__bar {
transition: none;
}
}// 실제 동작: 지도를 누른 좌표에 핀을 떨어뜨린다. 키보드는 화살표로 옮기고 Enter·Space 로 다시 떨어뜨린다.
var root = document.querySelector('.pd');
var map = document.getElementById('pd-map');
var parts = [document.getElementById('pd-pin'), document.getElementById('pd-shadow')];
var val = document.getElementById('pd-val');
var coord = document.getElementById('pd-coord');
var names = ['성수동 카페거리', '서울숲 정문', '왕십리역 2번 출구', '뚝섬유원지', '한강공원 나들목'];
var x = 52, y = 58;
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);
}
map.addEventListener('click', function (e) {
if (!e.detail) { drop(x, y); return; } // Enter·Space 는 좌표가 0 이라 제자리에 다시 떨어뜨린다
var r = map.getBoundingClientRect();
drop(((e.clientX - r.left) / r.width) * 100, ((e.clientY - r.top) / r.height) * 100);
});
map.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') drop(x - 6, y);
else if (e.key === 'ArrowRight') drop(x + 6, y);
else if (e.key === 'ArrowUp') drop(x, y - 6);
else if (e.key === 'ArrowDown') drop(x, y + 6);
else return;
e.preventDefault();
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PinDrop.module.scss";
export type PinDropProps = {
label?: string;
hint?: string;
places?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// React 이벤트 타입은 'react' 에서 import 하지 않는다 — 포트 안에서 필요한 모양만 선언한다
type Box = { left: number; top: number; width: number; height: number };
type ClickEvt = { detail: number; clientX: number; clientY: number; currentTarget: { getBoundingClientRect: () => Box } };
type KeyEvt = { key: string; preventDefault: () => void };
export default function PinDrop({
label = "Selected location",
hint = "Click the map to pick a location",
places = ["Seongsu Cafe Street", "Seoul Forest Main Gate", "Wangsimni Stn. Exit 2", "Ttukseom Resort", "Hangang Park Entrance"],
duration = 600,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: PinDropProps) {
const [pos, setPos] = useState({ x: 52, y: 58 });
const [drops, setDrops] = useState(0); // key 를 바꿔 같은 애니메이션을 다시 돌린다 (vanilla 의 리플로 강제와 같은 역할)
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function drop(nx: number, ny: number) {
setDemo(false);
setPos({ x: Math.max(8, Math.min(92, nx)), y: Math.max(14, Math.min(90, ny)) });
setDrops((v) => v + 1);
}
const name = places[Math.floor((pos.x + pos.y) / 38) % places.length];
const coord = `${(37.52 + pos.y / 400).toFixed(4)}, ${(127.03 + pos.x / 400).toFixed(4)}`;
const at: CSSProperties = { left: `${pos.x}%`, top: `${pos.y}%` };
return (
<div className={`${styles.pd} ${demo ? styles.isDemo : ""}`} style={vars}>
<button
type="button"
className={styles.map}
aria-label={hint}
onClick={(e: ClickEvt) => {
if (!e.detail) { drop(pos.x, pos.y); return; } // Enter·Space 는 좌표가 0 이다
const r = e.currentTarget.getBoundingClientRect();
drop(((e.clientX - r.left) / r.width) * 100, ((e.clientY - r.top) / r.height) * 100);
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowLeft") drop(pos.x - 6, pos.y);
else if (e.key === "ArrowRight") drop(pos.x + 6, pos.y);
else if (e.key === "ArrowUp") drop(pos.x, pos.y - 6);
else if (e.key === "ArrowDown") drop(pos.x, pos.y + 6);
else return;
e.preventDefault();
}}
>
<span key={`s${drops}`} className={`${styles.shadow} ${drops ? styles.isDrop : ""}`} style={at} aria-hidden="true" />
<span key={`p${drops}`} className={`${styles.pin} ${drops ? styles.isDrop : ""}`} style={at} aria-hidden="true">
<span className={styles.pinI}>
<svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" /><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" /></g></svg>
</span>
</span>
</button>
<div className={styles.bar}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2" /><path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24" /></g></svg>
</span>
<span className={styles.txt}>
<span className={styles.cap}>{label}</span>
<span className={styles.val} role="status" aria-live="polite">{name}</span>
</span>
<span className={styles.coord}>{coord}</span>
</div>
</div>
);
}01 · 클릭하면 떨어지는 핀 드롭 — 측정 누적면적 1.535% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
같은 애니메이션을 다시 돌리려면 클래스를 뗐다 붙이는 사이에 리플로를 한 번 강제해야 합니다.
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숫자 배지 클러스터 확산
화면에서 일어나는 일
겹친 핀 자리에 숫자 배지가 떠 있다가 누르면 핀 여섯 개가 부채꼴로 흩어지고, 다시 누르면 배지 하나로 합쳐집니다.

<main class="stage">
<div class="cb is-demo">
<div class="cb__map">
<ul class="cb__group" id="cb-group" aria-hidden="true">
<li class="cb__pin" style="--kx:-.985;--ky:-.174;--d:0ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">한강점</span>
</li>
<li class="cb__pin cb--low" style="--kx:-.695;--ky:-.719;--d:30ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">서울숲점</span>
</li>
<li class="cb__pin" style="--kx:-.174;--ky:-.985;--d:60ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">성수점</span>
</li>
<li class="cb__pin cb--low" style="--kx:.174;--ky:-.985;--d:90ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">연무장점</span>
</li>
<li class="cb__pin" style="--kx:.695;--ky:-.719;--d:120ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">뚝섬점</span>
</li>
<li class="cb__pin cb--low" style="--kx:.985;--ky:-.174;--d:150ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">왕십리점</span>
</li>
</ul>
<button class="cb__badge" type="button" id="cb-badge" aria-expanded="false" aria-controls="cb-group" aria-label="매장 6곳 펼치기">6</button>
</div>
<div class="cb__bar">
<span class="cb__icon" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span>
<span class="cb__out" id="cb-out" role="status" aria-live="polite">매장 6곳이 한 자리에 모여 있습니다</span>
<button class="cb__toggle" type="button" id="cb-toggle" aria-expanded="false" aria-controls="cb-group">펼치기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-yellow);
// 가짜 지도 — 외부 타일·이미지·폰트 0. 그라디언트만 겹친다. 이 색들은 토큰에 없는 지도 전용색이다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.cb {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex; flex-direction: column; gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
// 부채꼴 반지름 — 좁은 화면에서 줄여야 핀이 지도 밖으로 안 나간다
--rx: 92px;
--ry: 40px;
}
// ── 지도판: 공원 원 → 강 띠 → 세로 도로 → 가로 도로 → 땅. 배경은 고정, 움직이는 건 핀뿐 ──
.cb__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
border-radius: $r-card;
box-shadow: $shadow-press;
background:
radial-gradient(circle at 16% 78%, $map-park 0 34px, transparent 34px),
linear-gradient(100deg, transparent 0 74%, $map-water 74% 82%, transparent 82%),
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 31px),
repeating-linear-gradient(0deg, $map-road 0 7px, transparent 7px 25px),
$map-land;
}
.cb__map::before, .cb__map::after {
content: "";
position: absolute;
border-radius: $r-xs;
background: $map-block;
pointer-events: none;
}
.cb__map::before { left: 8%; top: 12%; width: 70px; height: 24px; }
.cb__map::after { right: 9%; bottom: 10%; width: 58px; height: 30px; }
.cb__group { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
.cb__pin {
position: absolute;
left: 50%; top: 50%;
width: 20px; height: 20px;
color: $color;
transform-origin: 50% 100%; // 규칙8: 핀 꼭짓점 고정 버스트
// 합쳐진 상태 — 배지 뒤에 접혀 있다
transform: translate(-50%, -100%) translate(0, 0) scale(.3);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
transition-delay: var(--d);
}
.cb__pin svg { display: block; }
.cb.is-open .cb__pin {
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
opacity: 1;
}
.cb__tag {
position: absolute;
left: 50%; top: 100%;
transform: translateX(-50%);
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(255, 255, 255, .94);
color: $subject-ink;
font-size: 8px; line-height: 13px; font-weight: 700;
white-space: nowrap;
}
// 이름표를 두 줄로 엇갈리게 — 한 줄에 여섯 개를 늘어놓으면 서로 겹쳐 못 읽는다
.cb__pin.cb--low .cb__tag { top: calc(100% + 13px); }
.cb__badge {
position: absolute;
left: 50%; top: 50%;
box-sizing: border-box;
width: 38px; height: 38px;
margin: 0; padding: 0;
border: 2px solid rgba(255, 255, 255, .9);
border-radius: 50%;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-size: 15px; line-height: 34px; font-weight: 800;
cursor: pointer;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
box-shadow: $shadow-press;
transition: transform $duration $easing, opacity $duration $easing;
}
.cb__badge:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.cb.is-open .cb__badge { transform: translate(-50%, -50%) scale(.42); opacity: 0; }
.cb__bar {
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-1 $sp-1 $sp-3;
border: 1px solid rgba($subject-ink, .14);
border-radius: $r-control;
background: rgba(255, 255, 255, .55);
}
.cb__icon { display: inline-flex; color: $color; }
.cb__out {
flex: 1 1 auto; min-width: 0;
font-size: 11px; line-height: 15px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cb__toggle {
box-sizing: border-box;
height: 24px;
margin: 0; padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 11px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.cb__toggle:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 자동 루프: 확산 → 유지 → 수축. 0% 와 100% 가 같은 합쳐진 상태다 (복귀는 86% 에 끝난다) ──
.cb.is-demo .cb__pin {
animation-name: cb-burst;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: var(--d);
}
.cb.is-demo .cb__badge {
animation-name: cb-merge;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@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); }
}
@keyframes cb-merge {
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
10% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
32% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
68% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
86%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
// 좁은 화면 — 지도가 낮아진 만큼 부채꼴을 좁힌다. 간격은 올리지 않는다 (BD9)
@media (max-width: 400px) {
.cb { --rx: 64px; --ry: 24px; }
.cb__tag { font-size: 7px; line-height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__pin, .cb.is-demo .cb__badge { animation: none !important; }
.cb__pin, .cb__badge { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
--rx: 92px;
--ry: 40px;
}
.cb__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 16% 78%, #c9e0bd 0 34px, transparent 34px), linear-gradient(100deg, transparent 0 74%, #bcd9ea 74% 82%, transparent 82%), repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 31px), repeating-linear-gradient(0deg, #fffdf7 0 7px, transparent 7px 25px), #efe9dc;
}
.cb__map::before, .cb__map::after {
content: "";
position: absolute;
border-radius: 4px;
background: #e2dac9;
pointer-events: none;
}
.cb__map::before {
left: 8%;
top: 12%;
width: 70px;
height: 24px;
}
.cb__map::after {
right: 9%;
bottom: 10%;
width: 58px;
height: 30px;
}
.cb__group {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
}
.cb__pin {
position: absolute;
left: 50%;
top: 50%;
width: 20px;
height: 20px;
color: #2f6df6;
transform-origin: 50% 100%;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
opacity: 0;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
transition-delay: var(--d);
}
.cb__pin svg {
display: block;
}
.cb.is-open .cb__pin {
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
opacity: 1;
}
.cb__tag {
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
padding: 0 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
color: #17141a;
font-size: 8px;
line-height: 13px;
font-weight: 700;
white-space: nowrap;
}
.cb__pin.cb--low .cb__tag {
top: calc(100% + 13px);
}
.cb__badge {
position: absolute;
left: 50%;
top: 50%;
box-sizing: border-box;
width: 38px;
height: 38px;
margin: 0;
padding: 0;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: 15px;
line-height: 34px;
font-weight: 800;
cursor: pointer;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__badge:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cb.is-open .cb__badge {
transform: translate(-50%, -50%) scale(0.42);
opacity: 0;
}
.cb__bar {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 4px 4px 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.55);
}
.cb__icon {
display: inline-flex;
color: #2f6df6;
}
.cb__out {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 15px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cb__toggle {
box-sizing: border-box;
height: 24px;
margin: 0;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.cb__toggle:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cb.is-demo .cb__pin {
animation-name: cb-burst;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: var(--d);
}
.cb.is-demo .cb__badge {
animation-name: cb-merge;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cb-burst {
0% {
opacity: 0;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
}
10% {
opacity: 0;
transform: translate(-50%, -100%) translate(0, 0) scale(0.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(0.3);
}
}
@keyframes cb-merge {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
10% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
32% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
68% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
86%, 100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@media (max-width: 400px) {
.cb {
--rx: 64px;
--ry: 24px;
}
.cb__tag {
font-size: 7px;
line-height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__pin, .cb.is-demo .cb__badge {
animation: none !important;
}
.cb__pin, .cb__badge {
transition: none;
}
}// 실제 동작: 배지나 버튼을 누르면 핀이 부채꼴로 흩어지고 다시 누르면 배지 하나로 합쳐진다. Escape 로 접는다.
var root = document.querySelector('.cb');
var badge = document.getElementById('cb-badge');
var toggle = document.getElementById('cb-toggle');
var group = document.getElementById('cb-group');
var out = document.getElementById('cb-out');
var ON = '접기', OFF = '펼치기';
var ONMSG = '매장 6곳이 펼쳐졌습니다', OFFMSG = '매장 6곳이 한 자리에 모여 있습니다';
function set(on) {
root.classList.remove('is-demo'); // 사람이 만졌으니 자동 루프를 멈춘다
root.classList.toggle('is-open', on);
badge.setAttribute('aria-expanded', String(on));
toggle.setAttribute('aria-expanded', String(on));
group.setAttribute('aria-hidden', String(!on));
toggle.textContent = on ? ON : OFF;
out.textContent = on ? ONMSG : OFFMSG;
}
function flip() { set(!root.classList.contains('is-open')); }
badge.addEventListener('click', flip);
toggle.addEventListener('click', flip);
root.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { set(false); badge.focus({ preventScroll: true }); }
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ClusterBurst.module.scss";
export type ClusterBurstProps = {
stores?: string[];
labels?: { expand: string; collapse: string; merged: string; spread: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PinVars = CSSProperties & { "--kx"?: string; "--ky"?: string; "--d"?: string };
// React 이벤트 타입은 'react' 에서 import 하지 않는다 — 포트 안에서 필요한 모양만 선언한다
type KeyEvt = { key: string };
export default function ClusterBurst({
stores = ["Hangang", "Seoul Forest", "Seongsu", "Yeonmujang", "Ttukseom", "Wangsimni"],
labels = { expand: "Expand", collapse: "Collapse", merged: "stores are merged into one spot", spread: "stores are fanned out" },
duration = 300,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ClusterBurstProps) {
const [open, setOpen] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function set(on: boolean) {
setDemo(false); // 사람이 만졌으니 자동 루프를 멈춘다
setOpen(on);
}
// 부채꼴: 170° 에서 10° 까지 고르게. 이름표는 두 줄로 엇갈려야 겹치지 않는다
const fan = stores.map((name, i) => {
const a = ((170 - (i * 160) / Math.max(stores.length - 1, 1)) * Math.PI) / 180;
return { name, kx: Math.cos(a), ky: -Math.sin(a), low: i % 2 === 1 };
});
return (
<div className={`${styles.cb} ${open ? styles.isOpen : ""} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.map}>
<ul className={styles.group} id="cb-group" aria-hidden={!open}>
{fan.map((p, i) => {
const pv: PinVars = { "--kx": p.kx.toFixed(3), "--ky": p.ky.toFixed(3), "--d": `${i * 30}ms` };
return (
<li key={p.name} className={`${styles.pin} ${p.low ? styles.low : ""}`} style={pv}>
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" /><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" /></g></svg>
<span className={styles.tag}>{p.name}</span>
</li>
);
})}
</ul>
<button
type="button"
className={styles.badge}
aria-expanded={open}
aria-controls="cb-group"
aria-label={`${labels.expand} ${stores.length}`}
onClick={() => set(!open)}
>
{stores.length}
</button>
</div>
<div className={styles.bar}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2" /><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z" /></g></svg>
</span>
<span className={styles.out} role="status" aria-live="polite">
{stores.length} {open ? labels.spread : labels.merged}
</span>
<button
type="button"
className={styles.toggle}
aria-expanded={open}
aria-controls="cb-group"
onClick={() => set(!open)}
onKeyDown={(e: KeyEvt) => { if (e.key === "Escape") set(false); }}
>
{open ? labels.collapse : labels.expand}
</button>
</div>
</div>
);
}02 · 숫자 배지 클러스터 확산 — 측정 누적면적 5.049% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
핀마다 방향 계수 두 개(--kx·--ky)만 다르고 반지름(--rx 92px·--ry 40px)은 공통이라, 좁은 화면에서는 반지름 두 값만 줄이면 핀이 지도 밖으로 나가지 않습니다.
@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배로 커지며 링이 퍼지고, 반대로 핀에 마우스를 올리면 카드 테두리가 켜집니다.

<main class="stage">
<div class="cl is-demo">
<div class="cl__map">
<button class="cl__pin cl--a" type="button" data-id="a" style="left:26%;top:40%" aria-pressed="false" aria-label="성수 로스터리 핀">
<span class="cl__ring" aria-hidden="true"></span>
<span class="cl__head" aria-hidden="true"><svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
</button>
<button class="cl__pin cl--b" type="button" data-id="b" style="left:57%;top:66%" aria-pressed="false" aria-label="뚝섬 베이커리 핀">
<span class="cl__ring" aria-hidden="true"></span>
<span class="cl__head" aria-hidden="true"><svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
</button>
<button class="cl__pin cl--c" type="button" data-id="c" style="left:80%;top:32%" aria-pressed="false" aria-label="서울숲 책방 핀">
<span class="cl__ring" aria-hidden="true"></span>
<span class="cl__head" aria-hidden="true"><svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
</button>
</div>
<ul class="cl__list">
<li><button class="cl__card cl--a" type="button" data-id="a" aria-pressed="false">
<span class="cl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span>
<span class="cl__txt"><span class="cl__name">성수 로스터리</span><span class="cl__dist">320 m</span></span>
</button></li>
<li><button class="cl__card cl--b" type="button" data-id="b" aria-pressed="false">
<span class="cl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span>
<span class="cl__txt"><span class="cl__name">뚝섬 베이커리</span><span class="cl__dist">540 m</span></span>
</button></li>
<li><button class="cl__card cl--c" type="button" data-id="c" aria-pressed="false">
<span class="cl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span>
<span class="cl__txt"><span class="cl__name">서울숲 책방</span><span class="cl__dist">780 m</span></span>
</button></li>
</ul>
<span class="cl__sr" id="cl-out" role="status" aria-live="polite">지도 핀 강조 · 성수 로스터리</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
// 가짜 지도 — 외부 타일·이미지·폰트 0. 그라디언트만 겹친다. 이 색들은 토큰에 없는 지도 전용색이다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.cl {
position: relative;
box-sizing: border-box;
width: min(408px, 88vw);
display: grid;
grid-template-columns: 1fr 150px;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
// ── 지도판: 공원 원 → 강 띠 → 세로 도로 → 가로 도로 → 땅. 배경은 고정, 움직이는 건 핀뿐 ──
.cl__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
border-radius: $r-card;
box-shadow: $shadow-raised;
background:
radial-gradient(circle at 34% 86%, $map-park 0 30px, transparent 30px),
linear-gradient(96deg, transparent 0 62%, $map-water 62% 69%, transparent 69%),
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 27px),
repeating-linear-gradient(0deg, $map-road 0 7px, transparent 7px 29px),
$map-land;
}
.cl__map::before, .cl__map::after {
content: "";
position: absolute;
border-radius: $r-xs;
background: $map-block;
pointer-events: none;
}
.cl__map::before { left: 10%; top: 14%; width: 56px; height: 28px; }
.cl__map::after { left: 44%; top: 20%; width: 40px; height: 40px; }
.cl__pin {
position: absolute;
box-sizing: border-box;
width: 22px; height: 22px;
margin: 0; padding: 0;
border: 0;
background: none;
color: $subject-ink;
cursor: pointer;
transform-origin: 50% 100%; // 규칙8: 핀 꼭짓점 찍힌 채 커짐
transform: translate(-50%, -100%) scale(1);
transition: transform $duration $easing, color $duration $easing;
}
.cl__pin svg { display: block; }
.cl__pin:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.cl__pin.is-on, .cl__pin:hover { color: $color; transform: translate(-50%, -100%) scale(1.42); }
.cl__ring {
position: absolute;
left: 50%; top: 100%;
width: 34px; height: 34px;
border: 2px solid $color;
border-radius: 50%;
transform: translate(-50%, -50%) scale(.4);
opacity: 0;
pointer-events: none;
}
// 링은 켜져 있는 동안 계속 퍼진다 — 두 끝값이 모두 투명이라 transition 으로는 아예 안 보인다
.cl__pin.is-on .cl__ring, .cl__pin:hover .cl__ring {
animation-name: cl-ring-pulse;
animation-duration: $dur-enter;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes cl-ring-pulse {
0% { opacity: .6; transform: translate(-50%, -50%) scale(.55); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.9); }
}
.cl__list { margin: 0; padding: 0; list-style: none; display: grid; gap: $sp-2; align-content: start; }
.cl__card {
box-sizing: border-box;
width: 100%; height: 48px;
display: flex; align-items: center; gap: $sp-2;
margin: 0; padding: 0 $sp-2;
border: 1px solid rgba(255, 255, 255, .22);
border-radius: $r-sm;
background: rgba(255, 255, 255, .1);
color: $subject-ink;
font-family: inherit;
text-align: left;
cursor: pointer;
transform: translateY(0);
transition: transform $duration $easing, border-color $duration $easing, background $duration $easing;
}
.cl__card:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.cl__card.is-on, .cl__card:hover {
border-color: $subject-cream;
background: rgba(255, 255, 255, .2);
transform: translateY(-2px);
}
.cl__ic { display: inline-flex; flex: 0 0 auto; color: $subject-ink; }
.cl__txt { display: flex; flex-direction: column; min-width: 0; }
.cl__name {
font-size: 11px; line-height: 15px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cl__dist {
font-size: 9px; line-height: 12px; font-weight: 600;
color: $subject-ink;
}
// 읽어 주기만 하는 상태 문구 — 높이를 차지하면 320px 에서 넘친다
.cl__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 자동 루프: 1 → 2 → 3 카드가 차례로 켜진다. 상태 교체는 steps(1, end) 컷 — 크로스페이드는 poster 에 고스팅을 남긴다 ──
.cl.is-demo .cl__card, .cl.is-demo .cl__pin {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cl.is-demo .cl__card.cl--a { animation-name: cl-card-a; }
.cl.is-demo .cl__card.cl--b { animation-name: cl-card-b; }
.cl.is-demo .cl__card.cl--c { animation-name: cl-card-c; }
.cl.is-demo .cl__pin.cl--a { animation-name: cl-pin-a; }
.cl.is-demo .cl__pin.cl--b { animation-name: cl-pin-b; }
.cl.is-demo .cl__pin.cl--c { animation-name: cl-pin-c; }
// 링만 컷이 아니라 실제로 퍼진다 — 카드 교체와 다른 축이라 고스팅이 안 생긴다
.cl.is-demo .cl__ring {
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.cl.is-demo .cl--a .cl__ring { animation-name: cl-ring-a; }
.cl.is-demo .cl--b .cl__ring { animation-name: cl-ring-b; }
.cl.is-demo .cl--c .cl__ring { animation-name: cl-ring-c; }
@keyframes cl-card-a {
0% { border-color: rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .1); transform: translateY(0); }
4% { border-color: $subject-cream; background: rgba(255, 255, 255, .2); transform: translateY(-2px); }
34% { border-color: rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .1); transform: translateY(0); }
100% { border-color: rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .1); transform: translateY(0); }
}
@keyframes cl-card-b {
0% { border-color: rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .1); transform: translateY(0); }
34% { border-color: $subject-cream; background: rgba(255, 255, 255, .2); transform: translateY(-2px); }
64% { border-color: rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .1); transform: translateY(0); }
100% { border-color: rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .1); transform: translateY(0); }
}
@keyframes cl-card-c {
0% { border-color: rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .1); transform: translateY(0); }
64% { border-color: $subject-cream; background: rgba(255, 255, 255, .2); transform: translateY(-2px); }
88% { border-color: rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .1); transform: translateY(0); }
100% { border-color: rgba(255, 255, 255, .22); background: rgba(255, 255, 255, .1); transform: translateY(0); }
}
@keyframes cl-pin-a {
0% { color: $subject-ink; transform: translate(-50%, -100%) scale(1); }
4% { color: $color; transform: translate(-50%, -100%) scale(1.42); }
34% { color: $subject-ink; transform: translate(-50%, -100%) scale(1); }
100% { color: $subject-ink; transform: translate(-50%, -100%) scale(1); }
}
@keyframes cl-pin-b {
0% { color: $subject-ink; transform: translate(-50%, -100%) scale(1); }
34% { color: $color; transform: translate(-50%, -100%) scale(1.42); }
64% { color: $subject-ink; transform: translate(-50%, -100%) scale(1); }
100% { color: $subject-ink; transform: translate(-50%, -100%) scale(1); }
}
@keyframes cl-pin-c {
0% { color: $subject-ink; transform: translate(-50%, -100%) scale(1); }
64% { color: $color; transform: translate(-50%, -100%) scale(1.42); }
88% { color: $subject-ink; transform: translate(-50%, -100%) scale(1); }
100% { color: $subject-ink; transform: translate(-50%, -100%) scale(1); }
}
@keyframes cl-ring-a {
0%, 4% { opacity: 0; transform: translate(-50%, -50%) scale(.4); }
9% { opacity: .55; transform: translate(-50%, -50%) scale(.55); }
30% { opacity: 0; transform: translate(-50%, -50%) scale(1.9); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(.4); }
}
@keyframes cl-ring-b {
0%, 34% { opacity: 0; transform: translate(-50%, -50%) scale(.4); }
39% { opacity: .55; transform: translate(-50%, -50%) scale(.55); }
60% { opacity: 0; transform: translate(-50%, -50%) scale(1.9); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(.4); }
}
@keyframes cl-ring-c {
0%, 64% { opacity: 0; transform: translate(-50%, -50%) scale(.4); }
69% { opacity: .55; transform: translate(-50%, -50%) scale(.55); }
88% { opacity: 0; transform: translate(-50%, -50%) scale(1.9); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(.4); }
}
// 좁은 화면 — 목록을 지도 아래 가로 3칸으로. 간격은 올리지 않는다 (BD9)
@media (max-width: 400px) {
.cl { grid-template-columns: 1fr; }
.cl__list { grid-template-columns: repeat(3, 1fr); gap: $sp-1; }
.cl__card { height: 36px; gap: $sp-1; padding: 0 $sp-1; }
.cl__ic { display: none; }
.cl__name { font-size: 9px; line-height: 12px; }
.cl__dist { font-size: 8px; line-height: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.cl.is-demo .cl__card,
.cl.is-demo .cl__pin,
.cl.is-demo .cl__ring,
.cl__pin.is-on .cl__ring,
.cl__pin:hover .cl__ring { animation: none !important; }
.cl__card, .cl__pin { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
position: relative;
box-sizing: border-box;
width: min(408px, 88vw);
display: grid;
grid-template-columns: 1fr 150px;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.cl__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 34% 86%, #c9e0bd 0 30px, transparent 30px), linear-gradient(96deg, transparent 0 62%, #bcd9ea 62% 69%, transparent 69%), repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 27px), repeating-linear-gradient(0deg, #fffdf7 0 7px, transparent 7px 29px), #efe9dc;
}
.cl__map::before, .cl__map::after {
content: "";
position: absolute;
border-radius: 4px;
background: #e2dac9;
pointer-events: none;
}
.cl__map::before {
left: 10%;
top: 14%;
width: 56px;
height: 28px;
}
.cl__map::after {
left: 44%;
top: 20%;
width: 40px;
height: 40px;
}
.cl__pin {
position: absolute;
box-sizing: border-box;
width: 22px;
height: 22px;
margin: 0;
padding: 0;
border: 0;
background: none;
color: #17141a;
cursor: pointer;
transform-origin: 50% 100%;
transform: translate(-50%, -100%) scale(1);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__pin svg {
display: block;
}
.cl__pin:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.cl__pin.is-on, .cl__pin:hover {
color: #2f6df6;
transform: translate(-50%, -100%) scale(1.42);
}
.cl__ring {
position: absolute;
left: 50%;
top: 100%;
width: 34px;
height: 34px;
border: 2px solid #2f6df6;
border-radius: 50%;
transform: translate(-50%, -50%) scale(0.4);
opacity: 0;
pointer-events: none;
}
.cl__pin.is-on .cl__ring, .cl__pin:hover .cl__ring {
animation-name: cl-ring-pulse;
animation-duration: 600ms;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes cl-ring-pulse {
0% {
opacity: 0.6;
transform: translate(-50%, -50%) scale(0.55);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.9);
}
}
.cl__list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
align-content: start;
}
.cl__card {
box-sizing: border-box;
width: 100%;
height: 48px;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 0 8px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #17141a;
font-family: inherit;
text-align: left;
cursor: pointer;
transform: translateY(0);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__card:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cl__card.is-on, .cl__card:hover {
border-color: #fff7e6;
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.cl__ic {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.cl__txt {
display: flex;
flex-direction: column;
min-width: 0;
}
.cl__name {
font-size: 11px;
line-height: 15px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cl__dist {
font-size: 9px;
line-height: 12px;
font-weight: 600;
color: #17141a;
}
.cl__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cl.is-demo .cl__card, .cl.is-demo .cl__pin {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cl.is-demo .cl__card.cl--a {
animation-name: cl-card-a;
}
.cl.is-demo .cl__card.cl--b {
animation-name: cl-card-b;
}
.cl.is-demo .cl__card.cl--c {
animation-name: cl-card-c;
}
.cl.is-demo .cl__pin.cl--a {
animation-name: cl-pin-a;
}
.cl.is-demo .cl__pin.cl--b {
animation-name: cl-pin-b;
}
.cl.is-demo .cl__pin.cl--c {
animation-name: cl-pin-c;
}
.cl.is-demo .cl__ring {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.cl.is-demo .cl--a .cl__ring {
animation-name: cl-ring-a;
}
.cl.is-demo .cl--b .cl__ring {
animation-name: cl-ring-b;
}
.cl.is-demo .cl--c .cl__ring {
animation-name: cl-ring-c;
}
@keyframes cl-card-a {
0% {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
transform: translateY(0);
}
4% {
border-color: #fff7e6;
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
34% {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
transform: translateY(0);
}
100% {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
transform: translateY(0);
}
}
@keyframes cl-card-b {
0% {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
transform: translateY(0);
}
34% {
border-color: #fff7e6;
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
64% {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
transform: translateY(0);
}
100% {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
transform: translateY(0);
}
}
@keyframes cl-card-c {
0% {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
transform: translateY(0);
}
64% {
border-color: #fff7e6;
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
88% {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
transform: translateY(0);
}
100% {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
transform: translateY(0);
}
}
@keyframes cl-pin-a {
0% {
color: #17141a;
transform: translate(-50%, -100%) scale(1);
}
4% {
color: #2f6df6;
transform: translate(-50%, -100%) scale(1.42);
}
34% {
color: #17141a;
transform: translate(-50%, -100%) scale(1);
}
100% {
color: #17141a;
transform: translate(-50%, -100%) scale(1);
}
}
@keyframes cl-pin-b {
0% {
color: #17141a;
transform: translate(-50%, -100%) scale(1);
}
34% {
color: #2f6df6;
transform: translate(-50%, -100%) scale(1.42);
}
64% {
color: #17141a;
transform: translate(-50%, -100%) scale(1);
}
100% {
color: #17141a;
transform: translate(-50%, -100%) scale(1);
}
}
@keyframes cl-pin-c {
0% {
color: #17141a;
transform: translate(-50%, -100%) scale(1);
}
64% {
color: #2f6df6;
transform: translate(-50%, -100%) scale(1.42);
}
88% {
color: #17141a;
transform: translate(-50%, -100%) scale(1);
}
100% {
color: #17141a;
transform: translate(-50%, -100%) scale(1);
}
}
@keyframes cl-ring-a {
0%, 4% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.4);
}
9% {
opacity: 0.55;
transform: translate(-50%, -50%) scale(0.55);
}
30% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.9);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.4);
}
}
@keyframes cl-ring-b {
0%, 34% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.4);
}
39% {
opacity: 0.55;
transform: translate(-50%, -50%) scale(0.55);
}
60% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.9);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.4);
}
}
@keyframes cl-ring-c {
0%, 64% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.4);
}
69% {
opacity: 0.55;
transform: translate(-50%, -50%) scale(0.55);
}
88% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.9);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.4);
}
}
@media (max-width: 400px) {
.cl {
grid-template-columns: 1fr;
}
.cl__list {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.cl__card {
height: 36px;
gap: 4px;
padding: 0 4px;
}
.cl__ic {
display: none;
}
.cl__name {
font-size: 9px;
line-height: 12px;
}
.cl__dist {
font-size: 8px;
line-height: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl.is-demo .cl__card,
.cl.is-demo .cl__pin,
.cl.is-demo .cl__ring,
.cl__pin.is-on .cl__ring,
.cl__pin:hover .cl__ring {
animation: none !important;
}
.cl__card, .cl__pin {
transition: none;
}
}// 실제 동작: 카드 ↔ 핀 양방향. 마우스는 hover, 키보드는 Tab 포커스와 화살표 이동으로 같은 id 를 켠다.
var root = document.querySelector('.cl');
var cards = [].slice.call(root.querySelectorAll('.cl__card'));
var pins = [].slice.call(root.querySelectorAll('.cl__pin'));
var out = document.getElementById('cl-out');
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;
}
cards.concat(pins).forEach(function (el) {
var id = el.getAttribute('data-id');
el.addEventListener('mouseenter', function () { light(id); });
el.addEventListener('focus', function () { light(id); });
el.addEventListener('click', function () { light(id); });
});
root.addEventListener('keydown', function (e) {
var list = e.target.classList.contains('cl__pin') ? pins : cards;
var i = list.indexOf(e.target);
if (i < 0) return;
var k = e.key;
var n = (k === 'ArrowDown' || k === 'ArrowRight') ? i + 1 : (k === 'ArrowUp' || k === 'ArrowLeft') ? i - 1 : -1;
if (n < 0 || n >= list.length) return;
list[n].focus({ preventScroll: true });
e.preventDefault();
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CardPinLink.module.scss";
export type Place = { id: string; name: string; dist: string; x: string; y: string };
export type CardPinLinkProps = {
places?: Place[];
pinLabel?: string;
statusLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// React 이벤트 타입은 'react' 에서 import 하지 않는다 — 포트 안에서 필요한 모양만 선언한다
type KeyEvt = { key: string; preventDefault: () => void };
const PIN = (
<svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" /><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" /></g></svg>
);
const SHOP = (
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2" /><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z" /></g></svg>
);
export default function CardPinLink({
places = [
{ id: "a", name: "Seongsu Roastery", dist: "320 m", x: "26%", y: "40%" },
{ id: "b", name: "Ttukseom Bakery", dist: "540 m", x: "57%", y: "66%" },
{ id: "c", name: "Seoul Forest Books", dist: "780 m", x: "80%", y: "32%" },
],
pinLabel = "pin",
statusLabel = "Highlighted pin",
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CardPinLinkProps) {
const [on, setOn] = useState("");
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function light(id: string) {
setDemo(false); // 사람이 만졌으니 자동 루프를 멈춘다
setOn(id);
}
function move(i: number, k: string, group: string) {
const n = k === "ArrowDown" || k === "ArrowRight" ? i + 1 : k === "ArrowUp" || k === "ArrowLeft" ? i - 1 : -1;
if (n < 0 || n >= places.length) return false;
const el = document.getElementById(`${group}-${places[n].id}`);
if (el) el.focus({ preventScroll: true });
return true;
}
const hit = places.filter((p) => p.id === on)[0];
return (
<div className={`${styles.cl} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.map}>
{places.map((p, i) => (
<button
key={p.id}
id={`pin-${p.id}`}
type="button"
className={`${styles.pin} ${styles[p.id]} ${on === p.id ? styles.isOn : ""}`}
style={{ left: p.x, top: p.y }}
aria-pressed={on === p.id}
aria-label={`${p.name} ${pinLabel}`}
onMouseEnter={() => light(p.id)}
onFocus={() => light(p.id)}
onClick={() => light(p.id)}
onKeyDown={(e: KeyEvt) => { if (move(i, e.key, "pin")) e.preventDefault(); }}
>
<span className={styles.ring} aria-hidden="true" />
<span className={styles.head} aria-hidden="true">{PIN}</span>
</button>
))}
</div>
<ul className={styles.list}>
{places.map((p, i) => (
<li key={p.id}>
<button
id={`card-${p.id}`}
type="button"
className={`${styles.card} ${styles[p.id]} ${on === p.id ? styles.isOn : ""}`}
aria-pressed={on === p.id}
onMouseEnter={() => light(p.id)}
onFocus={() => light(p.id)}
onClick={() => light(p.id)}
onKeyDown={(e: KeyEvt) => { if (move(i, e.key, "card")) e.preventDefault(); }}
>
<span className={styles.ic} aria-hidden="true">{SHOP}</span>
<span className={styles.txt}>
<span className={styles.name}>{p.name}</span>
<span className={styles.dist}>{p.dist}</span>
</span>
</button>
</li>
))}
</ul>
<span className={styles.sr} role="status" aria-live="polite">
{statusLabel} · {hit ? hit.name : places[0].name}
</span>
</div>
);
}03 · 결과 카드와 핀의 양방향 하이라이트 — 측정 누적면적 21.182% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
양쪽을 잇는 건 data-id 하나뿐이라 카드와 핀의 DOM 순서가 달라도 상관없습니다.
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까지 올라가며, 원 안에 든 핀만 진해집니다.

<main class="stage">
<div class="rs is-demo">
<div class="rs__map">
<span class="rs__ring" aria-hidden="true"></span>
<span class="rs__me" aria-hidden="true"></span>
<!-- 핀마다 data-dist = 중심에서의 거리(최대 반경을 100 으로 본 값). JS 가 슬라이더 값과 견준다 -->
<span class="rs__pin" data-dist="33" style="left:50%;top:66.5%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="33" style="left:44%;top:41.7%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="47" style="left:58%;top:63.5%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="47" style="left:58.5%;top:38.3%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="79" style="left:34.5%;top:63.3%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="95" style="left:67.5%;top:27.8%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
</div>
<div class="rs__bar">
<label class="rs__cap" for="rs-range">반경</label>
<input class="rs__range" id="rs-range" type="range" min="30" max="100" step="1" value="55" aria-valuetext="1.8km">
<span class="rs__read">
<span class="rs__km" id="rs-km">1.8km</span>
<span class="rs__cut rs__cut--a" aria-hidden="true">1.0km</span>
<span class="rs__cut rs__cut--b" aria-hidden="true">2.1km</span>
<span class="rs__cut rs__cut--c" aria-hidden="true">3.2km</span>
</span>
</div>
<p class="rs__hint">밀거나 화살표 키로 반경을 바꾸세요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 가짜 지도 캔버스 — 외부 타일·이미지 없이 그라디언트만. 9개 데모가 같은 색을 쓴다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.rs {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
// ── 지도 ─────────────────────────────────────────────
// 세로 12 : 가로 5. 이 비율이 고정돼 있어야 핀의 data-dist(거리)와 원의 반경이 같은 자로 잰 값이 된다
.rs__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
aspect-ratio: 12 / 5;
background:
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 30px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 26px),
radial-gradient(circle at 16% 22%, $map-park 0 30px, transparent 30px),
linear-gradient(101deg, transparent 0 78%, $map-water 78% 90%, transparent 90%),
$map-land;
}
// 블록 두 개 — 지도에 변화를 준다. 배경과 함께 멈춰 있다(움직이는 건 원·핀뿐)
.rs__map::before,
.rs__map::after {
content: "";
position: absolute;
z-index: 0;
background: $map-block;
}
.rs__map::before { left: 6%; top: 58%; width: 15%; height: 30%; }
.rs__map::after { left: 76%; top: 12%; width: 12%; height: 34%; }
// 반경 원 — 지름이 지도 높이와 같을 때가 scale(1)
.rs__ring {
position: absolute;
z-index: 1;
left: 50%; top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, .6);
border-radius: 50%;
background: rgba(47, 109, 246, .12);
transform: translate(-50%, -50%) scale(var(--k, .55));
transition: transform $duration $easing;
}
.rs__me {
position: absolute;
z-index: 3;
left: 50%; top: 50%;
width: 8px; height: 8px;
border: 2px solid $stage-paper;
border-radius: 50%;
background: $subject-ink;
transform: translate(-50%, -50%);
}
.rs__pin {
position: absolute;
z-index: 2;
display: block;
color: $color;
opacity: .26;
transform: translate(-50%, -100%);
transition: opacity $duration steps(1, end);
}
.rs__pin.is-in { opacity: 1; }
// ── 컨트롤 ────────────────────────────────────────────
.rs__bar {
display: flex;
align-items: center;
gap: $sp-2;
}
.rs__cap {
flex: 0 0 auto;
font-size: 11px; line-height: 14px; font-weight: 700;
letter-spacing: .02em;
color: rgba(23, 20, 26, .62);
}
.rs__range {
flex: 1 1 auto;
min-width: 0;
height: 16px;
margin: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
cursor: pointer;
}
.rs__range::-webkit-slider-runnable-track {
height: 6px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .14);
}
.rs__range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
margin-top: -4px;
border: 0;
border-radius: 50%;
background: $color;
}
.rs__range::-moz-range-track { height: 6px; border-radius: $r-pill; background: rgba(23, 20, 26, .14); }
.rs__range::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: $color; }
.rs__range:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
// 거리 읽기 — 사람이 잡으면 .rs__km 이 실시간, 자동 루프 때는 컷 3장이 번갈아 뜬다
.rs__read {
position: relative;
flex: 0 0 auto;
width: 44px;
height: 16px;
text-align: right;
}
.rs__km,
.rs__cut {
position: absolute;
right: 0; top: 0;
font-size: 12px; line-height: 16px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: $color;
white-space: nowrap;
}
.rs__cut { opacity: 0; }
.rs.is-demo .rs__km { opacity: 0; }
.rs__hint {
margin: 0;
font-size: 10px; line-height: 14px; font-weight: 600;
color: rgba($stage-ink, .72);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 반경 값 v 는 0%→50% 에서 30→100 으로, 50%→85% 에서 100→30 으로 선형으로 움직인다.
// 핀과 거리 컷은 그 v 가 임계를 넘는 시각에 steps(1, end) 로 딱 끊어 바뀐다 — 반투명 교차가 없다.
.rs.is-demo .rs__ring {
transition: none;
animation-name: rs-ring;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rs.is-demo .rs__pin {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__pin[data-dist="33"] { animation-name: rs-pin-33; }
.rs.is-demo .rs__pin[data-dist="47"] { animation-name: rs-pin-47; }
.rs.is-demo .rs__pin[data-dist="79"] { animation-name: rs-pin-79; }
.rs.is-demo .rs__pin[data-dist="95"] { animation-name: rs-pin-95; }
.rs.is-demo .rs__cut {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__cut--a { animation-name: rs-km-a; }
.rs.is-demo .rs__cut--b { animation-name: rs-km-b; }
.rs.is-demo .rs__cut--c { animation-name: rs-km-c; }
@keyframes rs-ring {
0% { transform: translate(-50%, -50%) scale(.3); }
50% { transform: translate(-50%, -50%) scale(1); }
85% { transform: translate(-50%, -50%) scale(.3); }
100% { transform: translate(-50%, -50%) scale(.3); }
}
@keyframes rs-pin-33 { 0% { opacity: .26; } 2.14% { opacity: 1; } 83.5% { opacity: .26; } 100% { opacity: .26; } }
@keyframes rs-pin-47 { 0% { opacity: .26; } 12.14% { opacity: 1; } 76.5% { opacity: .26; } 100% { opacity: .26; } }
@keyframes rs-pin-79 { 0% { opacity: .26; } 35% { opacity: 1; } 60.5% { opacity: .26; } 100% { opacity: .26; } }
@keyframes rs-pin-95 { 0% { opacity: .26; } 46.43% { opacity: 1; } 52.5% { opacity: .26; } 100% { opacity: .26; } }
@keyframes rs-km-a { 0% { opacity: 1; } 14.29% { opacity: 0; } 75% { opacity: 1; } 100% { opacity: 1; } }
@keyframes rs-km-b { 0% { opacity: 0; } 14.29% { opacity: 1; } 35.71% { opacity: 0; } 60% { opacity: 1; } 75% { opacity: 0; } 100% { opacity: 0; } }
@keyframes rs-km-c { 0% { opacity: 0; } 35.71% { opacity: 1; } 60% { opacity: 0; } 100% { opacity: 0; } }
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.rs { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__ring,
.rs.is-demo .rs__pin,
.rs.is-demo .rs__cut { animation: none !important; }
.rs.is-demo .rs__km { opacity: 1; } // 컷이 멈추면 실시간 값이 대신 보인다
.rs__ring, .rs__pin { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rs {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.rs__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
aspect-ratio: 12/5;
background: repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 30px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 26px), radial-gradient(circle at 16% 22%, #c9e0bd 0 30px, transparent 30px), linear-gradient(101deg, transparent 0 78%, #bcd9ea 78% 90%, transparent 90%), #efe9dc;
}
.rs__map::before,
.rs__map::after {
content: "";
position: absolute;
z-index: 0;
background: #e2dac9;
}
.rs__map::before {
left: 6%;
top: 58%;
width: 15%;
height: 30%;
}
.rs__map::after {
left: 76%;
top: 12%;
width: 12%;
height: 34%;
}
.rs__ring {
position: absolute;
z-index: 1;
left: 50%;
top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, 0.6);
border-radius: 50%;
background: rgba(47, 109, 246, 0.12);
transform: translate(-50%, -50%) scale(var(--k, 0.55));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rs__me {
position: absolute;
z-index: 3;
left: 50%;
top: 50%;
width: 8px;
height: 8px;
border: 2px solid #ffffff;
border-radius: 50%;
background: #17141a;
transform: translate(-50%, -50%);
}
.rs__pin {
position: absolute;
z-index: 2;
display: block;
color: #2f6df6;
opacity: 0.26;
transform: translate(-50%, -100%);
transition: opacity 220ms steps(1, end);
}
.rs__pin.is-in {
opacity: 1;
}
.rs__bar {
display: flex;
align-items: center;
gap: 8px;
}
.rs__cap {
flex: 0 0 auto;
font-size: 11px;
line-height: 14px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(23, 20, 26, 0.62);
}
.rs__range {
flex: 1 1 auto;
min-width: 0;
height: 16px;
margin: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
cursor: pointer;
}
.rs__range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
}
.rs__range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
margin-top: -4px;
border: 0;
border-radius: 50%;
background: #2f6df6;
}
.rs__range::-moz-range-track {
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
}
.rs__range::-moz-range-thumb {
width: 14px;
height: 14px;
border: 0;
border-radius: 50%;
background: #2f6df6;
}
.rs__range:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 999px;
}
.rs__read {
position: relative;
flex: 0 0 auto;
width: 44px;
height: 16px;
text-align: right;
}
.rs__km,
.rs__cut {
position: absolute;
right: 0;
top: 0;
font-size: 12px;
line-height: 16px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #2f6df6;
white-space: nowrap;
}
.rs__cut {
opacity: 0;
}
.rs.is-demo .rs__km {
opacity: 0;
}
.rs__hint {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.rs.is-demo .rs__ring {
transition: none;
animation-name: rs-ring;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rs.is-demo .rs__pin {
transition: none;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__pin[data-dist="33"] {
animation-name: rs-pin-33;
}
.rs.is-demo .rs__pin[data-dist="47"] {
animation-name: rs-pin-47;
}
.rs.is-demo .rs__pin[data-dist="79"] {
animation-name: rs-pin-79;
}
.rs.is-demo .rs__pin[data-dist="95"] {
animation-name: rs-pin-95;
}
.rs.is-demo .rs__cut {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__cut--a {
animation-name: rs-km-a;
}
.rs.is-demo .rs__cut--b {
animation-name: rs-km-b;
}
.rs.is-demo .rs__cut--c {
animation-name: rs-km-c;
}
@keyframes rs-ring {
0% {
transform: translate(-50%, -50%) scale(0.3);
}
50% {
transform: translate(-50%, -50%) scale(1);
}
85% {
transform: translate(-50%, -50%) scale(0.3);
}
100% {
transform: translate(-50%, -50%) scale(0.3);
}
}
@keyframes rs-pin-33 {
0% {
opacity: 0.26;
}
2.14% {
opacity: 1;
}
83.5% {
opacity: 0.26;
}
100% {
opacity: 0.26;
}
}
@keyframes rs-pin-47 {
0% {
opacity: 0.26;
}
12.14% {
opacity: 1;
}
76.5% {
opacity: 0.26;
}
100% {
opacity: 0.26;
}
}
@keyframes rs-pin-79 {
0% {
opacity: 0.26;
}
35% {
opacity: 1;
}
60.5% {
opacity: 0.26;
}
100% {
opacity: 0.26;
}
}
@keyframes rs-pin-95 {
0% {
opacity: 0.26;
}
46.43% {
opacity: 1;
}
52.5% {
opacity: 0.26;
}
100% {
opacity: 0.26;
}
}
@keyframes rs-km-a {
0% {
opacity: 1;
}
14.29% {
opacity: 0;
}
75% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes rs-km-b {
0% {
opacity: 0;
}
14.29% {
opacity: 1;
}
35.71% {
opacity: 0;
}
60% {
opacity: 1;
}
75% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rs-km-c {
0% {
opacity: 0;
}
35.71% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.rs {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__ring,
.rs.is-demo .rs__pin,
.rs.is-demo .rs__cut {
animation: none !important;
}
.rs.is-demo .rs__km {
opacity: 1;
}
.rs__ring, .rs__pin {
transition: none;
}
}// 실제 동작: 슬라이더를 밀면(마우스) 또는 화살표 키를 누르면(키보드) 반경 원이 커지고,
// 핀마다 data-dist 를 슬라이더 값과 견줘 원 안에 든 핀만 진해진다.
var rsRoot = document.querySelector('.rs');
var rsRange = document.getElementById('rs-range');
var rsRing = rsRoot.querySelector('.rs__ring');
var rsKm = document.getElementById('rs-km');
var rsPins = [].slice.call(rsRoot.querySelectorAll('.rs__pin'));
var RS_MAX_KM = 3.2;
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);
});
}
function rsLive() {
rsRoot.classList.remove('is-demo'); // 사람이 잡으면 자동 루프를 멈춘다
rsPaint();
}
rsRange.addEventListener('input', rsLive);
rsRange.addEventListener('pointerdown', rsLive);
rsRange.addEventListener('keydown', function (e) {
if (e.key.indexOf('Arrow') === 0 || e.key === 'Home' || e.key === 'End') rsLive();
});
rsPaint();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RadiusSlider.module.scss";
export type RadiusPin = {
/** 지도 안 가로 위치 (예: "58%") */
left: string;
/** 지도 안 세로 위치 (예: "34%") */
top: string;
/** 중심에서의 거리 — 최대 반경을 100 으로 본 값. 슬라이더 값과 바로 견준다 */
dist: number;
};
export type RadiusSliderProps = {
label?: string;
hint?: string;
pins?: RadiusPin[];
maxKm?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--k"?: string;
};
type ChangeEvt = { target: { value: string } };
const DEFAULT_PINS: RadiusPin[] = [
{ left: "50%", top: "66.5%", dist: 33 },
{ left: "44%", top: "41.7%", dist: 33 },
{ left: "58%", top: "63.5%", dist: 47 },
{ left: "58.5%", top: "38.3%", dist: 47 },
{ left: "34.5%", top: "63.3%", dist: 79 },
{ left: "67.5%", top: "27.8%", dist: 95 },
];
function PinIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" />
<path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" />
</g>
</svg>
);
}
export default function RadiusSlider({
label = "Radius",
hint = "Drag, or use the arrow keys",
pins = DEFAULT_PINS,
maxKm = 3.2,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: RadiusSliderProps) {
const [value, setValue] = useState(55);
const km = ((value / 100) * maxKm).toFixed(1) + "km";
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const ringVars: CSSProperties = { transform: `translate(-50%, -50%) scale(${value / 100})` };
return (
<div className={styles.rs} style={vars}>
<div className={styles.map}>
<span className={styles.ring} style={ringVars} aria-hidden="true" />
<span className={styles.me} aria-hidden="true" />
{pins.map((p, i) => (
<span
key={`${p.left}-${p.top}-${i}`}
className={`${styles.pin} ${p.dist <= value ? styles.isIn : ""}`}
style={{ left: p.left, top: p.top }}
aria-hidden="true"
>
<PinIcon />
</span>
))}
</div>
<div className={styles.bar}>
<label className={styles.cap} htmlFor="rs-range">{label}</label>
<input
id="rs-range"
className={styles.range}
type="range"
min={30}
max={100}
step={1}
value={value}
aria-valuetext={km}
onChange={(e: ChangeEvt) => setValue(Number(e.target.value))}
/>
<span className={styles.read}>{km}</span>
</div>
<p className={styles.hint}>{hint}</p>
</div>
);
}04 · 반경 원 거리 슬라이더 — 측정 누적면적 9.331% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
원은 지도 높이와 같은 정사각형에 aspect-ratio: 1을 걸고 scale(var(--k))로만 키우기 때문에, 커지는 동안 지도판 레이아웃은 한 번도 다시 계산되지 않습니다.
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를 누르면 지도 핀이 그 후보의 좌표로 실제로 건너갑니다.

<main class="stage">
<div class="ac is-demo">
<div class="ac__map">
<span class="ac__pin" id="ac-pin" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
</div>
<!-- 후보 목록은 입력칸 위로 열려 지도를 덮는다 — 절대배치라 뿌리 높이엔 안 잡히지만 뿌리 안쪽에 머문다 -->
<ul class="ac__list" id="ac-list" role="listbox" aria-label="주소 후보">
<li class="ac__opt" id="ac-o0" role="option" aria-selected="false" data-x="88%" data-y="30%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>테헤란로 152</span></li>
<li class="ac__opt" id="ac-o1" role="option" aria-selected="false" data-x="74%" data-y="58%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>테헤란로 427</span></li>
<li class="ac__opt" id="ac-o2" role="option" aria-selected="false" data-x="78%" data-y="72%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>테헤란로길 21</span></li>
<li class="ac__opt" id="ac-o3" role="option" aria-selected="false" data-x="92%" data-y="46%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>테헤란로 87길 36</span></li>
</ul>
<div class="ac__field">
<span class="ac__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="ac__input" id="ac-input" type="text" role="combobox" aria-expanded="false" aria-controls="ac-list" aria-autocomplete="list" autocomplete="off" placeholder="주소 검색">
<span class="ac__ghost" aria-hidden="true">테헤란로</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// 가짜 지도 캔버스 — 9개 데모가 같은 색으로 한 가족을 이룬다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.ac {
position: relative;
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
// 목록은 입력칸 위(지도 쪽)로 열린다 — 뿌리 높이를 넘지 않으니 따로 비워 둘 아래 여백이 없다
padding-bottom: 0;
}
// ── 지도 ─────────────────────────────────────────────
.ac__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
aspect-ratio: 12 / 5;
background:
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 32px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 24px),
radial-gradient(circle at 82% 78%, $map-park 0 26px, transparent 26px),
linear-gradient(-72deg, transparent 0 74%, $map-water 74% 86%, transparent 86%),
$map-land;
}
.ac__map::before,
.ac__map::after {
content: "";
position: absolute;
z-index: 0;
background: $map-block;
}
.ac__map::before { left: 12%; top: 16%; width: 14%; height: 32%; }
.ac__map::after { left: 46%; top: 62%; width: 17%; height: 26%; }
// 핀은 지도 오른쪽 띠에 산다 — 목록이 왼쪽 2/3 을 덮어도 핀이 옮겨가는 게 보여야 한다
.ac__pin {
position: absolute;
z-index: 2;
left: 82%; top: 38%;
display: block;
color: $color;
transform: translate(-50%, -100%);
transition: left $dur-base $easing, top $dur-base $easing;
}
// ── 후보 목록 (입력칸 바로 위로 슬라이드해 올라온다) ────────
.ac__list {
position: absolute;
z-index: 3;
left: 0; right: 34%; bottom: 36px;
box-sizing: border-box;
margin: 0;
padding: $sp-1;
list-style: none;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: bottom center; // 규칙8: 입력칸 위변에서 펼침
transform: translateY(10px) scale(.96);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.ac.is-open .ac__list {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.ac__opt {
position: relative;
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
color: $subject-ink;
font-size: 11px; line-height: 14px; font-weight: 600;
cursor: pointer;
}
// 하이라이트 판(::before)이 절대배치라 글자보다 나중에 그려진다 — 글자도 positioned 로 올려 덮이지 않게 한다
.ac__opt > svg { position: relative; z-index: 1; flex: 0 0 auto; color: $color; }
.ac__opt > span { position: relative; z-index: 1; }
.ac__opt::before {
content: "";
position: absolute;
inset: 0;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
opacity: 0;
}
.ac__opt.is-active::before { opacity: 1; }
// ── 검색 입력칸 ───────────────────────────────────────
.ac__field {
position: relative;
height: 28px;
flex: 0 0 auto;
}
.ac__ico {
position: absolute;
left: 11px; top: 50%;
display: inline-flex;
color: rgba(255, 255, 255, .5);
transform: translateY(-50%);
pointer-events: none;
}
.ac__input {
box-sizing: border-box;
width: 100%; height: 100%;
padding: 0 $sp-3 0 $sp-8;
border: 1px solid rgba(255, 255, 255, .16);
border-radius: $r-control;
background: rgba(255, 255, 255, .07);
color: $subject-cream;
font-family: inherit;
font-size: 12px; line-height: 26px; font-weight: 600;
}
.ac__input::placeholder { color: rgba(255, 255, 255, .38); }
.ac__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ac.is-open .ac__input { border-color: $color; }
// 자동 루프 때만 보이는 "타이핑" 유령 글자. 진짜 placeholder 와 겹치지 않게 투명으로 지운다
.ac.is-demo .ac__input::placeholder { color: transparent; }
.ac__ghost {
position: absolute;
left: 33px; top: 0;
color: $subject-cream;
font-size: 12px; line-height: 28px; font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~30% 한 자씩 타이핑 → 32~40% 목록 열림 → 40·50·58% 하이라이트 이동 → 66% 선택(핀 이동)
// → 80~90% 지우기 · 82~90% 목록 닫힘 · 84~90% 핀 복귀. 90% 에 처음 상태로 돌아온다.
.ac.is-demo .ac__ghost {
opacity: 1;
animation-name: ac-type;
animation-duration: $dur-loop;
animation-timing-function: steps(4, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__list {
transition: none;
animation-name: ac-list;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt::before {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt:nth-child(1)::before { animation-name: ac-hl-0; }
.ac.is-demo .ac__opt:nth-child(2)::before { animation-name: ac-hl-1; }
.ac.is-demo .ac__opt:nth-child(3)::before { animation-name: ac-hl-2; }
.ac.is-demo .ac__pin {
transition: none;
animation-name: ac-pin;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes ac-type {
0% { clip-path: inset(0 100% 0 0); }
30% { clip-path: inset(0 0 0 0); }
80% { clip-path: inset(0 0 0 0); }
90% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
@keyframes ac-list {
0% { opacity: 0; transform: translateY(10px) scale(.96); }
32% { opacity: 0; transform: translateY(10px) scale(.96); }
40% { opacity: 1; transform: translateY(0) scale(1); }
82% { opacity: 1; transform: translateY(0) scale(1); }
90% { opacity: 0; transform: translateY(10px) scale(.96); }
100% { opacity: 0; transform: translateY(10px) scale(.96); }
}
@keyframes ac-hl-0 { 0% { opacity: 0; } 40% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 0; } }
@keyframes ac-hl-1 { 0% { opacity: 0; } 50% { opacity: 1; } 58% { opacity: 0; } 100% { opacity: 0; } }
@keyframes ac-hl-2 { 0% { opacity: 0; } 58% { opacity: 1; } 90% { opacity: 0; } 100% { opacity: 0; } }
@keyframes ac-pin {
0% { left: 82%; top: 38%; }
66% { left: 82%; top: 38%; }
74% { left: 78%; top: 72%; }
84% { left: 78%; top: 72%; }
90% { left: 82%; top: 38%; }
100% { left: 82%; top: 38%; }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.ac { gap: $sp-1; }
.ac__list { bottom: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__ghost,
.ac.is-demo .ac__list,
.ac.is-demo .ac__opt::before,
.ac.is-demo .ac__pin { animation: none !important; }
.ac.is-demo .ac__ghost { opacity: 0; }
.ac__list, .ac__pin, .ac__input { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ac {
position: relative;
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding-bottom: 0;
}
.ac__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
aspect-ratio: 12/5;
background: repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 32px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 24px), radial-gradient(circle at 82% 78%, #c9e0bd 0 26px, transparent 26px), linear-gradient(-72deg, transparent 0 74%, #bcd9ea 74% 86%, transparent 86%), #efe9dc;
}
.ac__map::before,
.ac__map::after {
content: "";
position: absolute;
z-index: 0;
background: #e2dac9;
}
.ac__map::before {
left: 12%;
top: 16%;
width: 14%;
height: 32%;
}
.ac__map::after {
left: 46%;
top: 62%;
width: 17%;
height: 26%;
}
.ac__pin {
position: absolute;
z-index: 2;
left: 82%;
top: 38%;
display: block;
color: #2f6df6;
transform: translate(-50%, -100%);
transition: left 300ms cubic-bezier(0.2, 1.4, 0.4, 1), top 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ac__list {
position: absolute;
z-index: 3;
left: 0;
right: 34%;
bottom: 36px;
box-sizing: border-box;
margin: 0;
padding: 4px;
list-style: none;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: bottom center;
transform: translateY(10px) scale(0.96);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ac.is-open .ac__list {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.ac__opt {
position: relative;
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
color: #17141a;
font-size: 11px;
line-height: 14px;
font-weight: 600;
cursor: pointer;
}
.ac__opt > svg {
position: relative;
z-index: 1;
flex: 0 0 auto;
color: #2f6df6;
}
.ac__opt > span {
position: relative;
z-index: 1;
}
.ac__opt::before {
content: "";
position: absolute;
inset: 0;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
opacity: 0;
}
.ac__opt.is-active::before {
opacity: 1;
}
.ac__field {
position: relative;
height: 28px;
flex: 0 0 auto;
}
.ac__ico {
position: absolute;
left: 11px;
top: 50%;
display: inline-flex;
color: rgba(255, 255, 255, 0.5);
transform: translateY(-50%);
pointer-events: none;
}
.ac__input {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0 12px 0 32px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
background: rgba(255, 255, 255, 0.07);
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 26px;
font-weight: 600;
}
.ac__input::placeholder {
color: rgba(255, 255, 255, 0.38);
}
.ac__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ac.is-open .ac__input {
border-color: #2f6df6;
}
.ac.is-demo .ac__input::placeholder {
color: transparent;
}
.ac__ghost {
position: absolute;
left: 33px;
top: 0;
color: #fff7e6;
font-size: 12px;
line-height: 28px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.ac.is-demo .ac__ghost {
opacity: 1;
animation-name: ac-type;
animation-duration: 2s;
animation-timing-function: steps(4, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__list {
transition: none;
animation-name: ac-list;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt::before {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt:nth-child(1)::before {
animation-name: ac-hl-0;
}
.ac.is-demo .ac__opt:nth-child(2)::before {
animation-name: ac-hl-1;
}
.ac.is-demo .ac__opt:nth-child(3)::before {
animation-name: ac-hl-2;
}
.ac.is-demo .ac__pin {
transition: none;
animation-name: ac-pin;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ac-type {
0% {
clip-path: inset(0 100% 0 0);
}
30% {
clip-path: inset(0 0 0 0);
}
80% {
clip-path: inset(0 0 0 0);
}
90% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes ac-list {
0% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
32% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
40% {
opacity: 1;
transform: translateY(0) scale(1);
}
82% {
opacity: 1;
transform: translateY(0) scale(1);
}
90% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
}
@keyframes ac-hl-0 {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-hl-1 {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
58% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-hl-2 {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-pin {
0% {
left: 82%;
top: 38%;
}
66% {
left: 82%;
top: 38%;
}
74% {
left: 78%;
top: 72%;
}
84% {
left: 78%;
top: 72%;
}
90% {
left: 82%;
top: 38%;
}
100% {
left: 82%;
top: 38%;
}
}
@media (max-width: 360px) {
.ac {
gap: 4px;
}
.ac__list {
bottom: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__ghost,
.ac.is-demo .ac__list,
.ac.is-demo .ac__opt::before,
.ac.is-demo .ac__pin {
animation: none !important;
}
.ac.is-demo .ac__ghost {
opacity: 0;
}
.ac__list, .ac__pin, .ac__input {
transition: none;
}
}// 실제 동작: 글자를 치면 후보가 열리고, ↑↓ 로 옮기고, Enter(또는 클릭)로 고르면 지도 핀이 그 좌표로 간다.
var acRoot = document.querySelector('.ac');
var acInput = document.getElementById('ac-input');
var acList = document.getElementById('ac-list');
var acPin = document.getElementById('ac-pin');
var acOpts = [].slice.call(acList.querySelectorAll('.ac__opt'));
var acActive = 0;
function acPaint() {
acOpts.forEach(function (o, i) { o.classList.toggle('is-active', i === acActive); });
acInput.setAttribute('aria-activedescendant', acOpts[acActive].id);
}
function acOpen(on) {
acRoot.classList.remove('is-demo'); // 사람이 치면 자동 루프를 멈춘다
acRoot.classList.toggle('is-open', on);
acInput.setAttribute('aria-expanded', String(on));
if (on) acPaint(); else acInput.removeAttribute('aria-activedescendant');
}
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 });
}
acInput.addEventListener('input', function () { acOpen(acInput.value.length > 0); });
acInput.addEventListener('focus', function () { acOpen(acInput.value.length > 0); });
acInput.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { acActive = Math.min(acActive + 1, acOpts.length - 1); acOpen(true); e.preventDefault(); }
else if (e.key === 'ArrowUp') { acActive = Math.max(acActive - 1, 0); acOpen(true); e.preventDefault(); }
else if (e.key === 'Enter') { acPick(acActive); e.preventDefault(); }
else if (e.key === 'Escape') { acOpen(false); }
});
acOpts.forEach(function (o, i) {
o.addEventListener('mousemove', function () { acActive = i; acPaint(); });
o.addEventListener('click', function () { acPick(i); });
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AddressAuto.module.scss";
export type AddressItem = {
text: string;
/** 지도 안 좌표 (예: "58%") */
x: string;
y: string;
};
export type AddressAutoProps = {
placeholder?: string;
listLabel?: string;
items?: AddressItem[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
const DEFAULT_ITEMS: AddressItem[] = [
{ text: "Teheran-ro 152", x: "88%", y: "30%" },
{ text: "Teheran-ro 427", x: "74%", y: "58%" },
{ text: "Teheran-ro-gil 21", x: "78%", y: "72%" },
{ text: "Teheran-ro 87-gil 36", x: "92%", y: "46%" },
];
function PinIcon({ size }: { size: number }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size} aria-hidden="true">
<g fill="currentColor">
<path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" />
<path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" />
</g>
</svg>
);
}
export default function AddressAuto({
placeholder = "Search an address",
listLabel = "Address suggestions",
items = DEFAULT_ITEMS,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: AddressAutoProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [picked, setPicked] = useState(-1);
const [spot, setSpot] = useState({ left: "82%", top: "38%" });
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("click", onDocClick);
return () => document.removeEventListener("click", onDocClick);
}, []);
function pick(i: number) {
const it = items[i];
setPicked(i);
setQuery(it.text);
setSpot({ left: it.x, top: it.y });
setOpen(false);
inputRef.current?.focus({ preventScroll: true });
}
return (
<div className={`${styles.ac} ${open ? styles.isOpen : ""}`} style={vars} ref={rootRef}>
<div className={styles.map}>
<span className={styles.pin} style={spot} aria-hidden="true"><PinIcon size={20} /></span>
</div>
<ul className={styles.list} id="ac-list" role="listbox" aria-label={listLabel}>
{items.map((it, i) => (
<li
key={it.text}
id={`ac-o${i}`}
className={`${styles.opt} ${i === active ? styles.isActive : ""}`}
role="option"
aria-selected={i === picked}
onMouseMove={() => setActive(i)}
onClick={() => pick(i)}
>
<PinIcon size={13} />
<span>{it.text}</span>
</li>
))}
</ul>
<div className={styles.field}>
<span className={styles.ico} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
autoComplete="off"
placeholder={placeholder}
value={query}
aria-expanded={open}
aria-controls="ac-list"
aria-autocomplete="list"
aria-activedescendant={open ? `ac-o${active}` : undefined}
onChange={(e: ChangeEvt) => { setQuery(e.target.value); setOpen(e.target.value.length > 0); }}
onFocus={() => setOpen(query.length > 0)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown") { setActive((a) => Math.min(a + 1, items.length - 1)); setOpen(true); e.preventDefault(); }
else if (e.key === "ArrowUp") { setActive((a) => Math.max(a - 1, 0)); setOpen(true); e.preventDefault(); }
else if (e.key === "Enter") { pick(active); e.preventDefault(); }
else if (e.key === "Escape") { setOpen(false); }
}}
/>
</div>
</div>
);
}05 · 주소 자동완성 드롭다운 — 측정 누적면적 17.575% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
후보 좌표는 요소마다 data-x·data-y에 지도 상자 크기 기준 퍼센트로 박아 두었습니다.
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초 뒤 스캔이 끝나면 그 자리에서 현재 위치 점이 맥박처럼 뛰기 시작합니다.

<main class="stage">
<div class="lc is-demo">
<div class="lc__map">
<span class="lc__ring lc__ring--1" aria-hidden="true"></span>
<span class="lc__ring lc__ring--2" aria-hidden="true"></span>
<span class="lc__ring lc__ring--3" aria-hidden="true"></span>
<span class="lc__cross" aria-hidden="true"><svg viewBox="0 0 256 256" width="36" height="36"><g fill="currentColor"><path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2"/><path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></svg></span>
<span class="lc__dot" aria-hidden="true"></span>
</div>
<div class="lc__bar">
<!-- 진짜 <button> 이라 마우스 클릭과 키보드 Enter·Space 가 모두 click 으로 들어온다 -->
<button class="lc__btn" type="button" id="lc-btn">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2"/><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z"/></g></svg>
<span>내 위치 찾기</span>
</button>
<span class="lc__read">
<span class="lc__say" id="lc-say" role="status" aria-live="polite">위치를 찾는 중</span>
<span class="lc__cut lc__cut--a" aria-hidden="true">위치를 찾는 중</span>
<span class="lc__cut lc__cut--b" aria-hidden="true">현재 위치 · 12m</span>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
// 가짜 지도 캔버스 — 9개 데모가 같은 색으로 한 가족을 이룬다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.lc {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
// ── 지도 ─────────────────────────────────────────────
.lc__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
aspect-ratio: 12 / 5;
background:
repeating-linear-gradient(90deg, $map-road 0 8px, transparent 8px 28px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 28px),
radial-gradient(circle at 8% 76%, $map-park 0 28px, transparent 28px),
linear-gradient(118deg, transparent 0 82%, $map-water 82% 94%, transparent 94%),
$map-land;
}
.lc__map::before,
.lc__map::after {
content: "";
position: absolute;
z-index: 0;
background: $map-block;
}
.lc__map::before { left: 24%; top: 14%; width: 13%; height: 30%; }
.lc__map::after { left: 68%; top: 56%; width: 15%; height: 30%; }
// 스캔 링 — 지름이 지도 높이와 같을 때가 scale(1)
.lc__ring {
position: absolute;
z-index: 1;
left: 50%; top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, .75);
border-radius: 50%;
background: rgba(47, 109, 246, .08);
opacity: 0;
transform: translate(-50%, -50%) scale(.18);
}
.lc__cross {
position: absolute;
z-index: 2;
left: 50%; top: 50%;
display: block;
color: rgba(23, 20, 26, .5);
transform: translate(-50%, -50%);
}
.lc__dot {
position: absolute;
z-index: 3;
left: 50%; top: 50%;
width: 12px; height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $color;
box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
// ── 컨트롤 ────────────────────────────────────────────
.lc__bar {
display: flex;
align-items: center;
gap: $sp-2;
}
.lc__btn {
flex: 0 0 auto;
box-sizing: border-box;
height: 28px;
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 11px; line-height: 14px; font-weight: 700;
cursor: pointer;
transition: transform $duration $ease-pop, box-shadow $duration $easing;
}
.lc__btn:hover { box-shadow: $shadow-press; }
.lc__btn:active { transform: scale(.96); }
.lc__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 상태 문구 — 사람이 누르면 .lc__say 가 실시간(aria-live), 자동 루프 때는 컷 2장이 번갈아 뜬다
.lc__read {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 16px;
overflow: hidden;
}
.lc__say,
.lc__cut {
position: absolute;
right: 0; top: 0;
font-size: 10px; line-height: 16px; font-weight: 700;
color: rgba(23, 20, 26, .66);
white-space: nowrap;
}
.lc__cut { opacity: 0; }
.lc.is-demo .lc__say { opacity: 0; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 링 3개가 시차를 두고 바깥으로 퍼지고(단축 금지 — 단축이 animation-delay 를 0 으로 지운다),
// 가운데 점은 맥박친다. 상태 문구는 60% 에 "찾는 중 → 현재 위치" 로 끊어 바뀐다.
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring {
animation-name: lc-scan;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__ring--2,
.lc.is-scanning .lc__ring--2 { animation-delay: -667ms; }
.lc.is-demo .lc__ring--3,
.lc.is-scanning .lc__ring--3 { animation-delay: -1334ms; }
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot {
animation-name: lc-pulse;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__cut {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lc.is-demo .lc__cut--a { animation-name: lc-say-a; }
.lc.is-demo .lc__cut--b { animation-name: lc-say-b; }
@keyframes lc-scan {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.18); }
14% { opacity: .9; transform: translate(-50%, -50%) scale(.4); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.04); }
}
@keyframes lc-pulse {
0% { transform: translate(-50%, -50%) scale(1); }
30% { transform: translate(-50%, -50%) scale(1.45); }
60% { transform: translate(-50%, -50%) scale(1); }
100% { transform: translate(-50%, -50%) scale(1); }
}
@keyframes lc-say-a { 0% { opacity: 1; } 60% { opacity: 0; } 90% { opacity: 1; } 100% { opacity: 1; } }
@keyframes lc-say-b { 0% { opacity: 0; } 60% { opacity: 1; } 90% { opacity: 0; } 100% { opacity: 0; } }
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.lc { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring,
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot,
.lc.is-demo .lc__cut { animation: none !important; }
.lc.is-demo .lc__say { opacity: 1; } // 컷이 멈추면 실시간 문구가 대신 보인다
.lc__ring--1 { opacity: .9; transform: translate(-50%, -50%) scale(.55); }
.lc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lc {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.lc__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
aspect-ratio: 12/5;
background: repeating-linear-gradient(90deg, #fffdf7 0 8px, transparent 8px 28px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 28px), radial-gradient(circle at 8% 76%, #c9e0bd 0 28px, transparent 28px), linear-gradient(118deg, transparent 0 82%, #bcd9ea 82% 94%, transparent 94%), #efe9dc;
}
.lc__map::before,
.lc__map::after {
content: "";
position: absolute;
z-index: 0;
background: #e2dac9;
}
.lc__map::before {
left: 24%;
top: 14%;
width: 13%;
height: 30%;
}
.lc__map::after {
left: 68%;
top: 56%;
width: 15%;
height: 30%;
}
.lc__ring {
position: absolute;
z-index: 1;
left: 50%;
top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, 0.75);
border-radius: 50%;
background: rgba(47, 109, 246, 0.08);
opacity: 0;
transform: translate(-50%, -50%) scale(0.18);
}
.lc__cross {
position: absolute;
z-index: 2;
left: 50%;
top: 50%;
display: block;
color: rgba(23, 20, 26, 0.5);
transform: translate(-50%, -50%);
}
.lc__dot {
position: absolute;
z-index: 3;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.lc__bar {
display: flex;
align-items: center;
gap: 8px;
}
.lc__btn {
flex: 0 0 auto;
box-sizing: border-box;
height: 28px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
line-height: 14px;
font-weight: 700;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), box-shadow 220ms ease-out;
}
.lc__btn:hover {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.lc__btn:active {
transform: scale(0.96);
}
.lc__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.lc__read {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 16px;
overflow: hidden;
}
.lc__say,
.lc__cut {
position: absolute;
right: 0;
top: 0;
font-size: 10px;
line-height: 16px;
font-weight: 700;
color: rgba(23, 20, 26, 0.66);
white-space: nowrap;
}
.lc__cut {
opacity: 0;
}
.lc.is-demo .lc__say {
opacity: 0;
}
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring {
animation-name: lc-scan;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__ring--2,
.lc.is-scanning .lc__ring--2 {
animation-delay: -667ms;
}
.lc.is-demo .lc__ring--3,
.lc.is-scanning .lc__ring--3 {
animation-delay: -1334ms;
}
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot {
animation-name: lc-pulse;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__cut {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lc.is-demo .lc__cut--a {
animation-name: lc-say-a;
}
.lc.is-demo .lc__cut--b {
animation-name: lc-say-b;
}
@keyframes lc-scan {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.18);
}
14% {
opacity: 0.9;
transform: translate(-50%, -50%) scale(0.4);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.04);
}
}
@keyframes lc-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
}
30% {
transform: translate(-50%, -50%) scale(1.45);
}
60% {
transform: translate(-50%, -50%) scale(1);
}
100% {
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes lc-say-a {
0% {
opacity: 1;
}
60% {
opacity: 0;
}
90% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes lc-say-b {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.lc {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring,
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot,
.lc.is-demo .lc__cut {
animation: none !important;
}
.lc.is-demo .lc__say {
opacity: 1;
}
.lc__ring--1 {
opacity: 0.9;
transform: translate(-50%, -50%) scale(0.55);
}
.lc__btn {
transition: none;
}
}// 실제 동작: 버튼을 누르면(클릭·Enter·Space) 스캔 링이 다시 퍼지고, 1.4초 뒤 잠기며
// aria-live 영역의 문구가 바뀌어 스크린리더에도 알려진다.
var lcRoot = document.querySelector('.lc');
var lcBtn = document.getElementById('lc-btn');
var lcSay = document.getElementById('lc-say');
var lcTimer = 0;
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);
}
lcBtn.addEventListener('click', lcScan);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LocateCrosshair.module.scss";
export type LocateCrosshairProps = {
buttonLabel?: string;
searchingLabel?: string;
lockedLabel?: string;
/** 스캔이 끝나고 위치가 잠기기까지 걸리는 시간(ms) */
scanMs?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LocateCrosshair({
buttonLabel = "Find my location",
searchingLabel = "Searching for your location",
lockedLabel = "Your location · 12m",
scanMs = 1400,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: LocateCrosshairProps) {
const [scanning, setScanning] = useState(true);
const [locked, setLocked] = useState(false);
const timer = useRef<number>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!scanning) return;
const id = window.setTimeout(() => { setScanning(false); setLocked(true); }, scanMs);
timer.current = id;
return () => window.clearTimeout(id);
}, [scanning, scanMs]);
function scan() {
if (timer.current !== null) window.clearTimeout(timer.current);
setLocked(false);
setScanning(true);
}
const stateClass = `${scanning ? styles.isScanning : ""} ${locked ? styles.isLocked : ""}`;
return (
<div className={`${styles.lc} ${stateClass}`} style={vars}>
<div className={styles.map}>
<span className={`${styles.ring} ${styles.ring1}`} aria-hidden="true" />
<span className={`${styles.ring} ${styles.ring2}`} aria-hidden="true" />
<span className={`${styles.ring} ${styles.ring3}`} aria-hidden="true" />
<span className={styles.cross} aria-hidden="true">
<svg viewBox="0 0 256 256" width="36" height="36">
<g fill="currentColor">
<path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2" />
<path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24" />
</g>
</svg>
</span>
<span className={styles.dot} aria-hidden="true" />
</div>
<div className={styles.bar}>
{/* 진짜 <button> 이라 클릭과 키보드 Enter·Space 가 모두 들어온다 */}
<button type="button" className={styles.btn} onClick={scan}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2" />
<path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z" />
</g>
</svg>
<span>{buttonLabel}</span>
</button>
<span className={styles.read}>
<span className={styles.say} role="status" aria-live="polite">
{locked ? lockedLabel : searchingLabel}
</span>
</span>
</div>
</div>
);
}06 · 현재 위치 크로스헤어 스캔 — 측정 누적면적 7.419% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
상태 문구는 aria-live 영역이라 화면을 보지 못하는 사람에게도 "위치를 찾는 중"에서 "현재 위치 · 12m"로 바뀐 게 읽힙니다.
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" 배지가 튀어나옵니다.

<main class="stage">
<div class="rd is-demo">
<div class="rd__map">
<span class="rd__block rd__block--a" aria-hidden="true"></span>
<span class="rd__block rd__block--b" aria-hidden="true"></span>
<span class="rd__block rd__block--c" aria-hidden="true"></span>
<svg class="rd__route" viewBox="0 0 480 300" preserveAspectRatio="none" role="img" aria-label="출발지에서 도착지까지 예상 경로 12분 3.4km">
<path class="rd__line rd__line--base" pathLength="100" vector-effect="non-scaling-stroke" d="M58 246 L58 196 L150 196 L150 128 L286 128 L286 74 L414 74"/>
<path class="rd__line rd__line--draw" pathLength="100" vector-effect="non-scaling-stroke" d="M58 246 L58 196 L150 196 L150 128 L286 128 L286 74 L414 74"/>
</svg>
<span class="rd__pin rd__pin--start" aria-hidden="true"><svg viewBox="0 0 256 256" width="24" height="24"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rd__flag" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 56v60c-19.89 17.23-38.23 18.39-56 13.48v-60c17.76 4.91 36.11 3.75 56-13.48m-120 46.52v-60C86.24 37.61 67.89 38.77 48 56v60c19.89-17.22 38.23-18.39 56-13.48v60c21.62 6 42.38 21 64 27v-60c-21.62-6-42.38-21-64-27" opacity=".2"/><path d="M227.32 48.75a8 8 0 0 0-8.56 1.25c-28 24.22-51.72 12.48-79.21-1.13C111.07 34.76 78.78 18.79 42.76 50A8 8 0 0 0 40 56v168a8 8 0 0 0 16 0v-44.23c26.79-21.16 49.87-9.75 76.45 3.41c28.49 14.09 60.77 30.06 96.79-1.13a8 8 0 0 0 2.76-6V56a8 8 0 0 0-4.68-7.25M216 71.6v40.65c-14 11.06-27 13.22-40 10.88V79.34a60.05 60.05 0 0 0 40-7.74m-56 3.76v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18-8.91-27.55-12.18v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18.01 8.91 27.55 12.18M96 48.91v43.78a60.06 60.06 0 0 0-40 7.75V59.78c14-11.06 27-13.21 40-10.87M86.58 152A60.06 60.06 0 0 0 56 160.43v-40.65c14-11.06 27-13.22 40-10.88v43.8a65.6 65.6 0 0 0-9.42-.7m25.42 4.67v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18 8.9 27.55 12.17v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18.01-8.9-27.55-12.17m64 26.45v-43.8a65.6 65.6 0 0 0 9.42.72a60.1 60.1 0 0 0 30.58-8.47v40.68c-14 11.06-27 13.21-40 10.87"/></g></svg></span>
<span class="rd__badge" aria-hidden="true">
<span class="rd__badge-ico"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2"/><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z"/></g></svg></span>
<span class="rd__badge-txt">12분 · 3.4km</span>
</span>
</div>
<button class="rd__btn" type="button" id="rd-btn">
<span class="rd__btn-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2"/><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z"/></g></svg></span>
<span>경로 다시 그리기</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
// 가짜 지도 캔버스 — 외부 지도 API·이미지 금지. CSS 그라디언트만 (249 아홉 항목 공통 규격)
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.rd {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 지도 배경은 움직이지 않는다 — 움직이는 건 경로선과 배지뿐
.rd__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
height: clamp(104px, 34vw, 160px);
box-shadow: $shadow-press;
background:
radial-gradient(circle at 16% 74%, $map-park 0 42px, transparent 42px),
linear-gradient(104deg, transparent 0 64%, $map-water 64% 71%, transparent 71%),
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 30px),
repeating-linear-gradient(0deg, $map-road 0 7px, transparent 7px 26px),
$map-land;
}
.rd__block {
position: absolute;
display: block;
background: $map-block;
border-radius: $r-xs;
}
.rd__block--a { left: 20%; top: 10%; width: 15%; height: 22%; }
.rd__block--b { left: 47%; top: 48%; width: 19%; height: 26%; }
.rd__block--c { left: 73%; top: 42%; width: 13%; height: 20%; }
// ── 경로선 ──────────────────────────────────────────────
.rd__route { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.rd__line { fill: none; stroke-linecap: round; stroke-linejoin: round; }
.rd__line--base { stroke: rgba(23, 20, 26, .13); stroke-width: 7; }
.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; }
.rd__pin, .rd__flag { position: absolute; display: block; filter: drop-shadow(0 2px 3px rgba(23, 20, 26, .28)); }
.rd__pin svg, .rd__flag svg { display: block; }
.rd__pin--start { left: 12.1%; top: 82%; transform: translate(-50%, -100%); color: $color; }
.rd__flag { left: 86.3%; top: 24.7%; transform: translate(-50%, -50%); color: rgba(23, 20, 26, .82); }
// ── 도착 배지 ───────────────────────────────────────────
.rd__badge {
position: absolute;
top: $sp-2;
left: 50%;
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: #fff;
color: rgba(23, 20, 26, .92);
font-size: 11px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
box-shadow: $shadow-press;
transform: translate(-50%, -6px) scale(.6);
opacity: 0;
transition: transform $duration $ease-pop, opacity $duration $ease-pop;
}
.rd__badge-ico { display: inline-flex; color: $color; }
.rd.is-drawn .rd__badge {
opacity: 1;
transform: translate(-50%, 0) scale(1);
transition-delay: 860ms; // 선이 다 그어진 뒤에 튀어나온다
}
// ── 다시 그리기 버튼 ────────────────────────────────────
.rd__btn {
box-sizing: border-box;
width: 100%;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: #fff;
color: rgba(23, 20, 26, .92);
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 14px;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-pop;
}
.rd__btn-ico { display: inline-flex; color: $color; }
.rd__btn:hover { transform: translateY(-1px); }
.rd__btn:active { transform: translateY(1px); }
.rd__btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
// ── 자동 루프: 긋기 → 배지 pop → 90% 안에 배지가 접히고 선이 되감긴다 (0% 와 100% 가 같은 상태) ──
.rd.is-demo .rd__line--draw {
animation-name: rd-draw;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes rd-draw {
0% { stroke-dashoffset: 100; }
45% { stroke-dashoffset: 0; }
72% { stroke-dashoffset: 0; }
88% { stroke-dashoffset: 100; }
100% { stroke-dashoffset: 100; }
}
.rd.is-demo .rd__badge {
animation-name: rd-badge;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes rd-badge {
0% { opacity: 0; transform: translate(-50%, -6px) scale(.6); }
44% { opacity: 0; transform: translate(-50%, -6px) scale(.6); }
56% { opacity: 1; transform: translate(-50%, 0) scale(1); }
72% { opacity: 1; transform: translate(-50%, 0) scale(1); }
84% { opacity: 0; transform: translate(-50%, -6px) scale(.6); }
100% { opacity: 0; transform: translate(-50%, -6px) scale(.6); }
}
// 320×200 — 무대 패딩(25.6px)을 빼면 174px 안에 들어가야 한다. 지도 108.8 + 간격 0 + 버튼 28
@media (max-width: 340px) {
.rd { gap: 0; }
.rd__btn { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rd.is-demo .rd__line--draw, .rd.is-demo .rd__badge { animation: none !important; }
.rd__line--draw, .rd__badge, .rd__btn { transition: none; }
.rd__line--draw { stroke-dashoffset: 0; }
.rd__badge { opacity: 1; transform: translate(-50%, 0) scale(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rd {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rd__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
height: clamp(104px, 34vw, 160px);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 16% 74%, #c9e0bd 0 42px, transparent 42px), linear-gradient(104deg, transparent 0 64%, #bcd9ea 64% 71%, transparent 71%), repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 30px), repeating-linear-gradient(0deg, #fffdf7 0 7px, transparent 7px 26px), #efe9dc;
}
.rd__block {
position: absolute;
display: block;
background: #e2dac9;
border-radius: 4px;
}
.rd__block--a {
left: 20%;
top: 10%;
width: 15%;
height: 22%;
}
.rd__block--b {
left: 47%;
top: 48%;
width: 19%;
height: 26%;
}
.rd__block--c {
left: 73%;
top: 42%;
width: 13%;
height: 20%;
}
.rd__route {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.rd__line {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.rd__line--base {
stroke: rgba(23, 20, 26, 0.13);
stroke-width: 7;
}
.rd__line--draw {
stroke: #2f6df6;
stroke-width: 5;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.rd:not(.is-demo) .rd__line--draw {
transition: stroke-dashoffset 900ms ease-out;
}
.rd.is-drawn .rd__line--draw {
stroke-dashoffset: 0;
}
.rd__pin, .rd__flag {
position: absolute;
display: block;
filter: drop-shadow(0 2px 3px rgba(23, 20, 26, 0.28));
}
.rd__pin svg, .rd__flag svg {
display: block;
}
.rd__pin--start {
left: 12.1%;
top: 82%;
transform: translate(-50%, -100%);
color: #2f6df6;
}
.rd__flag {
left: 86.3%;
top: 24.7%;
transform: translate(-50%, -50%);
color: rgba(23, 20, 26, 0.82);
}
.rd__badge {
position: absolute;
top: 8px;
left: 50%;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: #fff;
color: rgba(23, 20, 26, 0.92);
font-size: 11px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -6px) scale(0.6);
opacity: 0;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rd__badge-ico {
display: inline-flex;
color: #2f6df6;
}
.rd.is-drawn .rd__badge {
opacity: 1;
transform: translate(-50%, 0) scale(1);
transition-delay: 860ms;
}
.rd__btn {
box-sizing: border-box;
width: 100%;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #fff;
color: rgba(23, 20, 26, 0.92);
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 14px;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rd__btn-ico {
display: inline-flex;
color: #2f6df6;
}
.rd__btn:hover {
transform: translateY(-1px);
}
.rd__btn:active {
transform: translateY(1px);
}
.rd__btn:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.rd.is-demo .rd__line--draw {
animation-name: rd-draw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes rd-draw {
0% {
stroke-dashoffset: 100;
}
45% {
stroke-dashoffset: 0;
}
72% {
stroke-dashoffset: 0;
}
88% {
stroke-dashoffset: 100;
}
100% {
stroke-dashoffset: 100;
}
}
.rd.is-demo .rd__badge {
animation-name: rd-badge;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes rd-badge {
0% {
opacity: 0;
transform: translate(-50%, -6px) scale(0.6);
}
44% {
opacity: 0;
transform: translate(-50%, -6px) scale(0.6);
}
56% {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
72% {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
84% {
opacity: 0;
transform: translate(-50%, -6px) scale(0.6);
}
100% {
opacity: 0;
transform: translate(-50%, -6px) scale(0.6);
}
}
@media (max-width: 340px) {
.rd {
gap: 0;
}
.rd__btn {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rd.is-demo .rd__line--draw, .rd.is-demo .rd__badge {
animation: none !important;
}
.rd__line--draw, .rd__badge, .rd__btn {
transition: none;
}
.rd__line--draw {
stroke-dashoffset: 0;
}
.rd__badge {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}// 실제 동작: 버튼(마우스 클릭 · Enter/Space)으로 경로선을 0 부터 다시 긋고, 다 그으면 배지를 띄운다.
var root = document.querySelector('.rd');
var btn = document.getElementById('rd-btn');
var line = document.querySelector('.rd__line--draw');
function redraw() {
root.classList.remove('is-demo'); // 사람이 조작하면 자동 루프를 끈다 (237 선례)
root.classList.remove('is-drawn');
void line.getBoundingClientRect(); // 되감긴 상태를 한 프레임 확정시킨 뒤 다시 긋는다
requestAnimationFrame(function () {
requestAnimationFrame(function () { root.classList.add('is-drawn'); });
});
}
btn.addEventListener('click', redraw);
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); redraw(); }
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RouteDraw.module.scss";
export type RouteDrawProps = {
/** 경로 배지에 쓸 소요시간·거리 */
eta?: string;
/** 경로 SVG 의 스크린리더 설명 */
routeLabel?: string;
/** 다시 그리기 버튼 글자 */
buttonLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
// pathLength="100" 으로 길이를 정규화해 dash 값이 곧 진행률 % 가 되게 한다
const ROUTE = "M58 246 L58 196 L150 196 L150 128 L286 128 L286 74 L414 74";
export default function RouteDraw({
eta = "12 min · 3.4 km",
routeLabel = "Route from start to destination, 12 min 3.4 km",
buttonLabel = "Draw the route again",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: RouteDrawProps) {
const [demo, setDemo] = useState(true);
const [drawn, setDrawn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function redraw() {
setDemo(false); // 사람이 조작하면 자동 루프를 끈다
setDrawn(false);
requestAnimationFrame(() => requestAnimationFrame(() => setDrawn(true)));
}
return (
<div className={`${styles.rd} ${demo ? styles.isDemo : ""} ${drawn ? styles.isDrawn : ""}`} style={vars}>
<div className={styles.map}>
<span className={`${styles.block} ${styles.blockA}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockB}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockC}`} aria-hidden="true" />
<svg className={styles.route} viewBox="0 0 480 300" preserveAspectRatio="none" role="img" aria-label={routeLabel}>
<path className={`${styles.line} ${styles.lineBase}`} pathLength={100} vectorEffect="non-scaling-stroke" d={ROUTE} />
<path className={`${styles.line} ${styles.lineDraw}`} pathLength={100} vectorEffect="non-scaling-stroke" d={ROUTE} />
</svg>
<span className={`${styles.pin} ${styles.pinStart}`} aria-hidden="true">
<svg viewBox="0 0 256 256" width="24" height="24"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" /><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" /></g></svg>
</span>
<span className={styles.flag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 56v60c-19.89 17.23-38.23 18.39-56 13.48v-60c17.76 4.91 36.11 3.75 56-13.48m-120 46.52v-60C86.24 37.61 67.89 38.77 48 56v60c19.89-17.22 38.23-18.39 56-13.48v60c21.62 6 42.38 21 64 27v-60c-21.62-6-42.38-21-64-27" opacity=".2" /><path d="M227.32 48.75a8 8 0 0 0-8.56 1.25c-28 24.22-51.72 12.48-79.21-1.13C111.07 34.76 78.78 18.79 42.76 50A8 8 0 0 0 40 56v168a8 8 0 0 0 16 0v-44.23c26.79-21.16 49.87-9.75 76.45 3.41c28.49 14.09 60.77 30.06 96.79-1.13a8 8 0 0 0 2.76-6V56a8 8 0 0 0-4.68-7.25M216 71.6v40.65c-14 11.06-27 13.22-40 10.88V79.34a60.05 60.05 0 0 0 40-7.74m-56 3.76v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18-8.91-27.55-12.18v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18.01 8.91 27.55 12.18M96 48.91v43.78a60.06 60.06 0 0 0-40 7.75V59.78c14-11.06 27-13.21 40-10.87M86.58 152A60.06 60.06 0 0 0 56 160.43v-40.65c14-11.06 27-13.22 40-10.88v43.8a65.6 65.6 0 0 0-9.42-.7m25.42 4.67v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18 8.9 27.55 12.17v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18.01-8.9-27.55-12.17m64 26.45v-43.8a65.6 65.6 0 0 0 9.42.72a60.1 60.1 0 0 0 30.58-8.47v40.68c-14 11.06-27 13.21-40 10.87" /></g></svg>
</span>
<span className={styles.badge} aria-hidden="true">
<span className={styles.badgeIco}>
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2" /><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z" /></g></svg>
</span>
<span>{eta}</span>
</span>
</div>
<button
type="button"
className={styles.btn}
onClick={redraw}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); redraw(); }
}}
>
<span className={styles.btnIco} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2" /><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z" /></g></svg>
</span>
<span>{buttonLabel}</span>
</button>
</div>
);
}07 · 그려지는 경로선 — 측정 누적면적 3.528% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
pathLength="100"으로 길이를 100으로 정규화해 두면 stroke-dasharray와 stroke-dashoffset에 넣는 숫자가 곧 진행 퍼센트가 되어, 경로 좌표를 고쳐도 값을 다시 계산할 필요가 없습니다.
.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즐겨찾기 핀 목록
화면에서 일어나는 일
핀의 하트를 누르면 듀오톤 아이콘의 뒤에 깔린 면이 켜지며 통통 튀고, 지도 아래 "내 장소" 줄에 그 가게 칩이 밀려 들어옵니다.

<main class="stage">
<div class="fp is-demo">
<div class="fp__map">
<span class="fp__block fp__block--a" aria-hidden="true"></span>
<span class="fp__block fp__block--b" aria-hidden="true"></span>
<span class="fp__block fp__block--c" aria-hidden="true"></span>
<button class="fp__pin fp__pin--1" type="button" aria-pressed="false" aria-label="카페 온도 즐겨찾기">
<span class="fp__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</button>
<button class="fp__pin fp__pin--2" type="button" aria-pressed="false" aria-label="하나책방 즐겨찾기">
<span class="fp__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</button>
<button class="fp__pin fp__pin--3" type="button" aria-pressed="false" aria-label="공원베이커리 즐겨찾기">
<span class="fp__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</button>
<button class="fp__pin fp__pin--4" type="button" aria-pressed="false" aria-label="소금집 즐겨찾기">
<span class="fp__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</button>
</div>
<div class="fp__foot">
<p class="fp__title">내 장소</p>
<ul class="fp__chips" aria-live="polite" aria-label="즐겨찾기한 장소">
<li class="fp__chip" aria-hidden="true"><span class="fp__chip-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span><span class="fp__chip-txt">카페 온도</span></li>
<li class="fp__chip" aria-hidden="true"><span class="fp__chip-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span><span class="fp__chip-txt">하나책방</span></li>
<li class="fp__chip" aria-hidden="true"><span class="fp__chip-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span><span class="fp__chip-txt">공원베이커리</span></li>
<li class="fp__chip" aria-hidden="true"><span class="fp__chip-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span><span class="fp__chip-txt">소금집</span></li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-orange; // 하트 채움색 — 토큰 주황
@include stage($stage-paper);
// 가짜 지도 캔버스 — 외부 지도 API·이미지 금지. CSS 그라디언트만 (249 아홉 항목 공통 규격)
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.fp {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, .92);
}
// 지도 배경은 움직이지 않는다 — 움직이는 건 하트와 칩뿐
.fp__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
height: clamp(96px, 30vw, 148px);
box-shadow: $shadow-press;
background:
radial-gradient(circle at 80% 22%, $map-park 0 38px, transparent 38px),
linear-gradient(74deg, transparent 0 22%, $map-water 22% 29%, transparent 29%),
repeating-linear-gradient(90deg, $map-road 0 6px, transparent 6px 28px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 25px),
$map-land;
}
.fp__block {
position: absolute;
display: block;
background: $map-block;
border-radius: $r-xs;
}
.fp__block--a { left: 38%; top: 12%; width: 14%; height: 22%; }
.fp__block--b { left: 12%; top: 52%; width: 18%; height: 24%; }
.fp__block--c { left: 60%; top: 62%; width: 16%; height: 22%; }
// ── 하트 핀 ─────────────────────────────────────────────
.fp__pin {
position: absolute;
box-sizing: border-box;
width: 28px;
height: 28px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: #fff;
color: rgba(23, 20, 26, .34);
box-shadow: $shadow-press;
cursor: pointer;
transform: translate(-50%, -50%);
transition: color $duration $easing;
}
.fp__pin--1 { left: 22%; top: 30%; }
.fp__pin--2 { left: 50%; top: 68%; }
.fp__pin--3 { left: 72%; top: 26%; }
.fp__pin--4 { left: 86%; top: 72%; }
.fp__pin:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fp__heart { display: inline-flex; }
.fp__heart svg { display: block; }
// 듀오톤 아이콘의 뒷면을 채워 "빈 하트 → 찬 하트" 로 읽히게 한다
.fp__heart svg g > path:first-child { opacity: .2; transition: opacity $duration $easing; }
.fp__pin[aria-pressed="true"] { color: $color; }
.fp__pin[aria-pressed="true"] .fp__heart svg g > path:first-child { opacity: 1; }
.fp__pin[aria-pressed="true"] .fp__heart {
animation-name: fp-pop;
animation-duration: $dur-quick;
animation-timing-function: $ease-pop;
animation-iteration-count: 1;
}
@keyframes fp-pop {
0% { transform: scale(.7); }
60% { transform: scale(1.26); }
100% { transform: scale(1); }
}
// ── 내 장소 칩 목록 ─────────────────────────────────────
.fp__foot { display: flex; flex-direction: column; gap: $sp-1; }
.fp__title {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 700;
letter-spacing: .04em;
color: rgba($stage-ink, .72);
}
.fp__chips {
display: flex;
gap: 0; // 숨은 칩의 gap 이 남지 않게 — 여백은 칩의 margin-right 로
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
height: 24px;
}
.fp__chip {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-1;
height: 24px;
max-width: 0;
padding: 0;
margin: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(23, 20, 26, .07);
font-size: 11px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
opacity: 0;
transform: scale(.6);
transition: max-width $duration $easing, padding $duration $easing, margin $duration $easing,
opacity $duration $easing, transform $duration $easing;
}
.fp__chip-ico { display: inline-flex; color: $color; }
.fp__chip.is-on {
max-width: 160px;
padding-left: $sp-2;
padding-right: $sp-2;
margin-right: $sp-2;
opacity: 1;
transform: scale(1);
}
// ── 자동 루프: 핀 두 개가 차례로 켜졌다 꺼지고 칩이 쌓였다 빠진다 (0% 와 100% 가 같은 상태) ──
.fp.is-demo .fp__pin--1 {
animation-name: fp-on-a;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--1 .fp__heart svg g > path:first-child {
animation-name: fp-fill-a;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__chip:nth-child(1) {
animation-name: fp-chip-a;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--2 {
animation-name: fp-on-b;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--2 .fp__heart svg g > path:first-child {
animation-name: fp-fill-b;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__chip:nth-child(2) {
animation-name: fp-chip-b;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fp-on-a {
0% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
10% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
16% { color: $color; transform: translate(-50%, -50%) scale(1.18); }
24% { color: $color; transform: translate(-50%, -50%) scale(1); }
64% { color: $color; transform: translate(-50%, -50%) scale(1); }
76% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
100% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
}
@keyframes fp-fill-a {
0% { opacity: .2; }
10% { opacity: .2; }
16% { opacity: 1; }
64% { opacity: 1; }
76% { opacity: .2; }
100% { opacity: .2; }
}
@keyframes fp-chip-a {
0% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
12% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
24% { max-width: 160px; padding-left: $sp-2; padding-right: $sp-2; margin-right: $sp-2; opacity: 1; transform: scale(1); }
64% { max-width: 160px; padding-left: $sp-2; padding-right: $sp-2; margin-right: $sp-2; opacity: 1; transform: scale(1); }
78% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
100% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
}
@keyframes fp-on-b {
0% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
26% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
32% { color: $color; transform: translate(-50%, -50%) scale(1.18); }
40% { color: $color; transform: translate(-50%, -50%) scale(1); }
70% { color: $color; transform: translate(-50%, -50%) scale(1); }
84% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
100% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
}
@keyframes fp-fill-b {
0% { opacity: .2; }
26% { opacity: .2; }
32% { opacity: 1; }
70% { opacity: 1; }
84% { opacity: .2; }
100% { opacity: .2; }
}
@keyframes fp-chip-b {
0% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
28% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
40% { max-width: 160px; padding-left: $sp-2; padding-right: $sp-2; margin-right: $sp-2; opacity: 1; transform: scale(1); }
70% { max-width: 160px; padding-left: $sp-2; padding-right: $sp-2; margin-right: $sp-2; opacity: 1; transform: scale(1); }
86% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
100% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
}
// 320×200 — 무대 패딩(25.6px)을 빼면 174px. 지도 96 + 제목 14 + 칩 24 + 간격은 0 으로 내린다
@media (max-width: 340px) {
.fp { gap: $sp-1; }
.fp__foot { gap: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fp.is-demo .fp__pin--1,
.fp.is-demo .fp__pin--2,
.fp.is-demo .fp__pin--1 .fp__heart svg g > path:first-child,
.fp.is-demo .fp__pin--2 .fp__heart svg g > path:first-child,
.fp.is-demo .fp__chip:nth-child(1),
.fp.is-demo .fp__chip:nth-child(2),
.fp__pin[aria-pressed="true"] .fp__heart { animation: none !important; }
.fp__pin, .fp__chip, .fp__heart svg g > path:first-child { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fp {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.92);
}
.fp__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
height: clamp(96px, 30vw, 148px);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 80% 22%, #c9e0bd 0 38px, transparent 38px), linear-gradient(74deg, transparent 0 22%, #bcd9ea 22% 29%, transparent 29%), repeating-linear-gradient(90deg, #fffdf7 0 6px, transparent 6px 28px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 25px), #efe9dc;
}
.fp__block {
position: absolute;
display: block;
background: #e2dac9;
border-radius: 4px;
}
.fp__block--a {
left: 38%;
top: 12%;
width: 14%;
height: 22%;
}
.fp__block--b {
left: 12%;
top: 52%;
width: 18%;
height: 24%;
}
.fp__block--c {
left: 60%;
top: 62%;
width: 16%;
height: 22%;
}
.fp__pin {
position: absolute;
box-sizing: border-box;
width: 28px;
height: 28px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: #fff;
color: rgba(23, 20, 26, 0.34);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: pointer;
transform: translate(-50%, -50%);
transition: color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fp__pin--1 {
left: 22%;
top: 30%;
}
.fp__pin--2 {
left: 50%;
top: 68%;
}
.fp__pin--3 {
left: 72%;
top: 26%;
}
.fp__pin--4 {
left: 86%;
top: 72%;
}
.fp__pin:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.fp__heart {
display: inline-flex;
}
.fp__heart svg {
display: block;
}
.fp__heart svg g > path:first-child {
opacity: 0.2;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fp__pin[aria-pressed=true] {
color: #ff4d1f;
}
.fp__pin[aria-pressed=true] .fp__heart svg g > path:first-child {
opacity: 1;
}
.fp__pin[aria-pressed=true] .fp__heart {
animation-name: fp-pop;
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes fp-pop {
0% {
transform: scale(0.7);
}
60% {
transform: scale(1.26);
}
100% {
transform: scale(1);
}
}
.fp__foot {
display: flex;
flex-direction: column;
gap: 4px;
}
.fp__title {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
}
.fp__chips {
display: flex;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
height: 24px;
}
.fp__chip {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
max-width: 0;
padding: 0;
margin: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(23, 20, 26, 0.07);
font-size: 11px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
opacity: 0;
transform: scale(0.6);
transition: max-width 220ms cubic-bezier(0.2, 1.4, 0.4, 1), padding 220ms cubic-bezier(0.2, 1.4, 0.4, 1), margin 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fp__chip-ico {
display: inline-flex;
color: #ff4d1f;
}
.fp__chip.is-on {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
.fp.is-demo .fp__pin--1 {
animation-name: fp-on-a;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--1 .fp__heart svg g > path:first-child {
animation-name: fp-fill-a;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__chip:nth-child(1) {
animation-name: fp-chip-a;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--2 {
animation-name: fp-on-b;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--2 .fp__heart svg g > path:first-child {
animation-name: fp-fill-b;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__chip:nth-child(2) {
animation-name: fp-chip-b;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes fp-on-a {
0% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
10% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
16% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1.18);
}
24% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1);
}
64% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1);
}
76% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
100% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes fp-fill-a {
0% {
opacity: 0.2;
}
10% {
opacity: 0.2;
}
16% {
opacity: 1;
}
64% {
opacity: 1;
}
76% {
opacity: 0.2;
}
100% {
opacity: 0.2;
}
}
@keyframes fp-chip-a {
0% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
12% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
24% {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
64% {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
78% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
100% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
}
@keyframes fp-on-b {
0% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
26% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
32% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1.18);
}
40% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1);
}
70% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1);
}
84% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
100% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes fp-fill-b {
0% {
opacity: 0.2;
}
26% {
opacity: 0.2;
}
32% {
opacity: 1;
}
70% {
opacity: 1;
}
84% {
opacity: 0.2;
}
100% {
opacity: 0.2;
}
}
@keyframes fp-chip-b {
0% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
28% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
40% {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
70% {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
86% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
100% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
}
@media (max-width: 340px) {
.fp {
gap: 4px;
}
.fp__foot {
gap: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.fp.is-demo .fp__pin--1,
.fp.is-demo .fp__pin--2,
.fp.is-demo .fp__pin--1 .fp__heart svg g > path:first-child,
.fp.is-demo .fp__pin--2 .fp__heart svg g > path:first-child,
.fp.is-demo .fp__chip:nth-child(1),
.fp.is-demo .fp__chip:nth-child(2),
.fp__pin[aria-pressed=true] .fp__heart {
animation: none !important;
}
.fp__pin, .fp__chip, .fp__heart svg g > path:first-child {
transition: none;
}
}// 실제 동작: 핀의 하트를 토글하면(마우스 클릭 · Enter/Space) aria-pressed 가 바뀌고 아래 칩 목록이 실제로 늘고 준다.
var root = document.querySelector('.fp');
var pins = [].slice.call(document.querySelectorAll('.fp__pin'));
var chips = [].slice.call(document.querySelectorAll('.fp__chip'));
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'); // 사람이 조작하면 자동 루프를 끈다 (237 선례)
p.setAttribute('aria-pressed', p.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
sync();
});
p.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); p.click(); }
});
});
sync();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FavoritePins.module.scss";
export type FavoritePinsProps = {
/** 지도 위 매장 이름 4개 */
places?: string[];
/** 칩 목록 제목 */
title?: string;
/** 하트 버튼의 스크린리더 설명을 만드는 함수 */
pinLabel?: (place: string) => string;
listLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const heart = (
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2" /><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" /></g></svg>
);
const storefront = (
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2" /><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z" /></g></svg>
);
export default function FavoritePins({
places = ["Cafe Ondo", "Hana Books", "Park Bakery", "Salt House"],
title = "My places",
pinLabel = (p: string) => `Save ${p} to favorites`,
listLabel = "Saved places",
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff4d1f",
}: FavoritePinsProps) {
const [demo, setDemo] = useState(true);
const [on, setOn] = useState<boolean[]>([false, false, false, false]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const pinPos = [styles.pin1, styles.pin2, styles.pin3, styles.pin4];
function toggle(i: number) {
setDemo(false); // 사람이 조작하면 자동 루프를 끈다
setOn((prev) => prev.map((v, k) => (k === i ? !v : v)));
}
return (
<div className={`${styles.fp} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.map}>
<span className={`${styles.block} ${styles.blockA}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockB}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockC}`} aria-hidden="true" />
{places.map((p, i) => (
<button
key={p}
type="button"
className={`${styles.pin} ${pinPos[i]}`}
aria-pressed={on[i]}
aria-label={pinLabel(p)}
onClick={() => toggle(i)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(i); }
}}
>
<span className={styles.heart} aria-hidden="true">{heart}</span>
</button>
))}
</div>
<div className={styles.foot}>
<p className={styles.title}>{title}</p>
<ul className={styles.chips} aria-live="polite" aria-label={listLabel}>
{places.map((p, i) => (
<li key={p} className={`${styles.chip} ${on[i] ? styles.isOn : ""}`} aria-hidden={!on[i]}>
<span className={styles.chipIco} aria-hidden="true">{storefront}</span>
<span>{p}</span>
</li>
))}
</ul>
</div>
</div>
);
}08 · 즐겨찾기 핀 목록 — 측정 누적면적 3.539% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
칩은 지우는 게 아니라 max-width와 좌우 여백을 0으로 접어 두는 방식이라, 다시 켜면 같은 자리로 펴집니다.
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눌러 확대되는 미니 지도
어떤 움직임인가
구석의 미니 지도 안에서 "지금 보는 영역" 사각형이 왕복하고, 미니 지도를 누르면 오른쪽 아래 모서리를 기준으로 큰 판이 자라납니다.

<main class="stage">
<div class="mm is-demo">
<div class="mm__map">
<span class="mm__block mm__block--a" aria-hidden="true"></span>
<span class="mm__block mm__block--b" aria-hidden="true"></span>
<span class="mm__block mm__block--c" aria-hidden="true"></span>
<span class="mm__you" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2"/><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z"/></g></svg></span>
<div class="mm__panel" id="mm-panel">
<span class="mm__panel-view" aria-hidden="true"></span>
<p class="mm__panel-title">
<span class="mm__panel-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M96 40v168l-56 24V64Zm120 24l-56-24v168l56 24Z" opacity=".2"/><path d="M228.92 49.69a8 8 0 0 0-6.86-1.45l-61.13 15.28l-61.35-30.68a8 8 0 0 0-5.52-.6l-64 16A8 8 0 0 0 24 56v144a8 8 0 0 0 9.94 7.76l61.13-15.28l61.35 30.68A8 8 0 0 0 160 224a8 8 0 0 0 1.94-.24l64-16A8 8 0 0 0 232 200V56a8 8 0 0 0-3.08-6.31M104 52.94l48 24v126.12l-48-24Zm-64 9.31l48-12v127.5l-48 12Zm176 131.5l-48 12V78.25l48-12Z"/></g></svg></span>
<span class="mm__panel-txt">전체 지도</span>
</p>
</div>
<button class="mm__mini" type="button" id="mm-btn" aria-expanded="false" aria-controls="mm-panel" aria-label="미니 지도 펼치기">
<span class="mm__view" aria-hidden="true"></span>
<span class="mm__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 48v160H48V48Z" opacity=".2"/><path d="M216 48v48a8 8 0 0 1-16 0V67.31l-42.34 42.35a8 8 0 0 1-11.32-11.32L188.69 56H160a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8M98.34 146.34L56 188.69V160a8 8 0 0 0-16 0v48a8 8 0 0 0 8 8h48a8 8 0 0 0 0-16H67.31l42.35-42.34a8 8 0 0 0-11.32-11.32M208 152a8 8 0 0 0-8 8v28.69l-42.34-42.35a8 8 0 0 0-11.32 11.32L188.69 200H160a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M67.31 56H96a8 8 0 0 0 0-16H48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31l42.34 42.35a8 8 0 0 0 11.32-11.32Z"/></g></svg></span>
</button>
</div>
<p class="mm__hint">미니 지도를 눌러 펼치고 Esc 로 접어요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 가짜 지도 캔버스 — 외부 지도 API·이미지 금지. CSS 그라디언트만 (249 아홉 항목 공통 규격)
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.mm {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 지도 배경은 움직이지 않는다 — 움직이는 건 영역 사각형과 확대 오버레이뿐
.mm__map {
position: relative;
overflow: hidden; // 오버레이를 지도 상자 안에 가둔다 (밖으로 삐져나오면 scrollHeight 에 잡힌다)
border-radius: $r-card;
width: 100%;
height: clamp(104px, 34vw, 160px);
box-shadow: $shadow-raised;
background:
radial-gradient(circle at 28% 28%, $map-park 0 40px, transparent 40px),
linear-gradient(122deg, transparent 0 56%, $map-water 56% 63%, transparent 63%),
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 31px),
repeating-linear-gradient(0deg, $map-road 0 7px, transparent 7px 27px),
$map-land;
}
.mm__block {
position: absolute;
display: block;
background: $map-block;
border-radius: $r-xs;
}
.mm__block--a { left: 8%; top: 62%; width: 16%; height: 24%; }
.mm__block--b { left: 44%; top: 16%; width: 18%; height: 24%; }
.mm__block--c { left: 62%; top: 56%; width: 14%; height: 20%; }
.mm__you {
position: absolute;
left: 18%;
top: 40%;
display: block;
transform: translate(-50%, -50%);
color: $color;
filter: drop-shadow(0 2px 3px rgba(23, 20, 26, .3));
}
.mm__you svg { display: block; }
// ── 미니 지도 (구석의 작은 버튼) ────────────────────────
.mm__mini {
position: absolute;
right: $sp-2;
bottom: $sp-2;
z-index: 2;
box-sizing: border-box;
width: 30%;
height: 40%;
padding: 0;
border: 2px solid rgba(255, 255, 255, .85);
border-radius: $r-sm;
cursor: pointer;
overflow: hidden;
box-shadow: $shadow-press;
background:
repeating-linear-gradient(90deg, $map-road 0 3px, transparent 3px 12px),
repeating-linear-gradient(0deg, $map-road 0 3px, transparent 3px 11px),
$map-land;
transition: border-color $duration $easing;
}
.mm__mini:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.mm.is-open .mm__mini { border-color: $color; }
.mm__ico {
position: absolute;
right: 2px;
bottom: 2px;
display: inline-flex;
padding: $sp-1;
border-radius: $r-xs;
background: rgba(255, 255, 255, .9);
color: rgba(23, 20, 26, .78);
transition: transform $duration $easing;
}
.mm__ico svg { display: block; }
.mm.is-open .mm__ico { transform: rotate(180deg); }
// "지금 보는 영역" 사각형
.mm__view, .mm__panel-view {
position: absolute;
box-sizing: border-box;
border: 2px solid $color;
border-radius: $r-xs;
background: rgba(47, 109, 246, .16);
}
.mm__view { left: 8%; top: 12%; width: 34%; height: 34%; }
.mm__panel-view { left: 10%; top: 16%; width: 30%; height: 32%; }
// ── 확대 오버레이 — 미니 지도 자리(오른쪽 아래)에서 자란다 ──
.mm__panel {
position: absolute;
right: $sp-2;
bottom: $sp-2;
z-index: 1;
box-sizing: border-box;
width: 66%;
height: 78%;
padding: $sp-2;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background:
radial-gradient(circle at 72% 70%, $map-park 0 26px, transparent 26px),
repeating-linear-gradient(90deg, $map-road 0 5px, transparent 5px 22px),
repeating-linear-gradient(0deg, $map-road 0 5px, transparent 5px 20px),
$map-land;
transform-origin: 100% 100%; // 규칙8: 미니맵 자리 모서리 확대
transform: scale(.3);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.mm.is-open .mm__panel { transform: scale(1); opacity: 1; pointer-events: auto; }
.mm__panel-title {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .92);
color: rgba(23, 20, 26, .9);
font-size: 10px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
}
.mm__panel-ico { display: inline-flex; color: $color; }
.mm__panel-ico svg { display: block; }
.mm__hint {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 600;
letter-spacing: .02em;
color: rgba(255, 255, 255, .58);
}
// ── 자동 루프: 영역 사각형이 왕복하고 미니 지도가 열렸다 닫힌다 (0% 와 100% 가 같은 상태) ──
.mm.is-demo .mm__view, .mm.is-demo .mm__panel-view {
animation-name: mm-pan;
animation-duration: $dur-loop;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes mm-pan {
0% { transform: translate(0, 0); }
50% { transform: translate(108%, 68%); }
100% { transform: translate(0, 0); }
}
.mm.is-demo .mm__panel {
animation-name: mm-open;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes mm-open {
0% { opacity: 0; transform: scale(.3); }
24% { opacity: 0; transform: scale(.3); }
40% { opacity: 1; transform: scale(1); }
66% { opacity: 1; transform: scale(1); }
84% { opacity: 0; transform: scale(.3); }
100% { opacity: 0; transform: scale(.3); }
}
.mm.is-demo .mm__ico {
animation-name: mm-flip;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes mm-flip {
0% { transform: rotate(0deg); }
24% { transform: rotate(0deg); }
40% { transform: rotate(180deg); }
66% { transform: rotate(180deg); }
84% { transform: rotate(0deg); }
100% { transform: rotate(0deg); }
}
// 320×200 — 무대 패딩(25.6px)을 빼면 174px. 지도 108.8 + 힌트 14, 간격은 0 으로 내린다
@media (max-width: 340px) {
.mm { gap: 0; }
.mm__hint { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.mm.is-demo .mm__view,
.mm.is-demo .mm__panel-view,
.mm.is-demo .mm__panel,
.mm.is-demo .mm__ico { animation: none !important; }
.mm__panel, .mm__ico, .mm__mini { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mm {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.mm__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
height: clamp(104px, 34vw, 160px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 28% 28%, #c9e0bd 0 40px, transparent 40px), linear-gradient(122deg, transparent 0 56%, #bcd9ea 56% 63%, transparent 63%), repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 31px), repeating-linear-gradient(0deg, #fffdf7 0 7px, transparent 7px 27px), #efe9dc;
}
.mm__block {
position: absolute;
display: block;
background: #e2dac9;
border-radius: 4px;
}
.mm__block--a {
left: 8%;
top: 62%;
width: 16%;
height: 24%;
}
.mm__block--b {
left: 44%;
top: 16%;
width: 18%;
height: 24%;
}
.mm__block--c {
left: 62%;
top: 56%;
width: 14%;
height: 20%;
}
.mm__you {
position: absolute;
left: 18%;
top: 40%;
display: block;
transform: translate(-50%, -50%);
color: #2f6df6;
filter: drop-shadow(0 2px 3px rgba(23, 20, 26, 0.3));
}
.mm__you svg {
display: block;
}
.mm__mini {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
box-sizing: border-box;
width: 30%;
height: 40%;
padding: 0;
border: 2px solid rgba(255, 255, 255, 0.85);
border-radius: 8px;
cursor: pointer;
overflow: hidden;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: repeating-linear-gradient(90deg, #fffdf7 0 3px, transparent 3px 12px), repeating-linear-gradient(0deg, #fffdf7 0 3px, transparent 3px 11px), #efe9dc;
transition: border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mm__mini:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.mm.is-open .mm__mini {
border-color: #2f6df6;
}
.mm__ico {
position: absolute;
right: 2px;
bottom: 2px;
display: inline-flex;
padding: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.9);
color: rgba(23, 20, 26, 0.78);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mm__ico svg {
display: block;
}
.mm.is-open .mm__ico {
transform: rotate(180deg);
}
.mm__view, .mm__panel-view {
position: absolute;
box-sizing: border-box;
border: 2px solid #2f6df6;
border-radius: 4px;
background: rgba(47, 109, 246, 0.16);
}
.mm__view {
left: 8%;
top: 12%;
width: 34%;
height: 34%;
}
.mm__panel-view {
left: 10%;
top: 16%;
width: 30%;
height: 32%;
}
.mm__panel {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 1;
box-sizing: border-box;
width: 66%;
height: 78%;
padding: 8px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 72% 70%, #c9e0bd 0 26px, transparent 26px), repeating-linear-gradient(90deg, #fffdf7 0 5px, transparent 5px 22px), repeating-linear-gradient(0deg, #fffdf7 0 5px, transparent 5px 20px), #efe9dc;
transform-origin: 100% 100%;
transform: scale(0.3);
opacity: 0;
pointer-events: none;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mm.is-open .mm__panel {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.mm__panel-title {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: rgba(23, 20, 26, 0.9);
font-size: 10px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
}
.mm__panel-ico {
display: inline-flex;
color: #2f6df6;
}
.mm__panel-ico svg {
display: block;
}
.mm__hint {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 600;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.58);
}
.mm.is-demo .mm__view, .mm.is-demo .mm__panel-view {
animation-name: mm-pan;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes mm-pan {
0% {
transform: translate(0, 0);
}
50% {
transform: translate(108%, 68%);
}
100% {
transform: translate(0, 0);
}
}
.mm.is-demo .mm__panel {
animation-name: mm-open;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes mm-open {
0% {
opacity: 0;
transform: scale(0.3);
}
24% {
opacity: 0;
transform: scale(0.3);
}
40% {
opacity: 1;
transform: scale(1);
}
66% {
opacity: 1;
transform: scale(1);
}
84% {
opacity: 0;
transform: scale(0.3);
}
100% {
opacity: 0;
transform: scale(0.3);
}
}
.mm.is-demo .mm__ico {
animation-name: mm-flip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes mm-flip {
0% {
transform: rotate(0deg);
}
24% {
transform: rotate(0deg);
}
40% {
transform: rotate(180deg);
}
66% {
transform: rotate(180deg);
}
84% {
transform: rotate(0deg);
}
100% {
transform: rotate(0deg);
}
}
@media (max-width: 340px) {
.mm {
gap: 0;
}
.mm__hint {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.mm.is-demo .mm__view,
.mm.is-demo .mm__panel-view,
.mm.is-demo .mm__panel,
.mm.is-demo .mm__ico {
animation: none !important;
}
.mm__panel, .mm__ico, .mm__mini {
transition: none;
}
}// 실제 동작: 미니 지도를 누르면(마우스 클릭 · Enter/Space) aria-expanded 가 켜지며 오버레이가 구석에서 자라고, Escape 로 접힌다.
var root = document.querySelector('.mm');
var btn = document.getElementById('mm-btn');
function toggle(on) {
root.classList.remove('is-demo'); // 사람이 조작하면 자동 루프를 끈다 (237 선례)
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', String(on));
}
btn.addEventListener('click', function () { toggle(!root.classList.contains('is-open')); });
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-open')) {
toggle(false);
btn.focus({ preventScroll: true });
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MiniMapExpand.module.scss";
export type MiniMapExpandProps = {
/** 확대 오버레이 제목 */
panelTitle?: string;
/** 미니 지도 버튼의 스크린리더 설명 */
buttonLabel?: string;
/** 지도 아래 안내 문구 */
hint?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
export default function MiniMapExpand({
panelTitle = "Whole map",
buttonLabel = "Expand the mini map",
hint = "Tap the mini map to expand, Esc to collapse",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MiniMapExpandProps) {
const [demo, setDemo] = useState(true);
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(on: boolean) {
setDemo(false); // 사람이 조작하면 자동 루프를 끈다
setOpen(on);
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && open) {
toggle(false);
btnRef.current?.focus({ preventScroll: true });
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
return (
<div className={`${styles.mm} ${demo ? styles.isDemo : ""} ${open ? styles.isOpen : ""}`} style={vars}>
<div className={styles.map}>
<span className={`${styles.block} ${styles.blockA}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockB}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockC}`} aria-hidden="true" />
<span className={styles.you} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2" /><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z" /></g></svg>
</span>
<div className={styles.panel} id="mm-panel">
<span className={styles.panelView} aria-hidden="true" />
<p className={styles.panelTitle}>
<span className={styles.panelIco} aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M96 40v168l-56 24V64Zm120 24l-56-24v168l56 24Z" opacity=".2" /><path d="M228.92 49.69a8 8 0 0 0-6.86-1.45l-61.13 15.28l-61.35-30.68a8 8 0 0 0-5.52-.6l-64 16A8 8 0 0 0 24 56v144a8 8 0 0 0 9.94 7.76l61.13-15.28l61.35 30.68A8 8 0 0 0 160 224a8 8 0 0 0 1.94-.24l64-16A8 8 0 0 0 232 200V56a8 8 0 0 0-3.08-6.31M104 52.94l48 24v126.12l-48-24Zm-64 9.31l48-12v127.5l-48 12Zm176 131.5l-48 12V78.25l48-12Z" /></g></svg>
</span>
<span>{panelTitle}</span>
</p>
</div>
<button
ref={btnRef}
type="button"
className={styles.mini}
aria-expanded={open}
aria-controls="mm-panel"
aria-label={buttonLabel}
onClick={() => toggle(!open)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(!open); }
}}
>
<span className={styles.view} aria-hidden="true" />
<span className={styles.ico} aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 48v160H48V48Z" opacity=".2" /><path d="M216 48v48a8 8 0 0 1-16 0V67.31l-42.34 42.35a8 8 0 0 1-11.32-11.32L188.69 56H160a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8M98.34 146.34L56 188.69V160a8 8 0 0 0-16 0v48a8 8 0 0 0 8 8h48a8 8 0 0 0 0-16H67.31l42.35-42.34a8 8 0 0 0-11.32-11.32M208 152a8 8 0 0 0-8 8v28.69l-42.34-42.35a8 8 0 0 0-11.32 11.32L188.69 200H160a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M67.31 56H96a8 8 0 0 0 0-16H48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31l42.34 42.35a8 8 0 0 0 11.32-11.32Z" /></g></svg>
</span>
</button>
</div>
<p className={styles.hint}>{hint}</p>
</div>
);
}09 · 눌러 확대되는 미니 지도 — 측정 누적면적 20.717% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
transform-origin: 100% 100%으로 확대 방향을 묶어 두면 열리는 판이 지도 상자 밖으로 삐져나가지 않습니다.
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의 확대)은 전부 transform과 opacity로만 움직이고, 위치 자체를 바꾸는 것은 사람이 한 번 고를 때로만 제한했습니다.
두 번째는 크기입니다. 이 데모들은 글 안에 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
지도 css 로 그린 가짜 지도를 진짜 지도 API 로 바꿔도 그대로 쓸 수 있나요?
핀·링·카드·슬라이더 쪽은 그대로 쓸 수 있습니다. 아홉 개 전부 지도판을 "배경이 깔린 position: relative 상자" 하나로만 취급하고, 그 안의 좌표는 퍼센트로 계산합니다. 배경 그라디언트 자리에 실제 지도 라이브러리의 캔버스를 넣고, 퍼센트 좌표를 그 라이브러리의 좌표 변환 함수로 바꿔 끼우면 됩니다. 바꿔야 하는 건 배경과 좌표 변환 두 곳입니다. 고른 위치를 카드로 밖에 내보내야 할 때는 블로그 위젯 디자인의 임베드 카드가 수납합니다.
핀이 겹칠 때 숫자로 묶는 02번은 몇 개부터 쓰는 게 좋나요?
핀 사이 거리가 핀 아이콘 폭보다 좁아지는 순간부터입니다. 개수가 아니라 화면 위 거리 기준이라, 같은 세 개라도 확대하면 풀리고 축소하면 묶입니다. 데모는 여섯 개를 한 배지로 묶어 놓았는데, 펼쳤을 때 이름표가 읽히는 한계가 대략 그 언저리였습니다.
반경 슬라이더는 원 안팎을 어떻게 가리나요?
핀마다 data-dist에 0부터 100 사이의 값을 넣어 두고 슬라이더 값과 크기를 비교할 뿐입니다. 실제 서비스라면 이 자리에 중심 좌표와 핀 좌표의 거리 계산이 들어가고, 계산 결과를 같은 0~100 눈금으로 바꿔 넣으면 나머지 코드는 손댈 필요가 없습니다.









