02Count-badge cluster burst
A count badge sits where pins overlap; pressing it fans the real pins out with their labels, and pulling back merges them into the badge again.
Best forWhen store pins pile up in one neighborhood and smear into a blob
scaletransform-originaria-expanded

<main class="stage">
<div class="cb is-demo">
<div class="cb__map">
<ul class="cb__group" id="cb-group" aria-hidden="true">
<li class="cb__pin" style="--kx:-.985;--ky:-.174;--d:0ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">Hangang</span>
</li>
<li class="cb__pin cb--low" style="--kx:-.695;--ky:-.719;--d:30ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">Seoul Forest</span>
</li>
<li class="cb__pin" style="--kx:-.174;--ky:-.985;--d:60ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">Seongsu</span>
</li>
<li class="cb__pin cb--low" style="--kx:.174;--ky:-.985;--d:90ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">Yeonmujang</span>
</li>
<li class="cb__pin" style="--kx:.695;--ky:-.719;--d:120ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">Ttukseom</span>
</li>
<li class="cb__pin cb--low" style="--kx:.985;--ky:-.174;--d:150ms">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
<span class="cb__tag">Wangsimni</span>
</li>
</ul>
<button class="cb__badge" type="button" id="cb-badge" aria-expanded="false" aria-controls="cb-group" aria-label="Expand 6 stores">6</button>
</div>
<div class="cb__bar">
<span class="cb__icon" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2"/><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z"/></g></svg></span>
<span class="cb__out" id="cb-out" role="status" aria-live="polite">6 stores are merged into one spot</span>
<button class="cb__toggle" type="button" id="cb-toggle" aria-expanded="false" aria-controls="cb-group">Expand</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-yellow);
// 가짜 지도 — 외부 타일·이미지·폰트 0. 그라디언트만 겹친다. 이 색들은 토큰에 없는 지도 전용색이다
$map-land: #efe9dc;
$map-road: #fffdf7;
$map-block: #e2dac9;
$map-park: #c9e0bd;
$map-water: #bcd9ea;
.cb {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex; flex-direction: column; gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
// 부채꼴 반지름 — 좁은 화면에서 줄여야 핀이 지도 밖으로 안 나간다
--rx: 92px;
--ry: 40px;
}
// ── 지도판: 공원 원 → 강 띠 → 세로 도로 → 가로 도로 → 땅. 배경은 고정, 움직이는 건 핀뿐 ──
.cb__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
border-radius: $r-card;
box-shadow: $shadow-press;
background:
radial-gradient(circle at 16% 78%, $map-park 0 34px, transparent 34px),
linear-gradient(100deg, transparent 0 74%, $map-water 74% 82%, transparent 82%),
repeating-linear-gradient(90deg, $map-road 0 7px, transparent 7px 31px),
repeating-linear-gradient(0deg, $map-road 0 7px, transparent 7px 25px),
$map-land;
}
.cb__map::before, .cb__map::after {
content: "";
position: absolute;
border-radius: $r-xs;
background: $map-block;
pointer-events: none;
}
.cb__map::before { left: 8%; top: 12%; width: 70px; height: 24px; }
.cb__map::after { right: 9%; bottom: 10%; width: 58px; height: 30px; }
.cb__group { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
.cb__pin {
position: absolute;
left: 50%; top: 50%;
width: 20px; height: 20px;
color: $color;
transform-origin: 50% 100%; // 규칙8: 핀 꼭짓점 고정 버스트
// 합쳐진 상태 — 배지 뒤에 접혀 있다
transform: translate(-50%, -100%) translate(0, 0) scale(.3);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
transition-delay: var(--d);
}
.cb__pin svg { display: block; }
.cb.is-open .cb__pin {
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
opacity: 1;
}
.cb__tag {
position: absolute;
left: 50%; top: 100%;
transform: translateX(-50%);
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(255, 255, 255, .94);
color: $subject-ink;
font-size: 8px; line-height: 13px; font-weight: 700;
white-space: nowrap;
}
// 이름표를 두 줄로 엇갈리게 — 한 줄에 여섯 개를 늘어놓으면 서로 겹쳐 못 읽는다
.cb__pin.cb--low .cb__tag { top: calc(100% + 13px); }
.cb__badge {
position: absolute;
left: 50%; top: 50%;
box-sizing: border-box;
width: 38px; height: 38px;
margin: 0; padding: 0;
border: 2px solid rgba(255, 255, 255, .9);
border-radius: 50%;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-size: 15px; line-height: 34px; font-weight: 800;
cursor: pointer;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
box-shadow: $shadow-press;
transition: transform $duration $easing, opacity $duration $easing;
}
.cb__badge:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.cb.is-open .cb__badge { transform: translate(-50%, -50%) scale(.42); opacity: 0; }
.cb__bar {
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-1 $sp-1 $sp-3;
border: 1px solid rgba($subject-ink, .14);
border-radius: $r-control;
background: rgba(255, 255, 255, .55);
}
.cb__icon { display: inline-flex; color: $color; }
.cb__out {
flex: 1 1 auto; min-width: 0;
font-size: 11px; line-height: 15px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cb__toggle {
box-sizing: border-box;
height: 24px;
margin: 0; padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 11px; font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.cb__toggle:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 자동 루프: 확산 → 유지 → 수축. 0% 와 100% 가 같은 합쳐진 상태다 (복귀는 86% 에 끝난다) ──
.cb.is-demo .cb__pin {
animation-name: cb-burst;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: var(--d);
}
.cb.is-demo .cb__badge {
animation-name: cb-merge;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cb-burst {
0% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
10% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
32% { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
68% { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
86%, 100% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
}
@keyframes cb-merge {
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
10% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
32% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
68% { opacity: 0; transform: translate(-50%, -50%) scale(.42); }
86%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
// 좁은 화면 — 지도가 낮아진 만큼 부채꼴을 좁힌다. 간격은 올리지 않는다 (BD9)
@media (max-width: 400px) {
.cb { --rx: 64px; --ry: 24px; }
.cb__tag { font-size: 7px; line-height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__pin, .cb.is-demo .cb__badge { animation: none !important; }
.cb__pin, .cb__badge { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
box-sizing: border-box;
width: min(408px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
--rx: 92px;
--ry: 40px;
}
.cb__map {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: clamp(104px, 34vw, 160px);
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: radial-gradient(circle at 16% 78%, #c9e0bd 0 34px, transparent 34px), linear-gradient(100deg, transparent 0 74%, #bcd9ea 74% 82%, transparent 82%), repeating-linear-gradient(90deg, #fffdf7 0 7px, transparent 7px 31px), repeating-linear-gradient(0deg, #fffdf7 0 7px, transparent 7px 25px), #efe9dc;
}
.cb__map::before, .cb__map::after {
content: "";
position: absolute;
border-radius: 4px;
background: #e2dac9;
pointer-events: none;
}
.cb__map::before {
left: 8%;
top: 12%;
width: 70px;
height: 24px;
}
.cb__map::after {
right: 9%;
bottom: 10%;
width: 58px;
height: 30px;
}
.cb__group {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
}
.cb__pin {
position: absolute;
left: 50%;
top: 50%;
width: 20px;
height: 20px;
color: #2f6df6;
transform-origin: 50% 100%;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
opacity: 0;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
transition-delay: var(--d);
}
.cb__pin svg {
display: block;
}
.cb.is-open .cb__pin {
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
opacity: 1;
}
.cb__tag {
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
padding: 0 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
color: #17141a;
font-size: 8px;
line-height: 13px;
font-weight: 700;
white-space: nowrap;
}
.cb__pin.cb--low .cb__tag {
top: calc(100% + 13px);
}
.cb__badge {
position: absolute;
left: 50%;
top: 50%;
box-sizing: border-box;
width: 38px;
height: 38px;
margin: 0;
padding: 0;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: 15px;
line-height: 34px;
font-weight: 800;
cursor: pointer;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cb__badge:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cb.is-open .cb__badge {
transform: translate(-50%, -50%) scale(0.42);
opacity: 0;
}
.cb__bar {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 4px 4px 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.55);
}
.cb__icon {
display: inline-flex;
color: #2f6df6;
}
.cb__out {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 15px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cb__toggle {
box-sizing: border-box;
height: 24px;
margin: 0;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.cb__toggle:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cb.is-demo .cb__pin {
animation-name: cb-burst;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: var(--d);
}
.cb.is-demo .cb__badge {
animation-name: cb-merge;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cb-burst {
0% {
opacity: 0;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
}
10% {
opacity: 0;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
}
32% {
opacity: 1;
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
}
68% {
opacity: 1;
transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1);
}
86%, 100% {
opacity: 0;
transform: translate(-50%, -100%) translate(0, 0) scale(0.3);
}
}
@keyframes cb-merge {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
10% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
32% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
68% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.42);
}
86%, 100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@media (max-width: 400px) {
.cb {
--rx: 64px;
--ry: 24px;
}
.cb__tag {
font-size: 7px;
line-height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__pin, .cb.is-demo .cb__badge {
animation: none !important;
}
.cb__pin, .cb__badge {
transition: none;
}
}// 실제 동작: 배지나 버튼을 누르면 핀이 부채꼴로 흩어지고 다시 누르면 배지 하나로 합쳐진다. Escape 로 접는다.
var root = document.querySelector('.cb');
var badge = document.getElementById('cb-badge');
var toggle = document.getElementById('cb-toggle');
var group = document.getElementById('cb-group');
var out = document.getElementById('cb-out');
var ON = 'Collapse', OFF = 'Expand';
var ONMSG = '6 stores are fanned out', OFFMSG = '6 stores are merged into one spot';
function set(on) {
root.classList.remove('is-demo'); // 사람이 만졌으니 자동 루프를 멈춘다
root.classList.toggle('is-open', on);
badge.setAttribute('aria-expanded', String(on));
toggle.setAttribute('aria-expanded', String(on));
group.setAttribute('aria-hidden', String(!on));
toggle.textContent = on ? ON : OFF;
out.textContent = on ? ONMSG : OFFMSG;
}
function flip() { set(!root.classList.contains('is-open')); }
badge.addEventListener('click', flip);
toggle.addEventListener('click', flip);
root.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { set(false); badge.focus({ preventScroll: true }); }
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ClusterBurst.module.scss";
export type ClusterBurstProps = {
stores?: string[];
labels?: { expand: string; collapse: string; merged: string; spread: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PinVars = CSSProperties & { "--kx"?: string; "--ky"?: string; "--d"?: string };
// React 이벤트 타입은 'react' 에서 import 하지 않는다 — 포트 안에서 필요한 모양만 선언한다
type KeyEvt = { key: string };
export default function ClusterBurst({
stores = ["Hangang", "Seoul Forest", "Seongsu", "Yeonmujang", "Ttukseom", "Wangsimni"],
labels = { expand: "Expand", collapse: "Collapse", merged: "stores are merged into one spot", spread: "stores are fanned out" },
duration = 300,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ClusterBurstProps) {
const [open, setOpen] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function set(on: boolean) {
setDemo(false); // 사람이 만졌으니 자동 루프를 멈춘다
setOpen(on);
}
// 부채꼴: 170° 에서 10° 까지 고르게. 이름표는 두 줄로 엇갈려야 겹치지 않는다
const fan = stores.map((name, i) => {
const a = ((170 - (i * 160) / Math.max(stores.length - 1, 1)) * Math.PI) / 180;
return { name, kx: Math.cos(a), ky: -Math.sin(a), low: i % 2 === 1 };
});
return (
<div className={`${styles.cb} ${open ? styles.isOpen : ""} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.map}>
<ul className={styles.group} id="cb-group" aria-hidden={!open}>
{fan.map((p, i) => {
const pv: PinVars = { "--kx": p.kx.toFixed(3), "--ky": p.ky.toFixed(3), "--d": `${i * 30}ms` };
return (
<li key={p.name} className={`${styles.pin} ${p.low ? styles.low : ""}`} style={pv}>
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2" /><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" /></g></svg>
<span className={styles.tag}>{p.name}</span>
</li>
);
})}
</ul>
<button
type="button"
className={styles.badge}
aria-expanded={open}
aria-controls="cb-group"
aria-label={`${labels.expand} ${stores.length}`}
onClick={() => set(!open)}
>
{stores.length}
</button>
</div>
<div className={styles.bar}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M224 96v16a32 32 0 0 1-64 0V96H96v16a32 32 0 0 1-64 0V96l14.34-50.2A8 8 0 0 1 54 40h148a8 8 0 0 1 7.69 5.8Z" opacity=".2" /><path d="M231.69 93.81L217.35 43.6A16.07 16.07 0 0 0 202 32H54a16.07 16.07 0 0 0-15.35 11.6L24.31 93.81A8 8 0 0 0 24 96v16a40 40 0 0 0 16 32v72a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8v-72a40 40 0 0 0 16-32V96a8 8 0 0 0-.31-2.19M54 48h148l11.42 40H42.61Zm98 56v8a24 24 0 0 1-48 0v-8ZM51.06 132.2A24 24 0 0 1 40 112v-8h48v8a24 24 0 0 1-35.12 21.26a8 8 0 0 0-1.82-1.06M200 208H56v-56.8a40.6 40.6 0 0 0 8 .8a40 40 0 0 0 32-16a40 40 0 0 0 64 0a40 40 0 0 0 32 16a40.6 40.6 0 0 0 8-.8Zm16-96a24 24 0 0 1-11.07 20.2a8 8 0 0 0-1.8 1.05A24 24 0 0 1 168 112v-8h48Z" /></g></svg>
</span>
<span className={styles.out} role="status" aria-live="polite">
{stores.length} {open ? labels.spread : labels.merged}
</span>
<button
type="button"
className={styles.toggle}
aria-expanded={open}
aria-controls="cb-group"
onClick={() => set(!open)}
onKeyDown={(e: KeyEvt) => { if (e.key === "Escape") set(false); }}
>
{open ? labels.collapse : labels.expand}
</button>
</div>
</div>
);
}02 Count-badge cluster burst — Measured area 5.049% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · 9 Map UI Design Patterns — Click-to-Drop Pickers
The post this demo lives in
9 Map UI Design Patterns — Click-to-Drop PickersMap UI design is the layer that turns "where should this go?" into one point a person can hand back to you, and these ni See all demos in this post


