03結果カードとピンの双方向ハイライト
リストのカードに乗せるかフォーカスすると地図の同じ店舗ピンが大きくなってリングが広がり、逆にピンに乗せるとカードの枠が点灯します。
使いどころ検索結果がリストと地図に同時に表示される店舗検索画面
mouseenterdata-idbox-shadow

<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 に同梱 · 地図 ui デザイン 9選 — クリックでピンが落ちる位置選択
このデモが属する記事
地図 ui デザイン 9選 — クリックでピンが落ちる位置選択地図 ui デザインとは、配達先や待ち合わせ場所を「住所を書かせる」のではなく地図の上で指して決めてもらう画面の作りです。9つとも地図 API を使わず、CSS で描いた盤の上だけで動きます。 この記事のデモをすべて見る

