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 원작 · zip 에 SCSS·React 포함 · 지도 ui 디자인 9종 — 누르면 핀이 떨어지는 위치 선택 화면
이 데모가 속한 글
지도 ui 디자인 9종 — 누르면 핀이 떨어지는 위치 선택 화면지도 ui 디자인은 배달·예약 화면에서 "어디로 보낼까"를 지도 위에서 고르게 만드는 화면 설계입니다. 아홉 개 전부 지도 API 없이 CSS 격자로 그린 가짜 지도 위에서 돌아가고, 첫 번째는 누르면 핀이 떨어지는 이 글의 데모 전부 보기

