地図 ui デザイン 9選 — クリックでピンが落ちる位置選択
地図 ui デザインとは、配達先や待ち合わせ場所を「住所を書かせる」のではなく地図の上で指して決めてもらう画面の作りです。9つとも地図 API を使わず、CSS で描いた盤の上だけで動きます。
01 map-pin-drop
02 map-cluster-burst
03 map-card-pin-link
04 map-radius-slider
05 map-address-autocomplete
06 map-locate-crosshair
07 map-route-draw
08 map-favorite-pins
09 map-minimap-expand
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 クリックで落ちるピンドロップ
- 02 数字バッジクラスターの拡散
- 03 結果カードとピンの双方向ハイライト
- 04 半径円の距離スライダー
- 05 住所自動補完ドロップダウン
- 06 現在地クロスヘッドスキャン
- 07 描かれていく経路線
- 08 お気に入りピンリスト
- 09 押して拡大するミニマップ
9つの並びは人気順ではなく、位置が決まるまでの時間軸です。前半の3つは地図の上に点を作る段階で、押した場所に点を打ち(01)、密集した点を数字に畳んでから開き(02)、リストの行と地図の点を同じ色で結びます(03)。中盤の3つは候補を絞る段階で、半径の外を切り落とし(04)、住所を打って中心を移し(05)、そもそもの出発点を現在地に置きます(06)。後半の3つは決まったあとの話で、経路を見せ(07)、保存し(08)、狭い画面で全体の位置関係を確認します(09)。マップ デザインの盤面の描き方は9つとも共通で、縦横2枚の repeating-linear-gradient で作った道路の格子を敷き、background の並びでそれより前に書いた公園の円と川の帯が道路の上を横切ります。先に書いた層が上に載るからです。その背景は固定したままピンとリングとカードだけを動かします。選択が時刻ならカレンダー css デザインが相手で、時刻ではなく区間ならタイムピッカー uiがその役を引き継ぎます。
01クリックで落ちるピンドロップ
「地図 ui と は」どんな画面か
地図のどこかを押すと、その座標へピンが 62px 上から落ちて一度だけ弾み、影は落ちてくる間は狭く、着地に合わせて広がります。クリック位置は地図ボックスの大きさで割ってパーセントに直すので、画面幅が変わっても同じ建物の上に刺さります。

<main class="stage">
<div class="pd is-demo">
<button class="pd__map" type="button" id="pd-map" aria-label="地図を押して位置を選んでください" aria-describedby="pd-val">
<span class="pd__shadow" id="pd-shadow" style="left:52%;top:58%" aria-hidden="true"></span>
<span class="pd__pin" id="pd-pin" style="left:52%;top:58%" aria-hidden="true">
<span class="pd__pin-i">
<svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
</span>
</span>
</button>
<div class="pd__bar">
<span class="pd__icon" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2"/><path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></svg></span>
<span class="pd__txt">
<span class="pd__cap">選択した位置</span>
<span class="pd__val" id="pd-val" role="status" aria-live="polite">ソンス洞カフェ通り</span>
</span>
<span class="pd__coord" id="pd-coord">37.5458, 127.0552</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// 가짜 지도 — 외부 타일·이미지·폰트 0. 그라디언트만 겹친다. 이 색들은 토큰에 없는 지도 전용색이다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.pd {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex; flex-direction: column; gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
// ── 지도판: 공원 원 → 강 띠 → 세로 도로 → 가로 도로 → 땅 순으로 겹친다. 배경 자체는 절대 움직이지 않는다 ──
.pd__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
margin: 0; padding: 0;
border: 0;
border-radius: $r-card;
cursor: crosshair;
box-shadow: $shadow-raised;
background:
radial-gradient(circle at 78% 24%, $map-park 0 38px, transparent 38px),
linear-gradient(72deg, transparent 0 10%, $map-water 10% 17%, transparent 17%),
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 29px),
repeating-linear-gradient(0deg, $map-road 0 7px, transparent 7px 27px),
$map-land;
}
// 블록 두 장 — 도로 격자만 있으면 벽지처럼 보인다
.pd__map::before, .pd__map::after {
content: "";
position: absolute;
border-radius: $r-xs;
background: $map-block;
pointer-events: none;
}
.pd__map::before { left: 13%; top: 17%; width: 64px; height: 30px; }
.pd__map::after { left: 31%; top: 63%; width: 88px; height: 26px; }
.pd__map:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pd__pin {
position: absolute;
width: 34px; height: 34px;
margin: 0;
transform: translate(-50%, -100%);
color: $color;
pointer-events: none;
}
.pd__pin-i { display: block; }
.pd__pin svg { display: block; }
.pd__shadow {
position: absolute;
width: 26px; height: 8px;
margin: 0;
border-radius: 50%;
background: rgba($subject-ink, .5);
transform: translate(-50%, -50%) scale(1);
opacity: .3;
pointer-events: none;
}
.pd__bar {
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-3;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: $r-control;
background: rgba(255, 255, 255, .06);
}
.pd__icon { display: inline-flex; color: $color; }
.pd__txt { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
.pd__cap {
font-size: 9px; line-height: 12px; font-weight: 700;
letter-spacing: .04em;
color: rgba(255, 255, 255, .55);
}
.pd__val {
font-size: 12px; line-height: 16px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pd__coord {
font-size: 10px; line-height: 14px; font-weight: 600;
color: rgba(255, 255, 255, .55);
white-space: nowrap;
}
// ── 사람이 누른 뒤: 한 번 떨어져 통통 튀고 앉는다 (.is-demo 는 JS 가 이미 뗐다) ──
.pd:not(.is-demo) .pd__pin.is-drop .pd__pin-i {
animation-name: pd-drop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
.pd:not(.is-demo) .pd__shadow.is-drop {
animation-name: pd-shadow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes pd-drop {
0% { opacity: 0; transform: translateY(-62px) scale(.92); animation-timing-function: cubic-bezier(.5, 0, .9, .4); }
14% { opacity: 1; transform: translateY(-52px) scale(.94); animation-timing-function: cubic-bezier(.5, 0, .9, .4); }
46% { opacity: 1; transform: translateY(0) scale(1.04); }
64% { opacity: 1; transform: translateY(-13px) scale(.98); }
82% { opacity: 1; transform: translateY(0) scale(1.02); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes pd-shadow {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
14% { opacity: .16; transform: translate(-50%, -50%) scale(.46); }
46% { opacity: .4; transform: translate(-50%, -50%) scale(1.18); }
64% { opacity: .22; transform: translate(-50%, -50%) scale(.72); }
82% { opacity: .36; transform: translate(-50%, -50%) scale(1.06); }
100% { opacity: .3; transform: translate(-50%, -50%) scale(1); }
}
// ── 자동 루프: 떨어짐 → 튐 → 유지 → 90% 안에서 fade-out+상승. 0% 와 100% 가 같은 상태라 이어 붙어도 안 튄다 ──
.pd.is-demo .pd__pin-i {
animation-name: pd-loop;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.pd.is-demo .pd__shadow {
animation-name: pd-shadow-loop;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pd-loop {
0% { opacity: 0; transform: translateY(-62px) scale(.92); }
6% { opacity: 1; transform: translateY(-62px) scale(.92); animation-timing-function: cubic-bezier(.5, 0, .9, .4); }
30% { opacity: 1; transform: translateY(0) scale(1.05); animation-timing-function: $easing; }
42% { opacity: 1; transform: translateY(-14px) scale(.97); }
54% { opacity: 1; transform: translateY(0) scale(1.03); }
62% { opacity: 1; transform: translateY(0) scale(1); }
80% { opacity: 1; transform: translateY(0) scale(1); }
90%, 100% { opacity: 0; transform: translateY(-62px) scale(.92); }
}
@keyframes pd-shadow-loop {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
6% { opacity: .16; transform: translate(-50%, -50%) scale(.44); }
30% { opacity: .4; transform: translate(-50%, -50%) scale(1.2); }
42% { opacity: .2; transform: translate(-50%, -50%) scale(.7); }
54% { opacity: .36; transform: translate(-50%, -50%) scale(1.08); }
62% { opacity: .3; transform: translate(-50%, -50%) scale(1); }
80% { opacity: .3; transform: translate(-50%, -50%) scale(1); }
90%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
}
@media (prefers-reduced-motion: reduce) {
.pd.is-demo .pd__pin-i,
.pd.is-demo .pd__shadow,
.pd:not(.is-demo) .pd__pin.is-drop .pd__pin-i,
.pd:not(.is-demo) .pd__shadow.is-drop { animation: none !important; }
.pd__map, .pd__bar { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pd {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.pd__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
margin: 0;
padding: 0;
border: 0;
border-radius: 16px;
cursor: crosshair;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 78% 24%, #c9e0bd 0 38px, transparent 38px), linear-gradient(72deg, transparent 0 10%, #bcd9ea 10% 17%, transparent 17%), repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 29px), repeating-linear-gradient(0deg, #fffdf7 0 7px, transparent 7px 27px), #efe9dc;
}
.pd__map::before, .pd__map::after {
content: "";
position: absolute;
border-radius: 4px;
background: #e2dac9;
pointer-events: none;
}
.pd__map::before {
left: 13%;
top: 17%;
width: 64px;
height: 30px;
}
.pd__map::after {
left: 31%;
top: 63%;
width: 88px;
height: 26px;
}
.pd__map:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pd__pin {
position: absolute;
width: 34px;
height: 34px;
margin: 0;
transform: translate(-50%, -100%);
color: #2f6df6;
pointer-events: none;
}
.pd__pin-i {
display: block;
}
.pd__pin svg {
display: block;
}
.pd__shadow {
position: absolute;
width: 26px;
height: 8px;
margin: 0;
border-radius: 50%;
background: rgba(23, 20, 26, 0.5);
transform: translate(-50%, -50%) scale(1);
opacity: 0.3;
pointer-events: none;
}
.pd__bar {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
}
.pd__icon {
display: inline-flex;
color: #2f6df6;
}
.pd__txt {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
}
.pd__cap {
font-size: 9px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.55);
}
.pd__val {
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pd__coord {
font-size: 10px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.55);
white-space: nowrap;
}
.pd:not(.is-demo) .pd__pin.is-drop .pd__pin-i {
animation-name: pd-drop;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
.pd:not(.is-demo) .pd__shadow.is-drop {
animation-name: pd-shadow;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes pd-drop {
0% {
opacity: 0;
transform: translateY(-62px) scale(0.92);
animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.4);
}
14% {
opacity: 1;
transform: translateY(-52px) scale(0.94);
animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.4);
}
46% {
opacity: 1;
transform: translateY(0) scale(1.04);
}
64% {
opacity: 1;
transform: translateY(-13px) scale(0.98);
}
82% {
opacity: 1;
transform: translateY(0) scale(1.02);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes pd-shadow {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
14% {
opacity: 0.16;
transform: translate(-50%, -50%) scale(0.46);
}
46% {
opacity: 0.4;
transform: translate(-50%, -50%) scale(1.18);
}
64% {
opacity: 0.22;
transform: translate(-50%, -50%) scale(0.72);
}
82% {
opacity: 0.36;
transform: translate(-50%, -50%) scale(1.06);
}
100% {
opacity: 0.3;
transform: translate(-50%, -50%) scale(1);
}
}
.pd.is-demo .pd__pin-i {
animation-name: pd-loop;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.pd.is-demo .pd__shadow {
animation-name: pd-shadow-loop;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pd-loop {
0% {
opacity: 0;
transform: translateY(-62px) scale(0.92);
}
6% {
opacity: 1;
transform: translateY(-62px) scale(0.92);
animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.4);
}
30% {
opacity: 1;
transform: translateY(0) scale(1.05);
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
42% {
opacity: 1;
transform: translateY(-14px) scale(0.97);
}
54% {
opacity: 1;
transform: translateY(0) scale(1.03);
}
62% {
opacity: 1;
transform: translateY(0) scale(1);
}
80% {
opacity: 1;
transform: translateY(0) scale(1);
}
90%, 100% {
opacity: 0;
transform: translateY(-62px) scale(0.92);
}
}
@keyframes pd-shadow-loop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
6% {
opacity: 0.16;
transform: translate(-50%, -50%) scale(0.44);
}
30% {
opacity: 0.4;
transform: translate(-50%, -50%) scale(1.2);
}
42% {
opacity: 0.2;
transform: translate(-50%, -50%) scale(0.7);
}
54% {
opacity: 0.36;
transform: translate(-50%, -50%) scale(1.08);
}
62% {
opacity: 0.3;
transform: translate(-50%, -50%) scale(1);
}
80% {
opacity: 0.3;
transform: translate(-50%, -50%) scale(1);
}
90%, 100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
}
@media (prefers-reduced-motion: reduce) {
.pd.is-demo .pd__pin-i,
.pd.is-demo .pd__shadow,
.pd:not(.is-demo) .pd__pin.is-drop .pd__pin-i,
.pd:not(.is-demo) .pd__shadow.is-drop {
animation: none !important;
}
.pd__map, .pd__bar {
transition: none;
}
}// 실제 동작: 지도를 누른 좌표에 핀을 떨어뜨린다. 키보드는 화살표로 옮기고 Enter·Space 로 다시 떨어뜨린다.
var root = document.querySelector('.pd');
var map = document.getElementById('pd-map');
var parts = [document.getElementById('pd-pin'), document.getElementById('pd-shadow')];
var val = document.getElementById('pd-val');
var coord = document.getElementById('pd-coord');
var names = ['ソンス洞カフェ通り', 'ソウルの森正門', 'ワンシムニ駅2番出口', 'トゥクソム遊園地', 'ハンガン公園入口'];
var x = 52, y = 58;
function drop(nx, ny) {
root.classList.remove('is-demo');
x = Math.max(8, Math.min(92, nx));
y = Math.max(14, Math.min(90, ny));
parts.forEach(function (el) {
el.style.left = x.toFixed(1) + '%';
el.style.top = y.toFixed(1) + '%';
el.classList.remove('is-drop');
void el.offsetWidth; // 리플로를 강제해야 같은 애니메이션이 다시 돈다
el.classList.add('is-drop');
});
val.textContent = names[Math.floor((x + y) / 38) % names.length];
coord.textContent = (37.52 + y / 400).toFixed(4) + ', ' + (127.03 + x / 400).toFixed(4);
}
map.addEventListener('click', function (e) {
if (!e.detail) { drop(x, y); return; } // Enter·Space 는 좌표가 0 이라 제자리에 다시 떨어뜨린다
var r = map.getBoundingClientRect();
drop(((e.clientX - r.left) / r.width) * 100, ((e.clientY - r.top) / r.height) * 100);
});
map.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') drop(x - 6, y);
else if (e.key === 'ArrowRight') drop(x + 6, y);
else if (e.key === 'ArrowUp') drop(x, y - 6);
else if (e.key === 'ArrowDown') drop(x, y + 6);
else return;
e.preventDefault();
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PinDrop.module.scss";
export type PinDropProps = {
label?: string;
hint?: string;
places?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// React 이벤트 타입은 'react' 에서 import 하지 않는다 — 포트 안에서 필요한 모양만 선언한다
type Box = { left: number; top: number; width: number; height: number };
type ClickEvt = { detail: number; clientX: number; clientY: number; currentTarget: { getBoundingClientRect: () => Box } };
type KeyEvt = { key: string; preventDefault: () => void };
export default function PinDrop({
label = "Selected location",
hint = "Click the map to pick a location",
places = ["Seongsu Cafe Street", "Seoul Forest Main Gate", "Wangsimni Stn. Exit 2", "Ttukseom Resort", "Hangang Park Entrance"],
duration = 600,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: PinDropProps) {
const [pos, setPos] = useState({ x: 52, y: 58 });
const [drops, setDrops] = useState(0); // key 를 바꿔 같은 애니메이션을 다시 돌린다 (vanilla 의 리플로 강제와 같은 역할)
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function drop(nx: number, ny: number) {
setDemo(false);
setPos({ x: Math.max(8, Math.min(92, nx)), y: Math.max(14, Math.min(90, ny)) });
setDrops((v) => v + 1);
}
const name = places[Math.floor((pos.x + pos.y) / 38) % places.length];
const coord = `${(37.52 + pos.y / 400).toFixed(4)}, ${(127.03 + pos.x / 400).toFixed(4)}`;
const at: CSSProperties = { left: `${pos.x}%`, top: `${pos.y}%` };
return (
<div className={`${styles.pd} ${demo ? styles.isDemo : ""}`} style={vars}>
<button
type="button"
className={styles.map}
aria-label={hint}
onClick={(e: ClickEvt) => {
if (!e.detail) { drop(pos.x, pos.y); return; } // Enter·Space 는 좌표가 0 이다
const r = e.currentTarget.getBoundingClientRect();
drop(((e.clientX - r.left) / r.width) * 100, ((e.clientY - r.top) / r.height) * 100);
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowLeft") drop(pos.x - 6, pos.y);
else if (e.key === "ArrowRight") drop(pos.x + 6, pos.y);
else if (e.key === "ArrowUp") drop(pos.x, pos.y - 6);
else if (e.key === "ArrowDown") drop(pos.x, pos.y + 6);
else return;
e.preventDefault();
}}
>
<span key={`s${drops}`} className={`${styles.shadow} ${drops ? styles.isDrop : ""}`} style={at} aria-hidden="true" />
<span key={`p${drops}`} className={`${styles.pin} ${drops ? styles.isDrop : ""}`} style={at} aria-hidden="true">
<span className={styles.pinI}>
<svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" /><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" /></g></svg>
</span>
</span>
</button>
<div className={styles.bar}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2" /><path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24" /></g></svg>
</span>
<span className={styles.txt}>
<span className={styles.cap}>{label}</span>
<span className={styles.val} role="status" aria-live="polite">{name}</span>
</span>
<span className={styles.coord}>{coord}</span>
</div>
</div>
);
}01 · クリックで落ちるピンドロップ — 計測 累積面積 1.535% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
同じアニメーションをもう一度走らせるには、クラスを外して付け直す間にリフローを一度強制する必要があります。
function drop(nx, ny) {
root.classList.remove('is-demo');
x = Math.max(8, Math.min(92, nx));
y = Math.max(14, Math.min(90, ny));
parts.forEach(function (el) {
el.style.left = x.toFixed(1) + '%';
el.style.top = y.toFixed(1) + '%';
el.classList.remove('is-drop');
void el.offsetWidth; // リフローを強制しないと同じアニメーションが再生されない
el.classList.add('is-drop');
});
val.textContent = names[Math.floor((x + y) / 38) % names.length];
coord.textContent = (37.52 + y / 400).toFixed(4) + ', ' + (127.03 + x / 400).toFixed(4);
}
02数字バッジクラスターの拡散
画面で起きること
重なったピンの位置に数字バッジが浮かび、押すとピン6個が扇状に散り、もう一度押すとバッジ1つへ合流します。

<main class="stage">
<div class="cb is-demo">
<div class="cb__map">
<ul class="cb__group" id="cb-group" aria-hidden="true">
<li class="cb__pin" style="--kx:-.985;--ky:-.174;--d:0ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">ハンガン店</span>
</li>
<li class="cb__pin cb--low" style="--kx:-.695;--ky:-.719;--d:30ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">ソウルの森店</span>
</li>
<li class="cb__pin" style="--kx:-.174;--ky:-.985;--d:60ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">ソンス店</span>
</li>
<li class="cb__pin cb--low" style="--kx:.174;--ky:-.985;--d:90ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">ヨンムジャン店</span>
</li>
<li class="cb__pin" style="--kx:.695;--ky:-.719;--d:120ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">トゥクソム店</span>
</li>
<li class="cb__pin cb--low" style="--kx:.985;--ky:-.174;--d:150ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">ワンシムニ店</span>
</li>
</ul>
<button class="cb__badge" type="button" id="cb-badge" aria-expanded="false" aria-controls="cb-group" aria-label="6店舗を展開する">6</button>
</div>
<div class="cb__bar">
<span class="cb__icon" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span>
<span class="cb__out" id="cb-out" role="status" aria-live="polite">店舗6件が1か所にまとまっています</span>
<button class="cb__toggle" type="button" id="cb-toggle" aria-expanded="false" aria-controls="cb-group">展開</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-yellow);
// 가짜 지도 — 외부 타일·이미지·폰트 0. 그라디언트만 겹친다. 이 색들은 토큰에 없는 지도 전용색이다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.cb {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex; flex-direction: column; gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
// 부채꼴 반지름 — 좁은 화면에서 줄여야 핀이 지도 밖으로 안 나간다
--rx: 92px;
--ry: 40px;
}
// ── 지도판: 공원 원 → 강 띠 → 세로 도로 → 가로 도로 → 땅. 배경은 고정, 움직이는 건 핀뿐 ──
.cb__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
border-radius: $r-card;
box-shadow: $shadow-press;
background:
radial-gradient(circle at 16% 78%, $map-park 0 34px, transparent 34px),
linear-gradient(100deg, transparent 0 74%, $map-water 74% 82%, transparent 82%),
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 31px),
repeating-linear-gradient(0deg, $map-road 0 7px, transparent 7px 25px),
$map-land;
}
.cb__map::before, .cb__map::after {
content: "";
position: absolute;
border-radius: $r-xs;
background: $map-block;
pointer-events: none;
}
.cb__map::before { left: 8%; top: 12%; width: 70px; height: 24px; }
.cb__map::after { right: 9%; bottom: 10%; width: 58px; height: 30px; }
.cb__group { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
.cb__pin {
position: absolute;
left: 50%; top: 50%;
width: 20px; height: 20px;
color: $color;
transform-origin: 50% 100%; // 규칙8: 핀 꼭짓점 고정 버스트
// 합쳐진 상태 — 배지 뒤에 접혀 있다
transform: translate(-50%, -100%) translate(0, 0) scale(.3);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
transition-delay: var(--d);
}
.cb__pin svg { display: block; }
.cb.is-open .cb__pin {
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
opacity: 1;
}
.cb__tag {
position: absolute;
left: 50%; top: 100%;
transform: translateX(-50%);
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(255, 255, 255, .94);
color: $subject-ink;
font-size: 8px; line-height: 13px; font-weight: 700;
white-space: nowrap;
}
// 이름표를 두 줄로 엇갈리게 — 한 줄에 여섯 개를 늘어놓으면 서로 겹쳐 못 읽는다
.cb__pin.cb--low .cb__tag { top: calc(100% + 13px); }
.cb__badge {
position: absolute;
left: 50%; top: 50%;
box-sizing: border-box;
width: 38px; height: 38px;
margin: 0; padding: 0;
border: 2px solid rgba(255, 255, 255, .9);
border-radius: 50%;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-size: 15px; line-height: 34px; font-weight: 800;
cursor: pointer;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
box-shadow: $shadow-press;
transition: transform $duration $easing, opacity $duration $easing;
}
.cb__badge:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.cb.is-open .cb__badge { transform: translate(-50%, -50%) scale(.42); opacity: 0; }
.cb__bar {
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-1 $sp-1 $sp-3;
border: 1px solid rgba($subject-ink, .14);
border-radius: $r-control;
background: rgba(255, 255, 255, .55);
}
.cb__icon { display: inline-flex; color: $color; }
.cb__out {
flex: 1 1 auto; min-width: 0;
font-size: 11px; line-height: 15px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cb__toggle {
box-sizing: border-box;
height: 24px;
margin: 0; padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 11px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.cb__toggle:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 자동 루프: 확산 → 유지 → 수축. 0% 와 100% 가 같은 합쳐진 상태다 (복귀는 86% 에 끝난다) ──
.cb.is-demo .cb__pin {
animation-name: cb-burst;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: var(--d);
}
.cb.is-demo .cb__badge {
animation-name: cb-merge;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cb-burst {
0% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
10% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
32% { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
68% { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
86%, 100% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
}
@keyframes cb-merge {
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
10% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
32% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
68% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
86%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
// 좁은 화면 — 지도가 낮아진 만큼 부채꼴을 좁힌다. 간격은 올리지 않는다 (BD9)
@media (max-width: 400px) {
.cb { --rx: 64px; --ry: 24px; }
.cb__tag { font-size: 7px; line-height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__pin, .cb.is-demo .cb__badge { animation: none !important; }
.cb__pin, .cb__badge { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
--rx: 92px;
--ry: 40px;
}
.cb__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 16% 78%, #c9e0bd 0 34px, transparent 34px), linear-gradient(100deg, transparent 0 74%, #bcd9ea 74% 82%, transparent 82%), repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 31px), repeating-linear-gradient(0deg, #fffdf7 0 7px, transparent 7px 25px), #efe9dc;
}
.cb__map::before, .cb__map::after {
content: "";
position: absolute;
border-radius: 4px;
background: #e2dac9;
pointer-events: none;
}
.cb__map::before {
left: 8%;
top: 12%;
width: 70px;
height: 24px;
}
.cb__map::after {
right: 9%;
bottom: 10%;
width: 58px;
height: 30px;
}
.cb__group {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
}
.cb__pin {
position: absolute;
left: 50%;
top: 50%;
width: 20px;
height: 20px;
color: #2f6df6;
transform-origin: 50% 100%;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
opacity: 0;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
transition-delay: var(--d);
}
.cb__pin svg {
display: block;
}
.cb.is-open .cb__pin {
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
opacity: 1;
}
.cb__tag {
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
padding: 0 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
color: #17141a;
font-size: 8px;
line-height: 13px;
font-weight: 700;
white-space: nowrap;
}
.cb__pin.cb--low .cb__tag {
top: calc(100% + 13px);
}
.cb__badge {
position: absolute;
left: 50%;
top: 50%;
box-sizing: border-box;
width: 38px;
height: 38px;
margin: 0;
padding: 0;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: 15px;
line-height: 34px;
font-weight: 800;
cursor: pointer;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__badge:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cb.is-open .cb__badge {
transform: translate(-50%, -50%) scale(0.42);
opacity: 0;
}
.cb__bar {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 4px 4px 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.55);
}
.cb__icon {
display: inline-flex;
color: #2f6df6;
}
.cb__out {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 15px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cb__toggle {
box-sizing: border-box;
height: 24px;
margin: 0;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.cb__toggle:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cb.is-demo .cb__pin {
animation-name: cb-burst;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: var(--d);
}
.cb.is-demo .cb__badge {
animation-name: cb-merge;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cb-burst {
0% {
opacity: 0;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
}
10% {
opacity: 0;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
}
32% {
opacity: 1;
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
}
68% {
opacity: 1;
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
}
86%, 100% {
opacity: 0;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
}
}
@keyframes cb-merge {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
10% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
32% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
68% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
86%, 100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@media (max-width: 400px) {
.cb {
--rx: 64px;
--ry: 24px;
}
.cb__tag {
font-size: 7px;
line-height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__pin, .cb.is-demo .cb__badge {
animation: none !important;
}
.cb__pin, .cb__badge {
transition: none;
}
}// 실제 동작: 배지나 버튼을 누르면 핀이 부채꼴로 흩어지고 다시 누르면 배지 하나로 합쳐진다. Escape 로 접는다.
var root = document.querySelector('.cb');
var badge = document.getElementById('cb-badge');
var toggle = document.getElementById('cb-toggle');
var group = document.getElementById('cb-group');
var out = document.getElementById('cb-out');
var ON = '折りたたむ', OFF = '展開';
var ONMSG = '店舗6件が展開されました', OFFMSG = '店舗6件が1か所にまとまっています';
function set(on) {
root.classList.remove('is-demo'); // 사람이 만졌으니 자동 루프를 멈춘다
root.classList.toggle('is-open', on);
badge.setAttribute('aria-expanded', String(on));
toggle.setAttribute('aria-expanded', String(on));
group.setAttribute('aria-hidden', String(!on));
toggle.textContent = on ? ON : OFF;
out.textContent = on ? ONMSG : OFFMSG;
}
function flip() { set(!root.classList.contains('is-open')); }
badge.addEventListener('click', flip);
toggle.addEventListener('click', flip);
root.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { set(false); badge.focus({ preventScroll: true }); }
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ClusterBurst.module.scss";
export type ClusterBurstProps = {
stores?: string[];
labels?: { expand: string; collapse: string; merged: string; spread: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PinVars = CSSProperties & { "--kx"?: string; "--ky"?: string; "--d"?: string };
// React 이벤트 타입은 'react' 에서 import 하지 않는다 — 포트 안에서 필요한 모양만 선언한다
type KeyEvt = { key: string };
export default function ClusterBurst({
stores = ["Hangang", "Seoul Forest", "Seongsu", "Yeonmujang", "Ttukseom", "Wangsimni"],
labels = { expand: "Expand", collapse: "Collapse", merged: "stores are merged into one spot", spread: "stores are fanned out" },
duration = 300,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ClusterBurstProps) {
const [open, setOpen] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function set(on: boolean) {
setDemo(false); // 사람이 만졌으니 자동 루프를 멈춘다
setOpen(on);
}
// 부채꼴: 170° 에서 10° 까지 고르게. 이름표는 두 줄로 엇갈려야 겹치지 않는다
const fan = stores.map((name, i) => {
const a = ((170 - (i * 160) / Math.max(stores.length - 1, 1)) * Math.PI) / 180;
return { name, kx: Math.cos(a), ky: -Math.sin(a), low: i % 2 === 1 };
});
return (
<div className={`${styles.cb} ${open ? styles.isOpen : ""} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.map}>
<ul className={styles.group} id="cb-group" aria-hidden={!open}>
{fan.map((p, i) => {
const pv: PinVars = { "--kx": p.kx.toFixed(3), "--ky": p.ky.toFixed(3), "--d": `${i * 30}ms` };
return (
<li key={p.name} className={`${styles.pin} ${p.low ? styles.low : ""}`} style={pv}>
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" /><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" /></g></svg>
<span className={styles.tag}>{p.name}</span>
</li>
);
})}
</ul>
<button
type="button"
className={styles.badge}
aria-expanded={open}
aria-controls="cb-group"
aria-label={`${labels.expand} ${stores.length}`}
onClick={() => set(!open)}
>
{stores.length}
</button>
</div>
<div className={styles.bar}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2" /><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z" /></g></svg>
</span>
<span className={styles.out} role="status" aria-live="polite">
{stores.length} {open ? labels.spread : labels.merged}
</span>
<button
type="button"
className={styles.toggle}
aria-expanded={open}
aria-controls="cb-group"
onClick={() => set(!open)}
onKeyDown={(e: KeyEvt) => { if (e.key === "Escape") set(false); }}
>
{open ? labels.collapse : labels.expand}
</button>
</div>
</div>
);
}02 · 数字バッジクラスターの拡散 — 計測 累積面積 5.049% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ピンごとに違うのは方向の係数2つ(--kx・--ky)だけで、半径(--rx 92px・--ry 40px)は共通なので、狭い画面ではこの2つの値を縮めるだけでピンが地図の外へ出なくなります。
@keyframes cb-burst {
0% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
10% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
32% { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
68% { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
86%, 100% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
}
03結果カードとピンの双方向ハイライト
どんな動きか
カードにマウスを乗せるか Tab でフォーカスすると、地図側の同じ店舗のピンが 1.42 倍に拡大してリングが広がり、逆にピンへ乗せるとカードの枠が点灯します。

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

<main class="stage">
<div class="rs is-demo">
<div class="rs__map">
<span class="rs__ring" aria-hidden="true"></span>
<span class="rs__me" aria-hidden="true"></span>
<!-- 핀마다 data-dist = 중심에서의 거리(최대 반경을 100 으로 본 값). JS 가 슬라이더 값과 견준다 -->
<span class="rs__pin" data-dist="33" style="left:50%;top:66.5%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="33" style="left:44%;top:41.7%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="47" style="left:58%;top:63.5%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="47" style="left:58.5%;top:38.3%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="79" style="left:34.5%;top:63.3%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
<span class="rs__pin" data-dist="95" style="left:67.5%;top:27.8%" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
</div>
<div class="rs__bar">
<label class="rs__cap" for="rs-range">半径</label>
<input class="rs__range" id="rs-range" type="range" min="30" max="100" step="1" value="55" aria-valuetext="1.8km">
<span class="rs__read">
<span class="rs__km" id="rs-km">1.8km</span>
<span class="rs__cut rs__cut--a" aria-hidden="true">1.0km</span>
<span class="rs__cut rs__cut--b" aria-hidden="true">2.1km</span>
<span class="rs__cut rs__cut--c" aria-hidden="true">3.2km</span>
</span>
</div>
<p class="rs__hint">ドラッグまたは矢印キーで半径を変えられます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 가짜 지도 캔버스 — 외부 타일·이미지 없이 그라디언트만. 9개 데모가 같은 색을 쓴다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.rs {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
// ── 지도 ─────────────────────────────────────────────
// 세로 12 : 가로 5. 이 비율이 고정돼 있어야 핀의 data-dist(거리)와 원의 반경이 같은 자로 잰 값이 된다
.rs__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
aspect-ratio: 12 / 5;
background:
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 30px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 26px),
radial-gradient(circle at 16% 22%, $map-park 0 30px, transparent 30px),
linear-gradient(101deg, transparent 0 78%, $map-water 78% 90%, transparent 90%),
$map-land;
}
// 블록 두 개 — 지도에 변화를 준다. 배경과 함께 멈춰 있다(움직이는 건 원·핀뿐)
.rs__map::before,
.rs__map::after {
content: "";
position: absolute;
z-index: 0;
background: $map-block;
}
.rs__map::before { left: 6%; top: 58%; width: 15%; height: 30%; }
.rs__map::after { left: 76%; top: 12%; width: 12%; height: 34%; }
// 반경 원 — 지름이 지도 높이와 같을 때가 scale(1)
.rs__ring {
position: absolute;
z-index: 1;
left: 50%; top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, .6);
border-radius: 50%;
background: rgba(47, 109, 246, .12);
transform: translate(-50%, -50%) scale(var(--k, .55));
transition: transform $duration $easing;
}
.rs__me {
position: absolute;
z-index: 3;
left: 50%; top: 50%;
width: 8px; height: 8px;
border: 2px solid $stage-paper;
border-radius: 50%;
background: $subject-ink;
transform: translate(-50%, -50%);
}
.rs__pin {
position: absolute;
z-index: 2;
display: block;
color: $color;
opacity: .26;
transform: translate(-50%, -100%);
transition: opacity $duration steps(1, end);
}
.rs__pin.is-in { opacity: 1; }
// ── 컨트롤 ────────────────────────────────────────────
.rs__bar {
display: flex;
align-items: center;
gap: $sp-2;
}
.rs__cap {
flex: 0 0 auto;
font-size: 11px; line-height: 14px; font-weight: 700;
letter-spacing: .02em;
color: rgba(23, 20, 26, .62);
}
.rs__range {
flex: 1 1 auto;
min-width: 0;
height: 16px;
margin: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
cursor: pointer;
}
.rs__range::-webkit-slider-runnable-track {
height: 6px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .14);
}
.rs__range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
margin-top: -4px;
border: 0;
border-radius: 50%;
background: $color;
}
.rs__range::-moz-range-track { height: 6px; border-radius: $r-pill; background: rgba(23, 20, 26, .14); }
.rs__range::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: $color; }
.rs__range:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
// 거리 읽기 — 사람이 잡으면 .rs__km 이 실시간, 자동 루프 때는 컷 3장이 번갈아 뜬다
.rs__read {
position: relative;
flex: 0 0 auto;
width: 44px;
height: 16px;
text-align: right;
}
.rs__km,
.rs__cut {
position: absolute;
right: 0; top: 0;
font-size: 12px; line-height: 16px; font-weight: 700;
font-variant-numeric: tabular-nums;
color: $color;
white-space: nowrap;
}
.rs__cut { opacity: 0; }
.rs.is-demo .rs__km { opacity: 0; }
.rs__hint {
margin: 0;
font-size: 10px; line-height: 14px; font-weight: 600;
color: rgba($stage-ink, .72);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 반경 값 v 는 0%→50% 에서 30→100 으로, 50%→85% 에서 100→30 으로 선형으로 움직인다.
// 핀과 거리 컷은 그 v 가 임계를 넘는 시각에 steps(1, end) 로 딱 끊어 바뀐다 — 반투명 교차가 없다.
.rs.is-demo .rs__ring {
transition: none;
animation-name: rs-ring;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rs.is-demo .rs__pin {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__pin[data-dist="33"] { animation-name: rs-pin-33; }
.rs.is-demo .rs__pin[data-dist="47"] { animation-name: rs-pin-47; }
.rs.is-demo .rs__pin[data-dist="79"] { animation-name: rs-pin-79; }
.rs.is-demo .rs__pin[data-dist="95"] { animation-name: rs-pin-95; }
.rs.is-demo .rs__cut {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__cut--a { animation-name: rs-km-a; }
.rs.is-demo .rs__cut--b { animation-name: rs-km-b; }
.rs.is-demo .rs__cut--c { animation-name: rs-km-c; }
@keyframes rs-ring {
0% { transform: translate(-50%, -50%) scale(.3); }
50% { transform: translate(-50%, -50%) scale(1); }
85% { transform: translate(-50%, -50%) scale(.3); }
100% { transform: translate(-50%, -50%) scale(.3); }
}
@keyframes rs-pin-33 { 0% { opacity: .26; } 2.14% { opacity: 1; } 83.5% { opacity: .26; } 100% { opacity: .26; } }
@keyframes rs-pin-47 { 0% { opacity: .26; } 12.14% { opacity: 1; } 76.5% { opacity: .26; } 100% { opacity: .26; } }
@keyframes rs-pin-79 { 0% { opacity: .26; } 35% { opacity: 1; } 60.5% { opacity: .26; } 100% { opacity: .26; } }
@keyframes rs-pin-95 { 0% { opacity: .26; } 46.43% { opacity: 1; } 52.5% { opacity: .26; } 100% { opacity: .26; } }
@keyframes rs-km-a { 0% { opacity: 1; } 14.29% { opacity: 0; } 75% { opacity: 1; } 100% { opacity: 1; } }
@keyframes rs-km-b { 0% { opacity: 0; } 14.29% { opacity: 1; } 35.71% { opacity: 0; } 60% { opacity: 1; } 75% { opacity: 0; } 100% { opacity: 0; } }
@keyframes rs-km-c { 0% { opacity: 0; } 35.71% { opacity: 1; } 60% { opacity: 0; } 100% { opacity: 0; } }
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.rs { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__ring,
.rs.is-demo .rs__pin,
.rs.is-demo .rs__cut { animation: none !important; }
.rs.is-demo .rs__km { opacity: 1; } // 컷이 멈추면 실시간 값이 대신 보인다
.rs__ring, .rs__pin { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rs {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.rs__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
aspect-ratio: 12/5;
background: repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 30px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 26px), radial-gradient(circle at 16% 22%, #c9e0bd 0 30px, transparent 30px), linear-gradient(101deg, transparent 0 78%, #bcd9ea 78% 90%, transparent 90%), #efe9dc;
}
.rs__map::before,
.rs__map::after {
content: "";
position: absolute;
z-index: 0;
background: #e2dac9;
}
.rs__map::before {
left: 6%;
top: 58%;
width: 15%;
height: 30%;
}
.rs__map::after {
left: 76%;
top: 12%;
width: 12%;
height: 34%;
}
.rs__ring {
position: absolute;
z-index: 1;
left: 50%;
top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, 0.6);
border-radius: 50%;
background: rgba(47, 109, 246, 0.12);
transform: translate(-50%, -50%) scale(var(--k, 0.55));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rs__me {
position: absolute;
z-index: 3;
left: 50%;
top: 50%;
width: 8px;
height: 8px;
border: 2px solid #ffffff;
border-radius: 50%;
background: #17141a;
transform: translate(-50%, -50%);
}
.rs__pin {
position: absolute;
z-index: 2;
display: block;
color: #2f6df6;
opacity: 0.26;
transform: translate(-50%, -100%);
transition: opacity 220ms steps(1, end);
}
.rs__pin.is-in {
opacity: 1;
}
.rs__bar {
display: flex;
align-items: center;
gap: 8px;
}
.rs__cap {
flex: 0 0 auto;
font-size: 11px;
line-height: 14px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(23, 20, 26, 0.62);
}
.rs__range {
flex: 1 1 auto;
min-width: 0;
height: 16px;
margin: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
cursor: pointer;
}
.rs__range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
}
.rs__range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
margin-top: -4px;
border: 0;
border-radius: 50%;
background: #2f6df6;
}
.rs__range::-moz-range-track {
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
}
.rs__range::-moz-range-thumb {
width: 14px;
height: 14px;
border: 0;
border-radius: 50%;
background: #2f6df6;
}
.rs__range:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 999px;
}
.rs__read {
position: relative;
flex: 0 0 auto;
width: 44px;
height: 16px;
text-align: right;
}
.rs__km,
.rs__cut {
position: absolute;
right: 0;
top: 0;
font-size: 12px;
line-height: 16px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #2f6df6;
white-space: nowrap;
}
.rs__cut {
opacity: 0;
}
.rs.is-demo .rs__km {
opacity: 0;
}
.rs__hint {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.rs.is-demo .rs__ring {
transition: none;
animation-name: rs-ring;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rs.is-demo .rs__pin {
transition: none;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__pin[data-dist="33"] {
animation-name: rs-pin-33;
}
.rs.is-demo .rs__pin[data-dist="47"] {
animation-name: rs-pin-47;
}
.rs.is-demo .rs__pin[data-dist="79"] {
animation-name: rs-pin-79;
}
.rs.is-demo .rs__pin[data-dist="95"] {
animation-name: rs-pin-95;
}
.rs.is-demo .rs__cut {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__cut--a {
animation-name: rs-km-a;
}
.rs.is-demo .rs__cut--b {
animation-name: rs-km-b;
}
.rs.is-demo .rs__cut--c {
animation-name: rs-km-c;
}
@keyframes rs-ring {
0% {
transform: translate(-50%, -50%) scale(0.3);
}
50% {
transform: translate(-50%, -50%) scale(1);
}
85% {
transform: translate(-50%, -50%) scale(0.3);
}
100% {
transform: translate(-50%, -50%) scale(0.3);
}
}
@keyframes rs-pin-33 {
0% {
opacity: 0.26;
}
2.14% {
opacity: 1;
}
83.5% {
opacity: 0.26;
}
100% {
opacity: 0.26;
}
}
@keyframes rs-pin-47 {
0% {
opacity: 0.26;
}
12.14% {
opacity: 1;
}
76.5% {
opacity: 0.26;
}
100% {
opacity: 0.26;
}
}
@keyframes rs-pin-79 {
0% {
opacity: 0.26;
}
35% {
opacity: 1;
}
60.5% {
opacity: 0.26;
}
100% {
opacity: 0.26;
}
}
@keyframes rs-pin-95 {
0% {
opacity: 0.26;
}
46.43% {
opacity: 1;
}
52.5% {
opacity: 0.26;
}
100% {
opacity: 0.26;
}
}
@keyframes rs-km-a {
0% {
opacity: 1;
}
14.29% {
opacity: 0;
}
75% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes rs-km-b {
0% {
opacity: 0;
}
14.29% {
opacity: 1;
}
35.71% {
opacity: 0;
}
60% {
opacity: 1;
}
75% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rs-km-c {
0% {
opacity: 0;
}
35.71% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.rs {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__ring,
.rs.is-demo .rs__pin,
.rs.is-demo .rs__cut {
animation: none !important;
}
.rs.is-demo .rs__km {
opacity: 1;
}
.rs__ring, .rs__pin {
transition: none;
}
}// 실제 동작: 슬라이더를 밀면(마우스) 또는 화살표 키를 누르면(키보드) 半径 원이 커지고,
// 핀마다 data-dist 를 슬라이더 값과 견줘 원 안에 든 핀만 진해진다.
var rsRoot = document.querySelector('.rs');
var rsRange = document.getElementById('rs-range');
var rsRing = rsRoot.querySelector('.rs__ring');
var rsKm = document.getElementById('rs-km');
var rsPins = [].slice.call(rsRoot.querySelectorAll('.rs__pin'));
var RS_MAX_KM = 3.2;
function rsPaint() {
var v = Number(rsRange.value);
var t = (v / 100 * RS_MAX_KM).toFixed(1) + 'km';
rsRing.style.setProperty('--k', v / 100);
rsKm.textContent = t;
rsRange.setAttribute('aria-valuetext', t);
rsPins.forEach(function (p) {
p.classList.toggle('is-in', Number(p.getAttribute('data-dist')) <= v);
});
}
function rsLive() {
rsRoot.classList.remove('is-demo'); // 사람이 잡으면 자동 루프를 멈춘다
rsPaint();
}
rsRange.addEventListener('input', rsLive);
rsRange.addEventListener('pointerdown', rsLive);
rsRange.addEventListener('keydown', function (e) {
if (e.key.indexOf('Arrow') === 0 || e.key === 'Home' || e.key === 'End') rsLive();
});
rsPaint();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RadiusSlider.module.scss";
export type RadiusPin = {
/** 지도 안 가로 위치 (예: "58%") */
left: string;
/** 지도 안 세로 위치 (예: "34%") */
top: string;
/** 중심에서의 거리 — 최대 반경을 100 으로 본 값. 슬라이더 값과 바로 견준다 */
dist: number;
};
export type RadiusSliderProps = {
label?: string;
hint?: string;
pins?: RadiusPin[];
maxKm?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--k"?: string;
};
type ChangeEvt = { target: { value: string } };
const DEFAULT_PINS: RadiusPin[] = [
{ left: "50%", top: "66.5%", dist: 33 },
{ left: "44%", top: "41.7%", dist: 33 },
{ left: "58%", top: "63.5%", dist: 47 },
{ left: "58.5%", top: "38.3%", dist: 47 },
{ left: "34.5%", top: "63.3%", dist: 79 },
{ left: "67.5%", top: "27.8%", dist: 95 },
];
function PinIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" />
<path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" />
</g>
</svg>
);
}
export default function RadiusSlider({
label = "Radius",
hint = "Drag, or use the arrow keys",
pins = DEFAULT_PINS,
maxKm = 3.2,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: RadiusSliderProps) {
const [value, setValue] = useState(55);
const km = ((value / 100) * maxKm).toFixed(1) + "km";
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const ringVars: CSSProperties = { transform: `translate(-50%, -50%) scale(${value / 100})` };
return (
<div className={styles.rs} style={vars}>
<div className={styles.map}>
<span className={styles.ring} style={ringVars} aria-hidden="true" />
<span className={styles.me} aria-hidden="true" />
{pins.map((p, i) => (
<span
key={`${p.left}-${p.top}-${i}`}
className={`${styles.pin} ${p.dist <= value ? styles.isIn : ""}`}
style={{ left: p.left, top: p.top }}
aria-hidden="true"
>
<PinIcon />
</span>
))}
</div>
<div className={styles.bar}>
<label className={styles.cap} htmlFor="rs-range">{label}</label>
<input
id="rs-range"
className={styles.range}
type="range"
min={30}
max={100}
step={1}
value={value}
aria-valuetext={km}
onChange={(e: ChangeEvt) => setValue(Number(e.target.value))}
/>
<span className={styles.read}>{km}</span>
</div>
<p className={styles.hint}>{hint}</p>
</div>
);
}04 · 半径円の距離スライダー — 計測 累積面積 9.331% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
円は地図と同じ高さの正方形(aspect-ratio: 1)で、scale(var(--k)) だけで拡大するため、大きくなる間も地図のレイアウトは一度も計算し直されません。
function rsPaint() {
var v = Number(rsRange.value);
var t = (v / 100 * RS_MAX_KM).toFixed(1) + 'km';
rsRing.style.setProperty('--k', v / 100);
rsKm.textContent = t;
rsRange.setAttribute('aria-valuetext', t);
rsPins.forEach(function (p) {
p.classList.toggle('is-in', Number(p.getAttribute('data-dist')) <= v);
});
}
05住所自動補完ドロップダウン
画面で起きること
文字を打つと候補4件が入力欄の上へスライドして現れ、上下の矢印キーで移動して Enter を押すと地図のピンがその候補の座標へ実際に飛びます。

<main class="stage">
<div class="ac is-demo">
<div class="ac__map">
<span class="ac__pin" id="ac-pin" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg></span>
</div>
<!-- 후보 목록은 입력칸 위로 열려 지도를 덮는다 — 절대배치라 뿌리 높이엔 안 잡히지만 뿌리 안쪽에 머문다 -->
<ul class="ac__list" id="ac-list" role="listbox" aria-label="住所の候補">
<li class="ac__opt" id="ac-o0" role="option" aria-selected="false" data-x="88%" data-y="30%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>テヘラン路152</span></li>
<li class="ac__opt" id="ac-o1" role="option" aria-selected="false" data-x="74%" data-y="58%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>テヘラン路427</span></li>
<li class="ac__opt" id="ac-o2" role="option" aria-selected="false" data-x="78%" data-y="72%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>テヘラン路キル21</span></li>
<li class="ac__opt" id="ac-o3" role="option" aria-selected="false" data-x="92%" data-y="46%"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg><span>テヘラン路87キル36</span></li>
</ul>
<div class="ac__field">
<span class="ac__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="ac__input" id="ac-input" type="text" role="combobox" aria-expanded="false" aria-controls="ac-list" aria-autocomplete="list" autocomplete="off" placeholder="住所を検索">
<span class="ac__ghost" aria-hidden="true">テヘラン路</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// 가짜 지도 캔버스 — 9개 데모가 같은 색으로 한 가족을 이룬다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.ac {
position: relative;
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
// 목록은 입력칸 위(지도 쪽)로 열린다 — 뿌리 높이를 넘지 않으니 따로 비워 둘 아래 여백이 없다
padding-bottom: 0;
}
// ── 지도 ─────────────────────────────────────────────
.ac__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
aspect-ratio: 12 / 5;
background:
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 32px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 24px),
radial-gradient(circle at 82% 78%, $map-park 0 26px, transparent 26px),
linear-gradient(-72deg, transparent 0 74%, $map-water 74% 86%, transparent 86%),
$map-land;
}
.ac__map::before,
.ac__map::after {
content: "";
position: absolute;
z-index: 0;
background: $map-block;
}
.ac__map::before { left: 12%; top: 16%; width: 14%; height: 32%; }
.ac__map::after { left: 46%; top: 62%; width: 17%; height: 26%; }
// 핀은 지도 오른쪽 띠에 산다 — 목록이 왼쪽 2/3 을 덮어도 핀이 옮겨가는 게 보여야 한다
.ac__pin {
position: absolute;
z-index: 2;
left: 82%; top: 38%;
display: block;
color: $color;
transform: translate(-50%, -100%);
transition: left $dur-base $easing, top $dur-base $easing;
}
// ── 후보 목록 (입력칸 바로 위로 슬라이드해 올라온다) ────────
.ac__list {
position: absolute;
z-index: 3;
left: 0; right: 34%; bottom: 36px;
box-sizing: border-box;
margin: 0;
padding: $sp-1;
list-style: none;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: bottom center; // 규칙8: 입력칸 위변에서 펼침
transform: translateY(10px) scale(.96);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.ac.is-open .ac__list {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.ac__opt {
position: relative;
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
color: $subject-ink;
font-size: 11px; line-height: 14px; font-weight: 600;
cursor: pointer;
}
// 하이라이트 판(::before)이 절대배치라 글자보다 나중에 그려진다 — 글자도 positioned 로 올려 덮이지 않게 한다
.ac__opt > svg { position: relative; z-index: 1; flex: 0 0 auto; color: $color; }
.ac__opt > span { position: relative; z-index: 1; }
.ac__opt::before {
content: "";
position: absolute;
inset: 0;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
opacity: 0;
}
.ac__opt.is-active::before { opacity: 1; }
// ── 검색 입력칸 ───────────────────────────────────────
.ac__field {
position: relative;
height: 28px;
flex: 0 0 auto;
}
.ac__ico {
position: absolute;
left: 11px; top: 50%;
display: inline-flex;
color: rgba(255, 255, 255, .5);
transform: translateY(-50%);
pointer-events: none;
}
.ac__input {
box-sizing: border-box;
width: 100%; height: 100%;
padding: 0 $sp-3 0 $sp-8;
border: 1px solid rgba(255, 255, 255, .16);
border-radius: $r-control;
background: rgba(255, 255, 255, .07);
color: $subject-cream;
font-family: inherit;
font-size: 12px; line-height: 26px; font-weight: 600;
}
.ac__input::placeholder { color: rgba(255, 255, 255, .38); }
.ac__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ac.is-open .ac__input { border-color: $color; }
// 자동 루프 때만 보이는 "타이핑" 유령 글자. 진짜 placeholder 와 겹치지 않게 투명으로 지운다
.ac.is-demo .ac__input::placeholder { color: transparent; }
.ac__ghost {
position: absolute;
left: 33px; top: 0;
color: $subject-cream;
font-size: 12px; line-height: 28px; font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~30% 한 자씩 타이핑 → 32~40% 목록 열림 → 40·50·58% 하이라이트 이동 → 66% 선택(핀 이동)
// → 80~90% 지우기 · 82~90% 목록 닫힘 · 84~90% 핀 복귀. 90% 에 처음 상태로 돌아온다.
.ac.is-demo .ac__ghost {
opacity: 1;
animation-name: ac-type;
animation-duration: $dur-loop;
animation-timing-function: steps(4, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__list {
transition: none;
animation-name: ac-list;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt::before {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt:nth-child(1)::before { animation-name: ac-hl-0; }
.ac.is-demo .ac__opt:nth-child(2)::before { animation-name: ac-hl-1; }
.ac.is-demo .ac__opt:nth-child(3)::before { animation-name: ac-hl-2; }
.ac.is-demo .ac__pin {
transition: none;
animation-name: ac-pin;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes ac-type {
0% { clip-path: inset(0 100% 0 0); }
30% { clip-path: inset(0 0 0 0); }
80% { clip-path: inset(0 0 0 0); }
90% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
@keyframes ac-list {
0% { opacity: 0; transform: translateY(10px) scale(.96); }
32% { opacity: 0; transform: translateY(10px) scale(.96); }
40% { opacity: 1; transform: translateY(0) scale(1); }
82% { opacity: 1; transform: translateY(0) scale(1); }
90% { opacity: 0; transform: translateY(10px) scale(.96); }
100% { opacity: 0; transform: translateY(10px) scale(.96); }
}
@keyframes ac-hl-0 { 0% { opacity: 0; } 40% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 0; } }
@keyframes ac-hl-1 { 0% { opacity: 0; } 50% { opacity: 1; } 58% { opacity: 0; } 100% { opacity: 0; } }
@keyframes ac-hl-2 { 0% { opacity: 0; } 58% { opacity: 1; } 90% { opacity: 0; } 100% { opacity: 0; } }
@keyframes ac-pin {
0% { left: 82%; top: 38%; }
66% { left: 82%; top: 38%; }
74% { left: 78%; top: 72%; }
84% { left: 78%; top: 72%; }
90% { left: 82%; top: 38%; }
100% { left: 82%; top: 38%; }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.ac { gap: $sp-1; }
.ac__list { bottom: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__ghost,
.ac.is-demo .ac__list,
.ac.is-demo .ac__opt::before,
.ac.is-demo .ac__pin { animation: none !important; }
.ac.is-demo .ac__ghost { opacity: 0; }
.ac__list, .ac__pin, .ac__input { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ac {
position: relative;
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding-bottom: 0;
}
.ac__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
aspect-ratio: 12/5;
background: repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 32px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 24px), radial-gradient(circle at 82% 78%, #c9e0bd 0 26px, transparent 26px), linear-gradient(-72deg, transparent 0 74%, #bcd9ea 74% 86%, transparent 86%), #efe9dc;
}
.ac__map::before,
.ac__map::after {
content: "";
position: absolute;
z-index: 0;
background: #e2dac9;
}
.ac__map::before {
left: 12%;
top: 16%;
width: 14%;
height: 32%;
}
.ac__map::after {
left: 46%;
top: 62%;
width: 17%;
height: 26%;
}
.ac__pin {
position: absolute;
z-index: 2;
left: 82%;
top: 38%;
display: block;
color: #2f6df6;
transform: translate(-50%, -100%);
transition: left 300ms cubic-bezier(0.2, 1.4, 0.4, 1), top 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ac__list {
position: absolute;
z-index: 3;
left: 0;
right: 34%;
bottom: 36px;
box-sizing: border-box;
margin: 0;
padding: 4px;
list-style: none;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: bottom center;
transform: translateY(10px) scale(0.96);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ac.is-open .ac__list {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.ac__opt {
position: relative;
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
color: #17141a;
font-size: 11px;
line-height: 14px;
font-weight: 600;
cursor: pointer;
}
.ac__opt > svg {
position: relative;
z-index: 1;
flex: 0 0 auto;
color: #2f6df6;
}
.ac__opt > span {
position: relative;
z-index: 1;
}
.ac__opt::before {
content: "";
position: absolute;
inset: 0;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
opacity: 0;
}
.ac__opt.is-active::before {
opacity: 1;
}
.ac__field {
position: relative;
height: 28px;
flex: 0 0 auto;
}
.ac__ico {
position: absolute;
left: 11px;
top: 50%;
display: inline-flex;
color: rgba(255, 255, 255, 0.5);
transform: translateY(-50%);
pointer-events: none;
}
.ac__input {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0 12px 0 32px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
background: rgba(255, 255, 255, 0.07);
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 26px;
font-weight: 600;
}
.ac__input::placeholder {
color: rgba(255, 255, 255, 0.38);
}
.ac__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ac.is-open .ac__input {
border-color: #2f6df6;
}
.ac.is-demo .ac__input::placeholder {
color: transparent;
}
.ac__ghost {
position: absolute;
left: 33px;
top: 0;
color: #fff7e6;
font-size: 12px;
line-height: 28px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.ac.is-demo .ac__ghost {
opacity: 1;
animation-name: ac-type;
animation-duration: 2s;
animation-timing-function: steps(4, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__list {
transition: none;
animation-name: ac-list;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt::before {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ac.is-demo .ac__opt:nth-child(1)::before {
animation-name: ac-hl-0;
}
.ac.is-demo .ac__opt:nth-child(2)::before {
animation-name: ac-hl-1;
}
.ac.is-demo .ac__opt:nth-child(3)::before {
animation-name: ac-hl-2;
}
.ac.is-demo .ac__pin {
transition: none;
animation-name: ac-pin;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ac-type {
0% {
clip-path: inset(0 100% 0 0);
}
30% {
clip-path: inset(0 0 0 0);
}
80% {
clip-path: inset(0 0 0 0);
}
90% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes ac-list {
0% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
32% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
40% {
opacity: 1;
transform: translateY(0) scale(1);
}
82% {
opacity: 1;
transform: translateY(0) scale(1);
}
90% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
}
@keyframes ac-hl-0 {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-hl-1 {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
58% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-hl-2 {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ac-pin {
0% {
left: 82%;
top: 38%;
}
66% {
left: 82%;
top: 38%;
}
74% {
left: 78%;
top: 72%;
}
84% {
left: 78%;
top: 72%;
}
90% {
left: 82%;
top: 38%;
}
100% {
left: 82%;
top: 38%;
}
}
@media (max-width: 360px) {
.ac {
gap: 4px;
}
.ac__list {
bottom: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__ghost,
.ac.is-demo .ac__list,
.ac.is-demo .ac__opt::before,
.ac.is-demo .ac__pin {
animation: none !important;
}
.ac.is-demo .ac__ghost {
opacity: 0;
}
.ac__list, .ac__pin, .ac__input {
transition: none;
}
}// 실제 동작: 글자를 치면 후보가 열리고, ↑↓ 로 옮기고, Enter(또는 클릭)로 고르면 지도 핀이 그 좌표로 간다.
var acRoot = document.querySelector('.ac');
var acInput = document.getElementById('ac-input');
var acList = document.getElementById('ac-list');
var acPin = document.getElementById('ac-pin');
var acOpts = [].slice.call(acList.querySelectorAll('.ac__opt'));
var acActive = 0;
function acPaint() {
acOpts.forEach(function (o, i) { o.classList.toggle('is-active', i === acActive); });
acInput.setAttribute('aria-activedescendant', acOpts[acActive].id);
}
function acOpen(on) {
acRoot.classList.remove('is-demo'); // 사람이 치면 자동 루프를 멈춘다
acRoot.classList.toggle('is-open', on);
acInput.setAttribute('aria-expanded', String(on));
if (on) acPaint(); else acInput.removeAttribute('aria-activedescendant');
}
function acPick(i) {
var o = acOpts[i];
acOpts.forEach(function (x, k) { x.setAttribute('aria-selected', String(k === i)); });
acInput.value = o.lastElementChild.textContent;
acPin.style.left = o.getAttribute('data-x');
acPin.style.top = o.getAttribute('data-y');
acOpen(false);
acInput.focus({ preventScroll: true });
}
acInput.addEventListener('input', function () { acOpen(acInput.value.length > 0); });
acInput.addEventListener('focus', function () { acOpen(acInput.value.length > 0); });
acInput.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { acActive = Math.min(acActive + 1, acOpts.length - 1); acOpen(true); e.preventDefault(); }
else if (e.key === 'ArrowUp') { acActive = Math.max(acActive - 1, 0); acOpen(true); e.preventDefault(); }
else if (e.key === 'Enter') { acPick(acActive); e.preventDefault(); }
else if (e.key === 'Escape') { acOpen(false); }
});
acOpts.forEach(function (o, i) {
o.addEventListener('mousemove', function () { acActive = i; acPaint(); });
o.addEventListener('click', function () { acPick(i); });
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AddressAuto.module.scss";
export type AddressItem = {
text: string;
/** 지도 안 좌표 (예: "58%") */
x: string;
y: string;
};
export type AddressAutoProps = {
placeholder?: string;
listLabel?: string;
items?: AddressItem[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
const DEFAULT_ITEMS: AddressItem[] = [
{ text: "Teheran-ro 152", x: "88%", y: "30%" },
{ text: "Teheran-ro 427", x: "74%", y: "58%" },
{ text: "Teheran-ro-gil 21", x: "78%", y: "72%" },
{ text: "Teheran-ro 87-gil 36", x: "92%", y: "46%" },
];
function PinIcon({ size }: { size: number }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size} aria-hidden="true">
<g fill="currentColor">
<path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" />
<path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" />
</g>
</svg>
);
}
export default function AddressAuto({
placeholder = "Search an address",
listLabel = "Address suggestions",
items = DEFAULT_ITEMS,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: AddressAutoProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [picked, setPicked] = useState(-1);
const [spot, setSpot] = useState({ left: "82%", top: "38%" });
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("click", onDocClick);
return () => document.removeEventListener("click", onDocClick);
}, []);
function pick(i: number) {
const it = items[i];
setPicked(i);
setQuery(it.text);
setSpot({ left: it.x, top: it.y });
setOpen(false);
inputRef.current?.focus({ preventScroll: true });
}
return (
<div className={`${styles.ac} ${open ? styles.isOpen : ""}`} style={vars} ref={rootRef}>
<div className={styles.map}>
<span className={styles.pin} style={spot} aria-hidden="true"><PinIcon size={20} /></span>
</div>
<ul className={styles.list} id="ac-list" role="listbox" aria-label={listLabel}>
{items.map((it, i) => (
<li
key={it.text}
id={`ac-o${i}`}
className={`${styles.opt} ${i === active ? styles.isActive : ""}`}
role="option"
aria-selected={i === picked}
onMouseMove={() => setActive(i)}
onClick={() => pick(i)}
>
<PinIcon size={13} />
<span>{it.text}</span>
</li>
))}
</ul>
<div className={styles.field}>
<span className={styles.ico} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
autoComplete="off"
placeholder={placeholder}
value={query}
aria-expanded={open}
aria-controls="ac-list"
aria-autocomplete="list"
aria-activedescendant={open ? `ac-o${active}` : undefined}
onChange={(e: ChangeEvt) => { setQuery(e.target.value); setOpen(e.target.value.length > 0); }}
onFocus={() => setOpen(query.length > 0)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown") { setActive((a) => Math.min(a + 1, items.length - 1)); setOpen(true); e.preventDefault(); }
else if (e.key === "ArrowUp") { setActive((a) => Math.max(a - 1, 0)); setOpen(true); e.preventDefault(); }
else if (e.key === "Enter") { pick(active); e.preventDefault(); }
else if (e.key === "Escape") { setOpen(false); }
}}
/>
</div>
</div>
);
}05 · 住所自動補完ドロップダウン — 計測 累積面積 17.575% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
候補の座標は要素ごとに data-x・data-y として、地図の幅を基準にしたパーセントで埋め込んであります。
function acPick(i) {
var o = acOpts[i];
acOpts.forEach(function (x, k) { x.setAttribute('aria-selected', String(k === i)); });
acInput.value = o.lastElementChild.textContent;
acPin.style.left = o.getAttribute('data-x');
acPin.style.top = o.getAttribute('data-y');
acOpen(false);
acInput.focus({ preventScroll: true });
}
06現在地クロスヘッドスキャン
どんな動きか
ボタンを押すとリング3個が時間差で外へ広がり、1.4秒後にスキャンが終わるとその場所で現在地の点が脈打ち始めます。

<main class="stage">
<div class="lc is-demo">
<div class="lc__map">
<span class="lc__ring lc__ring--1" aria-hidden="true"></span>
<span class="lc__ring lc__ring--2" aria-hidden="true"></span>
<span class="lc__ring lc__ring--3" aria-hidden="true"></span>
<span class="lc__cross" aria-hidden="true"><svg viewBox="0 0 256 256" width="36" height="36"><g fill="currentColor"><path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2"/><path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></svg></span>
<span class="lc__dot" aria-hidden="true"></span>
</div>
<div class="lc__bar">
<!-- 진짜 <button> 이라 마우스 클릭과 키보드 Enter·Space 가 모두 click 으로 들어온다 -->
<button class="lc__btn" type="button" id="lc-btn">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2"/><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z"/></g></svg>
<span>現在地を探す</span>
</button>
<span class="lc__read">
<span class="lc__say" id="lc-say" role="status" aria-live="polite">現在地を検索しています</span>
<span class="lc__cut lc__cut--a" aria-hidden="true">現在地を検索しています</span>
<span class="lc__cut lc__cut--b" aria-hidden="true">現在地・誤差12m</span>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
// 가짜 지도 캔버스 — 9개 데모가 같은 색으로 한 가족을 이룬다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.lc {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
// ── 지도 ─────────────────────────────────────────────
.lc__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
aspect-ratio: 12 / 5;
background:
repeating-linear-gradient(90deg, $map-road 0 8px, transparent 8px 28px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 28px),
radial-gradient(circle at 8% 76%, $map-park 0 28px, transparent 28px),
linear-gradient(118deg, transparent 0 82%, $map-water 82% 94%, transparent 94%),
$map-land;
}
.lc__map::before,
.lc__map::after {
content: "";
position: absolute;
z-index: 0;
background: $map-block;
}
.lc__map::before { left: 24%; top: 14%; width: 13%; height: 30%; }
.lc__map::after { left: 68%; top: 56%; width: 15%; height: 30%; }
// 스캔 링 — 지름이 지도 높이와 같을 때가 scale(1)
.lc__ring {
position: absolute;
z-index: 1;
left: 50%; top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, .75);
border-radius: 50%;
background: rgba(47, 109, 246, .08);
opacity: 0;
transform: translate(-50%, -50%) scale(.18);
}
.lc__cross {
position: absolute;
z-index: 2;
left: 50%; top: 50%;
display: block;
color: rgba(23, 20, 26, .5);
transform: translate(-50%, -50%);
}
.lc__dot {
position: absolute;
z-index: 3;
left: 50%; top: 50%;
width: 12px; height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $color;
box-shadow: $shadow-press;
transform: translate(-50%, -50%);
}
// ── 컨트롤 ────────────────────────────────────────────
.lc__bar {
display: flex;
align-items: center;
gap: $sp-2;
}
.lc__btn {
flex: 0 0 auto;
box-sizing: border-box;
height: 28px;
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 11px; line-height: 14px; font-weight: 700;
cursor: pointer;
transition: transform $duration $ease-pop, box-shadow $duration $easing;
}
.lc__btn:hover { box-shadow: $shadow-press; }
.lc__btn:active { transform: scale(.96); }
.lc__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 상태 문구 — 사람이 누르면 .lc__say 가 실시간(aria-live), 자동 루프 때는 컷 2장이 번갈아 뜬다
.lc__read {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 16px;
overflow: hidden;
}
.lc__say,
.lc__cut {
position: absolute;
right: 0; top: 0;
font-size: 10px; line-height: 16px; font-weight: 700;
color: rgba(23, 20, 26, .66);
white-space: nowrap;
}
.lc__cut { opacity: 0; }
.lc.is-demo .lc__say { opacity: 0; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 링 3개가 시차를 두고 바깥으로 퍼지고(단축 금지 — 단축이 animation-delay 를 0 으로 지운다),
// 가운데 점은 맥박친다. 상태 문구는 60% 에 "찾는 중 → 현재 위치" 로 끊어 바뀐다.
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring {
animation-name: lc-scan;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__ring--2,
.lc.is-scanning .lc__ring--2 { animation-delay: -667ms; }
.lc.is-demo .lc__ring--3,
.lc.is-scanning .lc__ring--3 { animation-delay: -1334ms; }
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot {
animation-name: lc-pulse;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__cut {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lc.is-demo .lc__cut--a { animation-name: lc-say-a; }
.lc.is-demo .lc__cut--b { animation-name: lc-say-b; }
@keyframes lc-scan {
0% { opacity: 0; transform: translate(-50%, -50%) scale(.18); }
14% { opacity: .9; transform: translate(-50%, -50%) scale(.4); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.04); }
}
@keyframes lc-pulse {
0% { transform: translate(-50%, -50%) scale(1); }
30% { transform: translate(-50%, -50%) scale(1.45); }
60% { transform: translate(-50%, -50%) scale(1); }
100% { transform: translate(-50%, -50%) scale(1); }
}
@keyframes lc-say-a { 0% { opacity: 1; } 60% { opacity: 0; } 90% { opacity: 1; } 100% { opacity: 1; } }
@keyframes lc-say-b { 0% { opacity: 0; } 60% { opacity: 1; } 90% { opacity: 0; } 100% { opacity: 0; } }
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.lc { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring,
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot,
.lc.is-demo .lc__cut { animation: none !important; }
.lc.is-demo .lc__say { opacity: 1; } // 컷이 멈추면 실시간 문구가 대신 보인다
.lc__ring--1 { opacity: .9; transform: translate(-50%, -50%) scale(.55); }
.lc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lc {
box-sizing: border-box;
width: min(320px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.lc__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
aspect-ratio: 12/5;
background: repeating-linear-gradient(90deg, #fffdf7 0 8px, transparent 8px 28px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 28px), radial-gradient(circle at 8% 76%, #c9e0bd 0 28px, transparent 28px), linear-gradient(118deg, transparent 0 82%, #bcd9ea 82% 94%, transparent 94%), #efe9dc;
}
.lc__map::before,
.lc__map::after {
content: "";
position: absolute;
z-index: 0;
background: #e2dac9;
}
.lc__map::before {
left: 24%;
top: 14%;
width: 13%;
height: 30%;
}
.lc__map::after {
left: 68%;
top: 56%;
width: 15%;
height: 30%;
}
.lc__ring {
position: absolute;
z-index: 1;
left: 50%;
top: 50%;
box-sizing: border-box;
height: 100%;
aspect-ratio: 1;
border: 2px solid rgba(47, 109, 246, 0.75);
border-radius: 50%;
background: rgba(47, 109, 246, 0.08);
opacity: 0;
transform: translate(-50%, -50%) scale(0.18);
}
.lc__cross {
position: absolute;
z-index: 2;
left: 50%;
top: 50%;
display: block;
color: rgba(23, 20, 26, 0.5);
transform: translate(-50%, -50%);
}
.lc__dot {
position: absolute;
z-index: 3;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
}
.lc__bar {
display: flex;
align-items: center;
gap: 8px;
}
.lc__btn {
flex: 0 0 auto;
box-sizing: border-box;
height: 28px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
line-height: 14px;
font-weight: 700;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), box-shadow 220ms ease-out;
}
.lc__btn:hover {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.lc__btn:active {
transform: scale(0.96);
}
.lc__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.lc__read {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 16px;
overflow: hidden;
}
.lc__say,
.lc__cut {
position: absolute;
right: 0;
top: 0;
font-size: 10px;
line-height: 16px;
font-weight: 700;
color: rgba(23, 20, 26, 0.66);
white-space: nowrap;
}
.lc__cut {
opacity: 0;
}
.lc.is-demo .lc__say {
opacity: 0;
}
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring {
animation-name: lc-scan;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__ring--2,
.lc.is-scanning .lc__ring--2 {
animation-delay: -667ms;
}
.lc.is-demo .lc__ring--3,
.lc.is-scanning .lc__ring--3 {
animation-delay: -1334ms;
}
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot {
animation-name: lc-pulse;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__cut {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lc.is-demo .lc__cut--a {
animation-name: lc-say-a;
}
.lc.is-demo .lc__cut--b {
animation-name: lc-say-b;
}
@keyframes lc-scan {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.18);
}
14% {
opacity: 0.9;
transform: translate(-50%, -50%) scale(0.4);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.04);
}
}
@keyframes lc-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
}
30% {
transform: translate(-50%, -50%) scale(1.45);
}
60% {
transform: translate(-50%, -50%) scale(1);
}
100% {
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes lc-say-a {
0% {
opacity: 1;
}
60% {
opacity: 0;
}
90% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes lc-say-b {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.lc {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring,
.lc.is-demo .lc__dot,
.lc.is-locked .lc__dot,
.lc.is-demo .lc__cut {
animation: none !important;
}
.lc.is-demo .lc__say {
opacity: 1;
}
.lc__ring--1 {
opacity: 0.9;
transform: translate(-50%, -50%) scale(0.55);
}
.lc__btn {
transition: none;
}
}// 실제 동작: 버튼을 누르면(클릭·Enter·Space) 스캔 링이 다시 퍼지고, 1.4초 뒤 잠기며
// aria-live 영역의 문구가 바뀌어 스크린리더에도 알려진다.
var lcRoot = document.querySelector('.lc');
var lcBtn = document.getElementById('lc-btn');
var lcSay = document.getElementById('lc-say');
var lcTimer = 0;
function lcScan() {
lcRoot.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
lcRoot.classList.remove('is-locked');
lcRoot.classList.add('is-scanning');
lcSay.textContent = '現在地を検索しています';
clearTimeout(lcTimer);
lcTimer = setTimeout(function () {
lcRoot.classList.remove('is-scanning');
lcRoot.classList.add('is-locked');
lcSay.textContent = '現在地・誤差12m';
}, 1400);
}
lcBtn.addEventListener('click', lcScan);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LocateCrosshair.module.scss";
export type LocateCrosshairProps = {
buttonLabel?: string;
searchingLabel?: string;
lockedLabel?: string;
/** 스캔이 끝나고 위치가 잠기기까지 걸리는 시간(ms) */
scanMs?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LocateCrosshair({
buttonLabel = "Find my location",
searchingLabel = "Searching for your location",
lockedLabel = "Your location · 12m",
scanMs = 1400,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: LocateCrosshairProps) {
const [scanning, setScanning] = useState(true);
const [locked, setLocked] = useState(false);
const timer = useRef<number>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!scanning) return;
const id = window.setTimeout(() => { setScanning(false); setLocked(true); }, scanMs);
timer.current = id;
return () => window.clearTimeout(id);
}, [scanning, scanMs]);
function scan() {
if (timer.current !== null) window.clearTimeout(timer.current);
setLocked(false);
setScanning(true);
}
const stateClass = `${scanning ? styles.isScanning : ""} ${locked ? styles.isLocked : ""}`;
return (
<div className={`${styles.lc} ${stateClass}`} style={vars}>
<div className={styles.map}>
<span className={`${styles.ring} ${styles.ring1}`} aria-hidden="true" />
<span className={`${styles.ring} ${styles.ring2}`} aria-hidden="true" />
<span className={`${styles.ring} ${styles.ring3}`} aria-hidden="true" />
<span className={styles.cross} aria-hidden="true">
<svg viewBox="0 0 256 256" width="36" height="36">
<g fill="currentColor">
<path d="M160 128a32 32 0 1 1-32-32a32 32 0 0 1 32 32" opacity=".2" />
<path d="M232 120h-8.34A96.14 96.14 0 0 0 136 32.34V24a8 8 0 0 0-16 0v8.34A96.14 96.14 0 0 0 32.34 120H24a8 8 0 0 0 0 16h8.34A96.14 96.14 0 0 0 120 223.66V232a8 8 0 0 0 16 0v-8.34A96.14 96.14 0 0 0 223.66 136H232a8 8 0 0 0 0-16m-96 87.6V200a8 8 0 0 0-16 0v7.6A80.15 80.15 0 0 1 48.4 136H56a8 8 0 0 0 0-16h-7.6A80.15 80.15 0 0 1 120 48.4V56a8 8 0 0 0 16 0v-7.6a80.15 80.15 0 0 1 71.6 71.6H200a8 8 0 0 0 0 16h7.6a80.15 80.15 0 0 1-71.6 71.6M128 88a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24" />
</g>
</svg>
</span>
<span className={styles.dot} aria-hidden="true" />
</div>
<div className={styles.bar}>
{/* 진짜 <button> 이라 클릭과 키보드 Enter·Space 가 모두 들어온다 */}
<button type="button" className={styles.btn} onClick={scan}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2" />
<path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z" />
</g>
</svg>
<span>{buttonLabel}</span>
</button>
<span className={styles.read}>
<span className={styles.say} role="status" aria-live="polite">
{locked ? lockedLabel : searchingLabel}
</span>
</span>
</div>
</div>
);
}06 · 現在地クロスヘッドスキャン — 計測 累積面積 7.419% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
リングは同じキーフレームを共有したまま2つ目と3つ目に負の animation-delay を入れて途中から再生させており、状態の文言は aria-live の領域なので画面が見えない人にも切り替わりが読み上げられます。
.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; }
07描かれていく経路線
何が見えるか
出発のピンから到着の旗まで経路線が筆で引くように伸びていき、引き終わると所要時間と距離のバッジが飛び出します。

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

<main class="stage">
<div class="fp is-demo">
<div class="fp__map">
<span class="fp__block fp__block--a" aria-hidden="true"></span>
<span class="fp__block fp__block--b" aria-hidden="true"></span>
<span class="fp__block fp__block--c" aria-hidden="true"></span>
<button class="fp__pin fp__pin--1" type="button" aria-pressed="false" aria-label="カフェオンドをお気に入りに登録します">
<span class="fp__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</button>
<button class="fp__pin fp__pin--2" type="button" aria-pressed="false" aria-label="ハナ書店をお気に入りに登録します">
<span class="fp__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</button>
<button class="fp__pin fp__pin--3" type="button" aria-pressed="false" aria-label="パークベーカリーをお気に入りに登録します">
<span class="fp__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</button>
<button class="fp__pin fp__pin--4" type="button" aria-pressed="false" aria-label="ソルトハウスをお気に入りに登録します">
<span class="fp__heart" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</button>
</div>
<div class="fp__foot">
<p class="fp__title">マイプレイス</p>
<ul class="fp__chips" aria-live="polite" aria-label="お気に入りの場所">
<li class="fp__chip" aria-hidden="true"><span class="fp__chip-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span><span class="fp__chip-txt">カフェオンド</span></li>
<li class="fp__chip" aria-hidden="true"><span class="fp__chip-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span><span class="fp__chip-txt">ハナ書店</span></li>
<li class="fp__chip" aria-hidden="true"><span class="fp__chip-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span><span class="fp__chip-txt">パークベーカリー</span></li>
<li class="fp__chip" aria-hidden="true"><span class="fp__chip-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span><span class="fp__chip-txt">ソルトハウス</span></li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-orange; // 하트 채움색 — 토큰 주황
@include stage($stage-paper);
// 가짜 지도 캔버스 — 외부 지도 API·이미지 금지. CSS 그라디언트만 (249 아홉 항목 공통 규격)
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.fp {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, .92);
}
// 지도 배경은 움직이지 않는다 — 움직이는 건 하트와 칩뿐
.fp__map {
position: relative;
overflow: hidden;
border-radius: $r-card;
width: 100%;
height: clamp(96px, 30vw, 148px);
box-shadow: $shadow-press;
background:
radial-gradient(circle at 80% 22%, $map-park 0 38px, transparent 38px),
linear-gradient(74deg, transparent 0 22%, $map-water 22% 29%, transparent 29%),
repeating-linear-gradient(90deg, $map-road 0 6px, transparent 6px 28px),
repeating-linear-gradient(0deg, $map-road 0 6px, transparent 6px 25px),
$map-land;
}
.fp__block {
position: absolute;
display: block;
background: $map-block;
border-radius: $r-xs;
}
.fp__block--a { left: 38%; top: 12%; width: 14%; height: 22%; }
.fp__block--b { left: 12%; top: 52%; width: 18%; height: 24%; }
.fp__block--c { left: 60%; top: 62%; width: 16%; height: 22%; }
// ── 하트 핀 ─────────────────────────────────────────────
.fp__pin {
position: absolute;
box-sizing: border-box;
width: 28px;
height: 28px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: #fff;
color: rgba(23, 20, 26, .34);
box-shadow: $shadow-press;
cursor: pointer;
transform: translate(-50%, -50%);
transition: color $duration $easing;
}
.fp__pin--1 { left: 22%; top: 30%; }
.fp__pin--2 { left: 50%; top: 68%; }
.fp__pin--3 { left: 72%; top: 26%; }
.fp__pin--4 { left: 86%; top: 72%; }
.fp__pin:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fp__heart { display: inline-flex; }
.fp__heart svg { display: block; }
// 듀오톤 아이콘의 뒷면을 채워 "빈 하트 → 찬 하트" 로 읽히게 한다
.fp__heart svg g > path:first-child { opacity: .2; transition: opacity $duration $easing; }
.fp__pin[aria-pressed="true"] { color: $color; }
.fp__pin[aria-pressed="true"] .fp__heart svg g > path:first-child { opacity: 1; }
.fp__pin[aria-pressed="true"] .fp__heart {
animation-name: fp-pop;
animation-duration: $dur-quick;
animation-timing-function: $ease-pop;
animation-iteration-count: 1;
}
@keyframes fp-pop {
0% { transform: scale(.7); }
60% { transform: scale(1.26); }
100% { transform: scale(1); }
}
// ── 내 장소 칩 목록 ─────────────────────────────────────
.fp__foot { display: flex; flex-direction: column; gap: $sp-1; }
.fp__title {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 700;
letter-spacing: .04em;
color: rgba($stage-ink, .72);
}
.fp__chips {
display: flex;
gap: 0; // 숨은 칩의 gap 이 남지 않게 — 여백은 칩의 margin-right 로
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
height: 24px;
}
.fp__chip {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-1;
height: 24px;
max-width: 0;
padding: 0;
margin: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(23, 20, 26, .07);
font-size: 11px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
opacity: 0;
transform: scale(.6);
transition: max-width $duration $easing, padding $duration $easing, margin $duration $easing,
opacity $duration $easing, transform $duration $easing;
}
.fp__chip-ico { display: inline-flex; color: $color; }
.fp__chip.is-on {
max-width: 160px;
padding-left: $sp-2;
padding-right: $sp-2;
margin-right: $sp-2;
opacity: 1;
transform: scale(1);
}
// ── 자동 루프: 핀 두 개가 차례로 켜졌다 꺼지고 칩이 쌓였다 빠진다 (0% 와 100% 가 같은 상태) ──
.fp.is-demo .fp__pin--1 {
animation-name: fp-on-a;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--1 .fp__heart svg g > path:first-child {
animation-name: fp-fill-a;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__chip:nth-child(1) {
animation-name: fp-chip-a;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--2 {
animation-name: fp-on-b;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--2 .fp__heart svg g > path:first-child {
animation-name: fp-fill-b;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fp.is-demo .fp__chip:nth-child(2) {
animation-name: fp-chip-b;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fp-on-a {
0% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
10% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
16% { color: $color; transform: translate(-50%, -50%) scale(1.18); }
24% { color: $color; transform: translate(-50%, -50%) scale(1); }
64% { color: $color; transform: translate(-50%, -50%) scale(1); }
76% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
100% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
}
@keyframes fp-fill-a {
0% { opacity: .2; }
10% { opacity: .2; }
16% { opacity: 1; }
64% { opacity: 1; }
76% { opacity: .2; }
100% { opacity: .2; }
}
@keyframes fp-chip-a {
0% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
12% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
24% { max-width: 160px; padding-left: $sp-2; padding-right: $sp-2; margin-right: $sp-2; opacity: 1; transform: scale(1); }
64% { max-width: 160px; padding-left: $sp-2; padding-right: $sp-2; margin-right: $sp-2; opacity: 1; transform: scale(1); }
78% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
100% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
}
@keyframes fp-on-b {
0% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
26% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
32% { color: $color; transform: translate(-50%, -50%) scale(1.18); }
40% { color: $color; transform: translate(-50%, -50%) scale(1); }
70% { color: $color; transform: translate(-50%, -50%) scale(1); }
84% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
100% { color: rgba(23, 20, 26, .34); transform: translate(-50%, -50%) scale(1); }
}
@keyframes fp-fill-b {
0% { opacity: .2; }
26% { opacity: .2; }
32% { opacity: 1; }
70% { opacity: 1; }
84% { opacity: .2; }
100% { opacity: .2; }
}
@keyframes fp-chip-b {
0% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
28% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
40% { max-width: 160px; padding-left: $sp-2; padding-right: $sp-2; margin-right: $sp-2; opacity: 1; transform: scale(1); }
70% { max-width: 160px; padding-left: $sp-2; padding-right: $sp-2; margin-right: $sp-2; opacity: 1; transform: scale(1); }
86% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
100% { max-width: 0; padding-left: 0; padding-right: 0; margin-right: 0; opacity: 0; transform: scale(.6); }
}
// 320×200 — 무대 패딩(25.6px)을 빼면 174px. 지도 96 + 제목 14 + 칩 24 + 간격은 0 으로 내린다
@media (max-width: 340px) {
.fp { gap: $sp-1; }
.fp__foot { gap: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fp.is-demo .fp__pin--1,
.fp.is-demo .fp__pin--2,
.fp.is-demo .fp__pin--1 .fp__heart svg g > path:first-child,
.fp.is-demo .fp__pin--2 .fp__heart svg g > path:first-child,
.fp.is-demo .fp__chip:nth-child(1),
.fp.is-demo .fp__chip:nth-child(2),
.fp__pin[aria-pressed="true"] .fp__heart { animation: none !important; }
.fp__pin, .fp__chip, .fp__heart svg g > path:first-child { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fp {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.92);
}
.fp__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
height: clamp(96px, 30vw, 148px);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 80% 22%, #c9e0bd 0 38px, transparent 38px), linear-gradient(74deg, transparent 0 22%, #bcd9ea 22% 29%, transparent 29%), repeating-linear-gradient(90deg, #fffdf7 0 6px, transparent 6px 28px), repeating-linear-gradient(0deg, #fffdf7 0 6px, transparent 6px 25px), #efe9dc;
}
.fp__block {
position: absolute;
display: block;
background: #e2dac9;
border-radius: 4px;
}
.fp__block--a {
left: 38%;
top: 12%;
width: 14%;
height: 22%;
}
.fp__block--b {
left: 12%;
top: 52%;
width: 18%;
height: 24%;
}
.fp__block--c {
left: 60%;
top: 62%;
width: 16%;
height: 22%;
}
.fp__pin {
position: absolute;
box-sizing: border-box;
width: 28px;
height: 28px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: #fff;
color: rgba(23, 20, 26, 0.34);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: pointer;
transform: translate(-50%, -50%);
transition: color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fp__pin--1 {
left: 22%;
top: 30%;
}
.fp__pin--2 {
left: 50%;
top: 68%;
}
.fp__pin--3 {
left: 72%;
top: 26%;
}
.fp__pin--4 {
left: 86%;
top: 72%;
}
.fp__pin:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.fp__heart {
display: inline-flex;
}
.fp__heart svg {
display: block;
}
.fp__heart svg g > path:first-child {
opacity: 0.2;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fp__pin[aria-pressed=true] {
color: #ff4d1f;
}
.fp__pin[aria-pressed=true] .fp__heart svg g > path:first-child {
opacity: 1;
}
.fp__pin[aria-pressed=true] .fp__heart {
animation-name: fp-pop;
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes fp-pop {
0% {
transform: scale(0.7);
}
60% {
transform: scale(1.26);
}
100% {
transform: scale(1);
}
}
.fp__foot {
display: flex;
flex-direction: column;
gap: 4px;
}
.fp__title {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
}
.fp__chips {
display: flex;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
height: 24px;
}
.fp__chip {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
max-width: 0;
padding: 0;
margin: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(23, 20, 26, 0.07);
font-size: 11px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
opacity: 0;
transform: scale(0.6);
transition: max-width 220ms cubic-bezier(0.2, 1.4, 0.4, 1), padding 220ms cubic-bezier(0.2, 1.4, 0.4, 1), margin 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fp__chip-ico {
display: inline-flex;
color: #ff4d1f;
}
.fp__chip.is-on {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
.fp.is-demo .fp__pin--1 {
animation-name: fp-on-a;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--1 .fp__heart svg g > path:first-child {
animation-name: fp-fill-a;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__chip:nth-child(1) {
animation-name: fp-chip-a;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--2 {
animation-name: fp-on-b;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__pin--2 .fp__heart svg g > path:first-child {
animation-name: fp-fill-b;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fp.is-demo .fp__chip:nth-child(2) {
animation-name: fp-chip-b;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes fp-on-a {
0% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
10% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
16% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1.18);
}
24% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1);
}
64% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1);
}
76% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
100% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes fp-fill-a {
0% {
opacity: 0.2;
}
10% {
opacity: 0.2;
}
16% {
opacity: 1;
}
64% {
opacity: 1;
}
76% {
opacity: 0.2;
}
100% {
opacity: 0.2;
}
}
@keyframes fp-chip-a {
0% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
12% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
24% {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
64% {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
78% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
100% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
}
@keyframes fp-on-b {
0% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
26% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
32% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1.18);
}
40% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1);
}
70% {
color: #ff4d1f;
transform: translate(-50%, -50%) scale(1);
}
84% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
100% {
color: rgba(23, 20, 26, 0.34);
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes fp-fill-b {
0% {
opacity: 0.2;
}
26% {
opacity: 0.2;
}
32% {
opacity: 1;
}
70% {
opacity: 1;
}
84% {
opacity: 0.2;
}
100% {
opacity: 0.2;
}
}
@keyframes fp-chip-b {
0% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
28% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
40% {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
70% {
max-width: 160px;
padding-left: 8px;
padding-right: 8px;
margin-right: 8px;
opacity: 1;
transform: scale(1);
}
86% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
100% {
max-width: 0;
padding-left: 0;
padding-right: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.6);
}
}
@media (max-width: 340px) {
.fp {
gap: 4px;
}
.fp__foot {
gap: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.fp.is-demo .fp__pin--1,
.fp.is-demo .fp__pin--2,
.fp.is-demo .fp__pin--1 .fp__heart svg g > path:first-child,
.fp.is-demo .fp__pin--2 .fp__heart svg g > path:first-child,
.fp.is-demo .fp__chip:nth-child(1),
.fp.is-demo .fp__chip:nth-child(2),
.fp__pin[aria-pressed=true] .fp__heart {
animation: none !important;
}
.fp__pin, .fp__chip, .fp__heart svg g > path:first-child {
transition: none;
}
}// 실제 동작: 핀의 하트를 토글하면(마우스 클릭 · Enter/Space) aria-pressed 가 바뀌고 아래 칩 목록이 실제로 늘고 준다.
var root = document.querySelector('.fp');
var pins = [].slice.call(document.querySelectorAll('.fp__pin'));
var chips = [].slice.call(document.querySelectorAll('.fp__chip'));
function sync() {
pins.forEach(function (p, i) {
var on = p.getAttribute('aria-pressed') === 'true';
chips[i].classList.toggle('is-on', on);
chips[i].setAttribute('aria-hidden', String(!on));
});
}
pins.forEach(function (p) {
p.addEventListener('click', function () {
root.classList.remove('is-demo'); // 사람이 조작하면 자동 루프를 끈다 (237 선례)
p.setAttribute('aria-pressed', p.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
sync();
});
p.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); p.click(); }
});
});
sync();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FavoritePins.module.scss";
export type FavoritePinsProps = {
/** 지도 위 매장 이름 4개 */
places?: string[];
/** 칩 목록 제목 */
title?: string;
/** 하트 버튼의 스크린리더 설명을 만드는 함수 */
pinLabel?: (place: string) => string;
listLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const heart = (
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2" /><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" /></g></svg>
);
const storefront = (
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2" /><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z" /></g></svg>
);
export default function FavoritePins({
places = ["Cafe Ondo", "Hana Books", "Park Bakery", "Salt House"],
title = "My places",
pinLabel = (p: string) => `Save ${p} to favorites`,
listLabel = "Saved places",
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff4d1f",
}: FavoritePinsProps) {
const [demo, setDemo] = useState(true);
const [on, setOn] = useState<boolean[]>([false, false, false, false]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const pinPos = [styles.pin1, styles.pin2, styles.pin3, styles.pin4];
function toggle(i: number) {
setDemo(false); // 사람이 조작하면 자동 루프를 끈다
setOn((prev) => prev.map((v, k) => (k === i ? !v : v)));
}
return (
<div className={`${styles.fp} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.map}>
<span className={`${styles.block} ${styles.blockA}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockB}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockC}`} aria-hidden="true" />
{places.map((p, i) => (
<button
key={p}
type="button"
className={`${styles.pin} ${pinPos[i]}`}
aria-pressed={on[i]}
aria-label={pinLabel(p)}
onClick={() => toggle(i)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(i); }
}}
>
<span className={styles.heart} aria-hidden="true">{heart}</span>
</button>
))}
</div>
<div className={styles.foot}>
<p className={styles.title}>{title}</p>
<ul className={styles.chips} aria-live="polite" aria-label={listLabel}>
{places.map((p, i) => (
<li key={p} className={`${styles.chip} ${on[i] ? styles.isOn : ""}`} aria-hidden={!on[i]}>
<span className={styles.chipIco} aria-hidden="true">{storefront}</span>
<span>{p}</span>
</li>
))}
</ul>
</div>
</div>
);
}08 · お気に入りピンリスト — 計測 累積面積 3.539% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
チップは消しているのではなく max-width と左右の余白を 0 に畳んでいるだけなので、もう一度ハートを点ければ同じ位置に開き直します。
function sync() {
pins.forEach(function (p, i) {
var on = p.getAttribute('aria-pressed') === 'true';
chips[i].classList.toggle('is-on', on);
chips[i].setAttribute('aria-hidden', String(!on));
});
}
pins.forEach(function (p) {
p.addEventListener('click', function () {
root.classList.remove('is-demo');
p.setAttribute('aria-pressed', p.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
sync();
});
});
09押して拡大するミニマップ
どんな動きか
隅のミニマップの中で今見ている範囲の四角が往復し、ミニマップを押すと右下の角を軸にして大きなパネルが伸び上がります。

<main class="stage">
<div class="mm is-demo">
<div class="mm__map">
<span class="mm__block mm__block--a" aria-hidden="true"></span>
<span class="mm__block mm__block--b" aria-hidden="true"></span>
<span class="mm__block mm__block--c" aria-hidden="true"></span>
<span class="mm__you" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2"/><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z"/></g></svg></span>
<div class="mm__panel" id="mm-panel">
<span class="mm__panel-view" aria-hidden="true"></span>
<p class="mm__panel-title">
<span class="mm__panel-ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M96 40v168l-56 24V64Zm120 24l-56-24v168l56 24Z" opacity=".2"/><path d="M228.92 49.69a8 8 0 0 0-6.86-1.45l-61.13 15.28l-61.35-30.68a8 8 0 0 0-5.52-.6l-64 16A8 8 0 0 0 24 56v144a8 8 0 0 0 9.94 7.76l61.13-15.28l61.35 30.68A8 8 0 0 0 160 224a8 8 0 0 0 1.94-.24l64-16A8 8 0 0 0 232 200V56a8 8 0 0 0-3.08-6.31M104 52.94l48 24v126.12l-48-24Zm-64 9.31l48-12v127.5l-48 12Zm176 131.5l-48 12V78.25l48-12Z"/></g></svg></span>
<span class="mm__panel-txt">全体マップ</span>
</p>
</div>
<button class="mm__mini" type="button" id="mm-btn" aria-expanded="false" aria-controls="mm-panel" aria-label="ミニマップを広げます">
<span class="mm__view" aria-hidden="true"></span>
<span class="mm__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 48v160H48V48Z" opacity=".2"/><path d="M216 48v48a8 8 0 0 1-16 0V67.31l-42.34 42.35a8 8 0 0 1-11.32-11.32L188.69 56H160a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8M98.34 146.34L56 188.69V160a8 8 0 0 0-16 0v48a8 8 0 0 0 8 8h48a8 8 0 0 0 0-16H67.31l42.35-42.34a8 8 0 0 0-11.32-11.32M208 152a8 8 0 0 0-8 8v28.69l-42.34-42.35a8 8 0 0 0-11.32 11.32L188.69 200H160a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M67.31 56H96a8 8 0 0 0 0-16H48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31l42.34 42.35a8 8 0 0 0 11.32-11.32Z"/></g></svg></span>
</button>
</div>
<p class="mm__hint">ミニマップを押して広げ、Escで閉じます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 가짜 지도 캔버스 — 외부 지도 API·이미지 금지. CSS 그라디언트만 (249 아홉 항목 공통 규격)
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.mm {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 지도 배경은 움직이지 않는다 — 움직이는 건 영역 사각형과 확대 오버레이뿐
.mm__map {
position: relative;
overflow: hidden; // 오버레이를 지도 상자 안에 가둔다 (밖으로 삐져나오면 scrollHeight 에 잡힌다)
border-radius: $r-card;
width: 100%;
height: clamp(104px, 34vw, 160px);
box-shadow: $shadow-raised;
background:
radial-gradient(circle at 28% 28%, $map-park 0 40px, transparent 40px),
linear-gradient(122deg, transparent 0 56%, $map-water 56% 63%, transparent 63%),
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 31px),
repeating-linear-gradient(0deg, $map-road 0 7px, transparent 7px 27px),
$map-land;
}
.mm__block {
position: absolute;
display: block;
background: $map-block;
border-radius: $r-xs;
}
.mm__block--a { left: 8%; top: 62%; width: 16%; height: 24%; }
.mm__block--b { left: 44%; top: 16%; width: 18%; height: 24%; }
.mm__block--c { left: 62%; top: 56%; width: 14%; height: 20%; }
.mm__you {
position: absolute;
left: 18%;
top: 40%;
display: block;
transform: translate(-50%, -50%);
color: $color;
filter: drop-shadow(0 2px 3px rgba(23, 20, 26, .3));
}
.mm__you svg { display: block; }
// ── 미니 지도 (구석의 작은 버튼) ────────────────────────
.mm__mini {
position: absolute;
right: $sp-2;
bottom: $sp-2;
z-index: 2;
box-sizing: border-box;
width: 30%;
height: 40%;
padding: 0;
border: 2px solid rgba(255, 255, 255, .85);
border-radius: $r-sm;
cursor: pointer;
overflow: hidden;
box-shadow: $shadow-press;
background:
repeating-linear-gradient(90deg, $map-road 0 3px, transparent 3px 12px),
repeating-linear-gradient(0deg, $map-road 0 3px, transparent 3px 11px),
$map-land;
transition: border-color $duration $easing;
}
.mm__mini:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.mm.is-open .mm__mini { border-color: $color; }
.mm__ico {
position: absolute;
right: 2px;
bottom: 2px;
display: inline-flex;
padding: $sp-1;
border-radius: $r-xs;
background: rgba(255, 255, 255, .9);
color: rgba(23, 20, 26, .78);
transition: transform $duration $easing;
}
.mm__ico svg { display: block; }
.mm.is-open .mm__ico { transform: rotate(180deg); }
// "지금 보는 영역" 사각형
.mm__view, .mm__panel-view {
position: absolute;
box-sizing: border-box;
border: 2px solid $color;
border-radius: $r-xs;
background: rgba(47, 109, 246, .16);
}
.mm__view { left: 8%; top: 12%; width: 34%; height: 34%; }
.mm__panel-view { left: 10%; top: 16%; width: 30%; height: 32%; }
// ── 확대 오버레이 — 미니 지도 자리(오른쪽 아래)에서 자란다 ──
.mm__panel {
position: absolute;
right: $sp-2;
bottom: $sp-2;
z-index: 1;
box-sizing: border-box;
width: 66%;
height: 78%;
padding: $sp-2;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background:
radial-gradient(circle at 72% 70%, $map-park 0 26px, transparent 26px),
repeating-linear-gradient(90deg, $map-road 0 5px, transparent 5px 22px),
repeating-linear-gradient(0deg, $map-road 0 5px, transparent 5px 20px),
$map-land;
transform-origin: 100% 100%; // 규칙8: 미니맵 자리 모서리 확대
transform: scale(.3);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.mm.is-open .mm__panel { transform: scale(1); opacity: 1; pointer-events: auto; }
.mm__panel-title {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .92);
color: rgba(23, 20, 26, .9);
font-size: 10px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
}
.mm__panel-ico { display: inline-flex; color: $color; }
.mm__panel-ico svg { display: block; }
.mm__hint {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 600;
letter-spacing: .02em;
color: rgba(255, 255, 255, .58);
}
// ── 자동 루프: 영역 사각형이 왕복하고 미니 지도가 열렸다 닫힌다 (0% 와 100% 가 같은 상태) ──
.mm.is-demo .mm__view, .mm.is-demo .mm__panel-view {
animation-name: mm-pan;
animation-duration: $dur-loop;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes mm-pan {
0% { transform: translate(0, 0); }
50% { transform: translate(108%, 68%); }
100% { transform: translate(0, 0); }
}
.mm.is-demo .mm__panel {
animation-name: mm-open;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes mm-open {
0% { opacity: 0; transform: scale(.3); }
24% { opacity: 0; transform: scale(.3); }
40% { opacity: 1; transform: scale(1); }
66% { opacity: 1; transform: scale(1); }
84% { opacity: 0; transform: scale(.3); }
100% { opacity: 0; transform: scale(.3); }
}
.mm.is-demo .mm__ico {
animation-name: mm-flip;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes mm-flip {
0% { transform: rotate(0deg); }
24% { transform: rotate(0deg); }
40% { transform: rotate(180deg); }
66% { transform: rotate(180deg); }
84% { transform: rotate(0deg); }
100% { transform: rotate(0deg); }
}
// 320×200 — 무대 패딩(25.6px)을 빼면 174px. 지도 108.8 + 힌트 14, 간격은 0 으로 내린다
@media (max-width: 340px) {
.mm { gap: 0; }
.mm__hint { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.mm.is-demo .mm__view,
.mm.is-demo .mm__panel-view,
.mm.is-demo .mm__panel,
.mm.is-demo .mm__ico { animation: none !important; }
.mm__panel, .mm__ico, .mm__mini { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mm {
box-sizing: border-box;
width: min(480px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.mm__map {
position: relative;
overflow: hidden;
border-radius: 16px;
width: 100%;
height: clamp(104px, 34vw, 160px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 28% 28%, #c9e0bd 0 40px, transparent 40px), linear-gradient(122deg, transparent 0 56%, #bcd9ea 56% 63%, transparent 63%), repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 31px), repeating-linear-gradient(0deg, #fffdf7 0 7px, transparent 7px 27px), #efe9dc;
}
.mm__block {
position: absolute;
display: block;
background: #e2dac9;
border-radius: 4px;
}
.mm__block--a {
left: 8%;
top: 62%;
width: 16%;
height: 24%;
}
.mm__block--b {
left: 44%;
top: 16%;
width: 18%;
height: 24%;
}
.mm__block--c {
left: 62%;
top: 56%;
width: 14%;
height: 20%;
}
.mm__you {
position: absolute;
left: 18%;
top: 40%;
display: block;
transform: translate(-50%, -50%);
color: #2f6df6;
filter: drop-shadow(0 2px 3px rgba(23, 20, 26, 0.3));
}
.mm__you svg {
display: block;
}
.mm__mini {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
box-sizing: border-box;
width: 30%;
height: 40%;
padding: 0;
border: 2px solid rgba(255, 255, 255, 0.85);
border-radius: 8px;
cursor: pointer;
overflow: hidden;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: repeating-linear-gradient(90deg, #fffdf7 0 3px, transparent 3px 12px), repeating-linear-gradient(0deg, #fffdf7 0 3px, transparent 3px 11px), #efe9dc;
transition: border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mm__mini:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.mm.is-open .mm__mini {
border-color: #2f6df6;
}
.mm__ico {
position: absolute;
right: 2px;
bottom: 2px;
display: inline-flex;
padding: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.9);
color: rgba(23, 20, 26, 0.78);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mm__ico svg {
display: block;
}
.mm.is-open .mm__ico {
transform: rotate(180deg);
}
.mm__view, .mm__panel-view {
position: absolute;
box-sizing: border-box;
border: 2px solid #2f6df6;
border-radius: 4px;
background: rgba(47, 109, 246, 0.16);
}
.mm__view {
left: 8%;
top: 12%;
width: 34%;
height: 34%;
}
.mm__panel-view {
left: 10%;
top: 16%;
width: 30%;
height: 32%;
}
.mm__panel {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 1;
box-sizing: border-box;
width: 66%;
height: 78%;
padding: 8px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 72% 70%, #c9e0bd 0 26px, transparent 26px), repeating-linear-gradient(90deg, #fffdf7 0 5px, transparent 5px 22px), repeating-linear-gradient(0deg, #fffdf7 0 5px, transparent 5px 20px), #efe9dc;
transform-origin: 100% 100%;
transform: scale(0.3);
opacity: 0;
pointer-events: none;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mm.is-open .mm__panel {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.mm__panel-title {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: rgba(23, 20, 26, 0.9);
font-size: 10px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
}
.mm__panel-ico {
display: inline-flex;
color: #2f6df6;
}
.mm__panel-ico svg {
display: block;
}
.mm__hint {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 600;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.58);
}
.mm.is-demo .mm__view, .mm.is-demo .mm__panel-view {
animation-name: mm-pan;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes mm-pan {
0% {
transform: translate(0, 0);
}
50% {
transform: translate(108%, 68%);
}
100% {
transform: translate(0, 0);
}
}
.mm.is-demo .mm__panel {
animation-name: mm-open;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes mm-open {
0% {
opacity: 0;
transform: scale(0.3);
}
24% {
opacity: 0;
transform: scale(0.3);
}
40% {
opacity: 1;
transform: scale(1);
}
66% {
opacity: 1;
transform: scale(1);
}
84% {
opacity: 0;
transform: scale(0.3);
}
100% {
opacity: 0;
transform: scale(0.3);
}
}
.mm.is-demo .mm__ico {
animation-name: mm-flip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes mm-flip {
0% {
transform: rotate(0deg);
}
24% {
transform: rotate(0deg);
}
40% {
transform: rotate(180deg);
}
66% {
transform: rotate(180deg);
}
84% {
transform: rotate(0deg);
}
100% {
transform: rotate(0deg);
}
}
@media (max-width: 340px) {
.mm {
gap: 0;
}
.mm__hint {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.mm.is-demo .mm__view,
.mm.is-demo .mm__panel-view,
.mm.is-demo .mm__panel,
.mm.is-demo .mm__ico {
animation: none !important;
}
.mm__panel, .mm__ico, .mm__mini {
transition: none;
}
}// 실제 동작: 미니 지도를 누르면(마우스 클릭 · Enter/Space) aria-expanded 가 켜지며 오버레이가 구석에서 자라고, Escape 로 접힌다.
var root = document.querySelector('.mm');
var btn = document.getElementById('mm-btn');
function toggle(on) {
root.classList.remove('is-demo'); // 사람이 조작하면 자동 루프를 끈다 (237 선례)
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', String(on));
}
btn.addEventListener('click', function () { toggle(!root.classList.contains('is-open')); });
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-open')) {
toggle(false);
btn.focus({ preventScroll: true });
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MiniMapExpand.module.scss";
export type MiniMapExpandProps = {
/** 확대 오버레이 제목 */
panelTitle?: string;
/** 미니 지도 버튼의 스크린리더 설명 */
buttonLabel?: string;
/** 지도 아래 안내 문구 */
hint?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
export default function MiniMapExpand({
panelTitle = "Whole map",
buttonLabel = "Expand the mini map",
hint = "Tap the mini map to expand, Esc to collapse",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MiniMapExpandProps) {
const [demo, setDemo] = useState(true);
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(on: boolean) {
setDemo(false); // 사람이 조작하면 자동 루프를 끈다
setOpen(on);
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && open) {
toggle(false);
btnRef.current?.focus({ preventScroll: true });
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
return (
<div className={`${styles.mm} ${demo ? styles.isDemo : ""} ${open ? styles.isOpen : ""}`} style={vars}>
<div className={styles.map}>
<span className={`${styles.block} ${styles.blockA}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockB}`} aria-hidden="true" />
<span className={`${styles.block} ${styles.blockC}`} aria-hidden="true" />
<span className={styles.you} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2" /><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Zm-87.49 38.09a8 8 0 0 0-5.54 5.54l-23 82.16l-.06-.17L56 56l175.82 65.22l.16.06Z" /></g></svg>
</span>
<div className={styles.panel} id="mm-panel">
<span className={styles.panelView} aria-hidden="true" />
<p className={styles.panelTitle}>
<span className={styles.panelIco} aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M96 40v168l-56 24V64Zm120 24l-56-24v168l56 24Z" opacity=".2" /><path d="M228.92 49.69a8 8 0 0 0-6.86-1.45l-61.13 15.28l-61.35-30.68a8 8 0 0 0-5.52-.6l-64 16A8 8 0 0 0 24 56v144a8 8 0 0 0 9.94 7.76l61.13-15.28l61.35 30.68A8 8 0 0 0 160 224a8 8 0 0 0 1.94-.24l64-16A8 8 0 0 0 232 200V56a8 8 0 0 0-3.08-6.31M104 52.94l48 24v126.12l-48-24Zm-64 9.31l48-12v127.5l-48 12Zm176 131.5l-48 12V78.25l48-12Z" /></g></svg>
</span>
<span>{panelTitle}</span>
</p>
</div>
<button
ref={btnRef}
type="button"
className={styles.mini}
aria-expanded={open}
aria-controls="mm-panel"
aria-label={buttonLabel}
onClick={() => toggle(!open)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(!open); }
}}
>
<span className={styles.view} aria-hidden="true" />
<span className={styles.ico} aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 48v160H48V48Z" opacity=".2" /><path d="M216 48v48a8 8 0 0 1-16 0V67.31l-42.34 42.35a8 8 0 0 1-11.32-11.32L188.69 56H160a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8M98.34 146.34L56 188.69V160a8 8 0 0 0-16 0v48a8 8 0 0 0 8 8h48a8 8 0 0 0 0-16H67.31l42.35-42.34a8 8 0 0 0-11.32-11.32M208 152a8 8 0 0 0-8 8v28.69l-42.34-42.35a8 8 0 0 0-11.32 11.32L188.69 200H160a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M67.31 56H96a8 8 0 0 0 0-16H48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31l42.34 42.35a8 8 0 0 0 11.32-11.32Z" /></g></svg>
</span>
</button>
</div>
<p className={styles.hint}>{hint}</p>
</div>
);
}09 · 押して拡大するミニマップ — 計測 累積面積 20.717% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
transform-origin: 100% 100% で拡大の向きを固定しておくと、開いたパネルが地図ボックスの外へはみ出しません。
function toggle(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', String(on));
}
btn.addEventListener('click', function () { toggle(!root.classList.contains('is-open')); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-open')) {
toggle(false);
btn.focus({ preventScroll: true });
}
});
どこで壊れるか — 落とし穴
地図の座標はほぼすべてパーセントです。盤の幅がいくつであっても「横 82%・縦 38% の地点」は同じ建物でなければ困るからです。ところがそのパーセントをそのまま left・top に入れて動かすと、ブラウザはそのたびに位置を計算し直します。05 のピンが候補へ飛ぶときがまさにその動き方です。かといって transform: translate() に置き換えればよい話でもありません。ここでのパーセントは盤ではなく動く要素自身の大きさが基準なので、20px のピンに translate(82%) を与えても 16px しか進まないからです。そこで座標はパーセントのまま持ちつつ、頻繁に動くもの(01 の落下、02 の拡散、03 の拡大、04 の円、09 の展開)は transform と opacity だけで動かし、位置そのものを書き換えるのは人が1回選んだときだけに限りました。
2つ目の落とし穴は大きさです。これらのデモは記事の中に 480×300 の iframe として埋め込まれますが、スマートフォンでは 320×200 まで縮みます。ステージの余白を引くと縦に使えるのは 174px しか残りません。地図の高さは9つのうち5つが clamp(104px, 34vw, 160px)、残りはもっと狭い clamp か aspect-ratio: 12 / 5 で抑えてあり、幅 320px で実測した9つの部品の高さは 127px から 155px の間に収まりました(この実測は部品全体の高さで、地図の板だけなら 96px から 117px です)。ここでリストの行の間隔を 4px 広げるだけで最後の行が切れます。同じ 174px の制約は 04 の半径円にも効いていて、円は地図の高さに合わせた正方形として作ってあるので、地図が縮めば円も一緒に縮み、幅 320px でのはみ出し検査は9つとも 0 件で通りました。もう1つ、狭さが文字を壊した例が 02 です。ピン6個を同じ半径のまま1行の名札で広げると、幅 480px でも名札同士が重なって読めません。扇の角度を 170 度から 10 度までの範囲に広げ、名札を2行に互い違いに置いて、ようやく6つとも読めるようになりました。狭いカードで入力を受ける問題ならログイン画面 デザインが同じ幅で解いていた場合と重なります。圧縮ファイルを開くパスワードは 7ajdcn4a で、いま見ている9つの SCSS と React 版が、直したあとの値のまま入っています。
アクセシビリティ
9つとも、prefers-reduced-motion: reduce の環境では自動再生のループが止まり、動いた結果だけが残ります。ピンは落ちる動きなしに指された場所へ立ち、半径円は拡大の動きなしにその大きさで置かれ、06 のスキャンはリングが広がる動きをやめ、1つ目のリングだけが現在地の点のまわりに残ります。このメディア特性がどの設定に反応するのかは MDN の prefers-reduced-motion の解説に一覧があります。
| 項目 | マウス | キーボード | 状態の通知 |
|---|---|---|---|
| 01 ピンドロップ | 地図をクリック | 矢印キーで移動・Enter でその場に落下 | role="status" で場所の名前だけ(座標は画面のみ) |
| 03 カードとピン | hover | Tab でフォーカス・矢印キーで次の項目 | aria-pressed・role="status" |
| 04 半径 | スライダーをドラッグ | 矢印キー・Home・End | aria-valuetext に距離 |
| 05 住所 | クリック | 上下の矢印・Enter・Escape | aria-activedescendant |
| 06 現在地 | ボタンをクリック | Enter・Space | aria-live="polite" |
| 09 ミニマップ | クリック | Enter・Space・Escape | aria-expanded |
05 が住所を選んだあと、09 が Escape で畳まれたあとにフォーカスを戻すとき preventScroll: true を添えているのは、これらの地図が記事の中に iframe として埋め込まれているからです。オプションなしで呼ぶと、読んでいた画面が地図のほうへスクロールしてしまいます。同じように値を選ばせる部品はコマース・購入カテゴリに、押すと反応する部品はクリックカテゴリに集めてあります。
FAQ
地図 ui で座標を px ではなくパーセントで持つと何が変わりますか
盤の幅が変わっても同じ場所を指し続けます。9つとも地図を position: relative の箱として扱い、ピンはその箱に対する割合で置いてあるので、480px でも 320px でもピンは同じ建物の上に立ちます。px で持つと幅が変わるたびに全部の座標を掛け直すことになり、その計算を JavaScript でやると縮めるたびにピンが一瞬ずれて見えます。選んだ場所を外のカードへ出す必要があるときはシェアボタン デザイン 埋め込みウィジェットが収めます。
自動再生はいつ止まりますか
人が最初に触った瞬間です。9つとも is-demo というクラスが付いている間だけ自動のループが回り、クリック・キー入力・スライダーの操作が入った時点でそのクラスを外します。一度外れたら読み込み直すまで戻らないので、自分で選んだピンの位置がループに上書きされることはありません。
React 版と vanilla 版で作りが違うところはありますか
アニメーションをもう一度走らせる方法だけが違います。vanilla はクラスを外して void el.offsetWidth でリフローを一度強制し、同じキーフレームを頭から再生させますが、React 版は落とした回数を状態として持ち、その数値を key に渡して要素を作り直すことで同じ結果を出しています。duration・easing・color は9つとも props で受け取り --duration・--easing・--color として CSS へ渡すので、値を変えるときに触る場所はどちらも1か所です。









