9 Map UI Design Patterns — Click-to-Drop Pickers
Map UI design is the layer that turns "where should this go?" into one point a person can hand back to you, and these nine click-to-drop pickers do it
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Click-to-drop pin
- 02 Count-badge cluster burst
- 03 Two-way card-and-pin highlight
- 04 Radius-circle distance slider
- 05 Address autocomplete dropdown
- 06 Current-location crosshair scan
- 07 Self-drawing route line
- 08 Favorite pins list
- 09 Press-to-expand mini map
The order is not popularity; it is the path someone walks while settling on a spot. The first three put a point on the board: tap the map for one (01), split a stack of overlapping markers when there are too many (02), and tie the result list to those markers so the eye can move between them (03). The next three narrow what counts: cut by distance (04), jump somewhere by typing an address (05), and anchor the search on the device itself (06). The last three happen after the choice is made: draw the route to it (07), keep it for next time (08), and glance at the whole city while the big map stays zoomed in (09). The board underneath all nine is built the same way — two crossed repeating-linear-gradient layers for roads, plus a park circle and a river band, painted once and never touched again while the map marker css moves pins, rings, and cards on top of it. If the choice is a date, css calendar design patterns is the pair; if it's a span rather than a point, time picker ui patterns inherits the role.
01Click-to-drop pin
What these map ui design examples show
The pin enters 62px above the point you clicked, falls, bounces once, and settles while its shadow spreads on impact and tightens again as it rebounds. Click coordinates are divided by the map box and clamped into a percentage, so the same tap resolves to the same building whether the frame is 480px or 320px wide.

<main class="stage">
<div class="pd is-demo">
<button class="pd__map" type="button" id="pd-map" aria-label="Click the map to pick a location" 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">Selected location</span>
<span class="pd__val" id="pd-val" role="status" aria-live="polite">Seongsu Cafe Street</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 = ['Seongsu Cafe Street', 'Seoul Forest Main Gate', 'Wangsimni Stn. Exit 2', 'Ttukseom Resort', 'Hangang Park Entrance'];
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 · Click-to-drop pin — Measured area 1.535% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Replaying that fall on the same node needs a forced reflow between removing and adding the class, or the browser collapses both writes into one frame and nothing moves.
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; // forcing a reflow is what lets the same animation run again
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);
}
02Count-badge cluster burst
What happens on screen
A badge holding the count sits where the markers overlap, and pressing it fans six real pins out with their labels until you press it again to fold them back into one badge. Each pin differs only by two direction coefficients, --kx and --ky, while the radius pair — --rx at 92px and --ry at 40px — is shared by all six.

<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">Hangang</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">Seoul Forest</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">Seongsu</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">Yeonmujang</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">Ttukseom</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">Wangsimni</span>
</li>
</ul>
<button class="cb__badge" type="button" id="cb-badge" aria-expanded="false" aria-controls="cb-group" aria-label="Expand 6 stores">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 stores are merged into one spot</span>
<button class="cb__toggle" type="button" id="cb-toggle" aria-expanded="false" aria-controls="cb-group">Expand</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 = 'Collapse', OFF = 'Expand';
var ONMSG = '6 stores are fanned out', OFFMSG = '6 stores are merged into one spot';
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 · Count-badge cluster burst — Measured area 5.049% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Shrinking those two numbers is the entire responsive fix, because nothing else in the burst knows how wide the board is.
@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); }
}
03Two-way card-and-pin highlight
What moves
Hovering or tabbing to a result card scales its marker to 1.42 and starts a ring spreading out from under it; pointing at the marker lights up the card border instead. The only thing joining the two halves is a matching data-id, so cards and pins can sit in any DOM order.

<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="Seongsu Roastery pin">
<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="Ttukseom Bakery pin">
<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="Seoul Forest Books pin">
<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">Seongsu Roastery</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">Ttukseom Bakery</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">Seoul Forest Books</span><span class="cl__dist">780 m</span></span>
</button></li>
</ul>
<span class="cl__sr" id="cl-out" role="status" aria-live="polite">Highlighted pin · Seongsu Roastery</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 = 'Highlighted pin · ' + 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 · Two-way card-and-pin highlight — Measured area 21.182% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The ring runs as an infinite animation rather than a transition because both of its ends are transparent, and a transition between two invisible states shows nothing at all.
.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;
}
.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); }
}
04Radius-circle distance slider
What you see
Dragging the slider grows the radius circle, walks the label up to 3.2km, and leaves only the pins inside it at full strength. That circle is a square built from aspect-ratio: 1 against the map height, and it changes size through scale(var(--k)) alone, so the board never reflows while the radius breathes.

<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">Radius</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">Drag or use the arrow keys</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;
}
}// 실제 동작: 슬라이더를 밀면(마우스) 또는 화살표 키를 누르면(키보드) Radius 원이 커지고,
// 핀마다 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 · Radius-circle distance slider — Measured area 9.331% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Membership is a plain comparison of each pin's data-dist against the raw slider value.
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);
});
}
05Address autocomplete dropdown
What happens on screen
Typing opens four candidates that slide up into place above the field, the arrow keys walk them, and Enter actually moves the map pin to that address. Each option carries its destination as data-x and data-y percentages, so picking one costs two style writes and nothing more.

<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="Address suggestions">
<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>Teheran-ro 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>Teheran-ro 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>Teheran-ro-gil 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>Teheran-ro 87-gil 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="Search an address">
<span class="ac__ghost" aria-hidden="true">Teheran-ro</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 · Address autocomplete dropdown — Measured area 17.575% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Focus never leaves the input — aria-activedescendant is what tells a screen reader which row is current.
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 });
}
06Current-location crosshair scan
What moves
Three rings ripple outward under the crosshair for 1.4 seconds; then the scan locks and a location dot starts pulsing where it stopped. The stagger comes from negative delays of -667ms and -1334ms on the second and third rings, which start them mid-cycle instead of queuing them one after another.

<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>Find my location</span>
</button>
<span class="lc__read">
<span class="lc__say" id="lc-say" role="status" aria-live="polite">Locating…</span>
<span class="lc__cut lc__cut--a" aria-hidden="true">Locating…</span>
<span class="lc__cut lc__cut--b" aria-hidden="true">Your location · 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 = 'Locating…';
clearTimeout(lcTimer);
lcTimer = setTimeout(function () {
lcRoot.classList.remove('is-scanning');
lcRoot.classList.add('is-locked');
lcSay.textContent = 'Your location · 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 · Current-location crosshair scan — Measured area 7.419% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The shorthand is avoided on purpose here, since writing animation on one line would reset that delay back to zero.
.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; }
07Self-drawing route line
What you see
The line fills from the start pin to the destination flag like a brush stroke, and a travel-time badge pops out once it lands. Setting pathLength="100" on the path normalizes its length, so the numbers handed to stroke-dasharray and stroke-dashoffset are already progress percentages.

<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="Estimated route from start to destination, 12 min 3.4 km">
<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 min · 3.4 km</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>Draw the route again</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 · Self-drawing route line — Measured area 3.528% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Moving the route coordinates later changes nothing about the dash math.
.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" makes the dash number a percentage
stroke-dashoffset: 100;
}
// under user control the line is drawn with a transition instead of the loop
.rd:not(.is-demo) .rd__line--draw { transition: stroke-dashoffset 900ms $ease-out; }
.rd.is-drawn .rd__line--draw { stroke-dashoffset: 0; }
08Favorite pins list
What happens on screen
Pressing a pin's heart flips aria-pressed, bounces the icon, and pushes that store into the chip row under the map. Chips are never removed from the DOM: they wait at max-width: 0 with padding and margin collapsed, and switching one on restores all of those values together.

<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="Save Cafe Ondo to favorites">
<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="Save Hana Books to favorites">
<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="Save Park Bakery to favorites">
<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="Save Salt House to favorites">
<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">My places</p>
<ul class="fp__chips" aria-live="polite" aria-label="Saved places">
<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">Cafe Ondo</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">Hana Books</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">Park Bakery</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">Salt House</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 · Favorite pins list — Measured area 3.539% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The row itself keeps gap: 0 for the same reason, since a flex gap would still reserve space around a chip folded down to nothing.
.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;
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.is-on {
max-width: 160px;
padding-left: $sp-2;
padding-right: $sp-2;
margin-right: $sp-2;
opacity: 1;
transform: scale(1);
}
09Press-to-expand mini map
What moves
A rectangle inside the corner map tracks the area currently in view, and pressing that mini map grows a larger overlay panel out of the same corner. transform-origin: 100% 100% anchors the growth to the bottom right, which keeps the panel from spilling past the edge of the board as it scales from .3 to 1.

<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">Whole map</span>
</p>
</div>
<button class="mm__mini" type="button" id="mm-btn" aria-expanded="false" aria-controls="mm-panel" aria-label="Expand the mini map">
<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">Tap the mini map to expand, Esc to collapse</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 · Press-to-expand mini map — Measured area 20.717% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Escape folds it back and returns focus to the button.
.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;
transform-origin: 100% 100%;
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; }
Where it breaks — the trap
Almost every coordinate in a map UI is a percentage, because "82% across, 38% down" has to mean the same doorway at any width. The trap is that feeding those percentages straight into left and top makes the browser recompute layout on every step, and once a pin is animating, that cost repeats every frame. The obvious swap fails in a less obvious way: a percentage inside transform: translate() resolves against the moving element itself rather than the board, so translate(82%) on a 20px pin shifts it by 16px and lands nowhere near the right place. What these nine do instead is store the coordinate in percent but never animate it — position writes happen only at the instant a person picks something, and everything that runs continuously (the fall in 01, the burst in 02, the scale in 03, the circle in 04, the overlay in 09) is expressed as transform and opacity.
The second wall is vertical room. Each demo is embedded at 480×300 and shrinks to 320×200 on a phone, which leaves 174px of height once the stage padding comes off. That is why every board's height is capped — clamp(104px, 34vw, 160px) in five of the nine, a tighter clamp or a fixed aspect-ratio in the rest; measured at 320px wide, the nine demos land between 127px and 155px, and widening the list spacing by another 4px is already enough to clip the bottom row. Taking input inside a narrow card is the exact case Login Form Design Patterns solved at the same width. The password for the archive holding these nine SCSS files and their React ports is 7ajdcn4a, and it opens the copies that carry exactly these numbers. The third break came out of 02 itself: six pins fanned at one radius with single-line labels still collide at 480px, and they only become readable after the arc spreads across angles from 170 degrees down to 10 with the labels alternating over two rows.
Accessibility (reduced-motion)
All nine drop their idle loop under prefers-reduced-motion: reduce while keeping the state that loop was showing. The pin stands on its spot without falling, the radius circle resizes with no transition, the scan collapses to one static ring around the locked dot, and the route line sits fully drawn. Pointer input is never the only way in, either.
| Pattern | Pointer | Keyboard | Announced as |
|---|---|---|---|
| 01 Pin drop | click the board | arrows to move, Enter to re-drop | place name in role="status" |
| 03 Card and pin | hover | Tab, then arrows for the next entry | aria-pressed, role="status" |
| 04 Radius | drag the slider | arrows, Home, End | distance in aria-valuetext |
| 05 Address | click | up, down, Enter, Escape | aria-activedescendant |
| 06 Locate | press the button | Enter, Space | aria-live="polite" |
| 09 Mini map | click | Enter, Space, Escape | aria-expanded |
The mechanism behind 05, where a visual cursor walks a list while real focus stays in the text field, is specified on MDN's combobox role page. Both 05 and 09 hand focus back with focus({ preventScroll: true }), which matters because these boards sit in an iframe inside an article: without that option the reader's page yanks itself down to the demo the moment a choice is made. Other components that return a chosen value are gathered under commerce, and the ones that answer a press are under click.
FAQ
Can this map pin css run on a real map instead of the CSS board?
The pins, rings, cards and slider carry over unchanged. Every pattern treats the board as a single position: relative box with a background and reads its coordinates as percentages of that box. Swapping in a real map means replacing the background with the library's canvas and routing those percentages through its projection function, which is two places rather than nine. When the picked place has to ship out as a card, the embeds of Social Share Button Design Widgets hold it.
When should markers start clustering into a count badge?
The trigger is distance on screen, not a total: cluster as soon as the gap between two markers drops below the width of the marker icon. That is why the same three pins cluster at one zoom level and separate at the next. The demo merges six because six is roughly where fanned labels stop being readable at 480px.
How does the radius slider decide what is inside the circle?
Each pin stores a value from 0 to 100 in data-dist and the code compares it against the raw slider position. A real service would compute the distance between the center point and each pin, then map the result onto that same 0 to 100 scale. Nothing downstream of the conversion has to change.









