08즐겨찾기 핀 목록
핀의 하트를 누르면 하트가 채워지며 통통 튀고, 지도 아래 목록에 그 매장이 칩으로 실제로 쌓이거나 빠집니다.
쓰임자주 쓰는 위치를 골라 두는 마이 플레이스 목록
aria-pressedscale bouncechip stack

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


