Status Badge Design UI — 9 CSS Ideas to Copy
This status badge design ui roundup turns a small detail — one dot, one label — into instant feedback: online or away, urgent or not, verified or plain.
01 status-pill-online-offline-away-busy
02 tag-chip-remove-button
03 priority-label-badge-levels
04 corner-ribbon-fold-unfurl
05 notification-dot-pulse-ring
06 tech-stack-tag-cloud
07 achievement-badge-unlock
08 premium-verified-badge-flip
09 countdown-expiry-chip-flip
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Status pill indicator (online/away/busy/offline)
- 02 Removable tag chip
- 03 Priority label badge (low/medium/high/urgent)
- 04 Corner ribbon fold-unfurl
- 05 Notification dot double-pulse
- 06 Tech-stack tag cloud
- 07 Achievement badge unlock animation
- 08 Premium / verified badge flip
- 09 Countdown expiry chip
These nine follow the order in which a real product shows them, not popularity. You log in and see a teammate's online status (01), pick and remove interest tags (02), and set a task's priority (03). Browsing turns up a sale ribbon (04) and a new notification dot (05) that stays put instead of sliding away like a css toast notification; your profile collects a tech-stack cloud (06), a freshly unlocked achievement (07), and a verified badge (08); and a coupon's countdown (09) closes things out. What happens after a badge lands, judging the input itself, is Form Validation CSS Animations, and teaching a first-time visitor what that badge means is collected in Onboarding UI Design.
01Status pill indicator (online/away/busy/offline)
What you see in this css badge design
A ring hidden behind the online dot expands once via opacity and scale, while the other three states sit still, differing only in color. A second ::after ripple follows the dot ring a third of a cycle later and briefly tints the pill the same green, announcing the state change in two layers.

<main class="stage is-demo">
<ul class="pills" aria-label="Status list">
<li class="pill pill--online"><span class="dot" aria-hidden="true"></span>Online</li>
<li class="pill pill--away"><span class="dot" aria-hidden="true"></span>Away</li>
<li class="pill pill--busy"><span class="dot" aria-hidden="true"></span>Busy</li>
<li class="pill pill--offline"><span class="dot" aria-hidden="true"></span>Offline</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 온라인 링 한 번 퍼짐
$easing: $ease-out;
$color: #50a26e; // 온라인 그린
@include stage($stage-paper);
.pills {
display: flex; flex-direction: column; gap: $sp-2;
margin: 0; padding: 0; list-style: none;
}
.pill {
display: inline-flex; align-items: center; gap: $sp-2;
padding: $sp-2 $sp-4;
border-radius: $r-pill;
background: #fff;
box-shadow: $shadow-press;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
color: $subject-ink;
}
.dot {
position: relative;
width: 8px; height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
// 온라인 — 링만 애니메이션, 점 자체는 고정
.pill--online .dot {
background: $color;
}
.pill--online .dot::before {
content: "";
position: absolute; inset: -4px;
border-radius: 50%;
border: 2px solid $color;
opacity: 0;
transform: scale(.6);
}
.stage.is-demo .pill--online .dot::before {
animation: ping $duration $easing infinite;
}
// 나머지 셋은 정지 상태 — 대조군
.pill--away .dot { background: #b9a871; }
.pill--busy .dot { background: #be7c7c; }
.pill--offline .dot { background: #9ca3af; }
@keyframes ping {
0% { opacity: .55; transform: scale(.6); }
70%, 100% { opacity: 0; transform: scale(2.1); }
}
// ── 한 겹: 상태 전이 — pill 배경 그린 틴트 전이 + 두 번째 파문 링 (기존 ping 위에) ──
.pill--online { position: relative; }
.stage.is-demo .pill--online {
animation: pill-tint $duration $ease-out infinite;
}
.pill--online::after {
content: "";
position: absolute; inset: -8px; // dot::before(-4px)보다 큰 두 번째 링
border-radius: $r-pill;
border: 2px solid $color;
opacity: 0;
pointer-events: none;
}
.stage.is-demo .pill--online::after {
animation: pill-ripple $duration $easing infinite;
animation-delay: calc($duration / 3); // 첫 링을 뒤따라 확산
}
@keyframes pill-tint {
0%, 35% { background-color: #fff; }
55% { background-color: mix(#fff, $color, 88%); }
80%, 100% { background-color: #fff; }
}
@keyframes pill-ripple {
0% { opacity: .38; transform: scale(.95); }
70%, 100% { opacity: 0; transform: scale(1.16); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pill--online .dot::before { animation: none; opacity: 0; }
.stage.is-demo .pill--online { animation: none; }
.stage.is-demo .pill--online::after { animation: none; opacity: 0; }
}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);
}
.pills {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 999px;
background: #fff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
color: #17141a;
}
.dot {
position: relative;
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.pill--online .dot {
background: #50a26e;
}
.pill--online .dot::before {
content: "";
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid #50a26e;
opacity: 0;
transform: scale(0.6);
}
.stage.is-demo .pill--online .dot::before {
animation: ping 2s ease-out infinite;
}
.pill--away .dot {
background: #b9a871;
}
.pill--busy .dot {
background: #be7c7c;
}
.pill--offline .dot {
background: #9ca3af;
}
@keyframes ping {
0% {
opacity: 0.55;
transform: scale(0.6);
}
70%, 100% {
opacity: 0;
transform: scale(2.1);
}
}
.pill--online {
position: relative;
}
.stage.is-demo .pill--online {
animation: pill-tint 2s ease-out infinite;
}
.pill--online::after {
content: "";
position: absolute;
inset: -8px;
border-radius: 999px;
border: 2px solid #50a26e;
opacity: 0;
pointer-events: none;
}
.stage.is-demo .pill--online::after {
animation: pill-ripple 2s ease-out infinite;
animation-delay: 0.6666666667s;
}
@keyframes pill-tint {
0%, 35% {
background-color: #fff;
}
55% {
background-color: rgb(91.7647058824%, 95.6235294118%, 93.1764705882%);
}
80%, 100% {
background-color: #fff;
}
}
@keyframes pill-ripple {
0% {
opacity: 0.38;
transform: scale(0.95);
}
70%, 100% {
opacity: 0;
transform: scale(1.16);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pill--online .dot::before {
animation: none;
opacity: 0;
}
.stage.is-demo .pill--online {
animation: none;
}
.stage.is-demo .pill--online::after {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./StatusPillIndicator.module.scss";
export type StatusPillIndicatorProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function StatusPillIndicator({
duration = 2000,
easing = "ease-out",
color = "#16a34a",
}: StatusPillIndicatorProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<ul className={styles.pills} style={vars} aria-label="status list">
<li className={`${styles.pill} ${styles.online}`}><span className={styles.dot} />Online</li>
<li className={`${styles.pill} ${styles.away}`}><span className={styles.dot} />Away</li>
<li className={`${styles.pill} ${styles.busy}`}><span className={styles.dot} />Busy</li>
<li className={`${styles.pill} ${styles.offline}`}><span className={styles.dot} />Offline</li>
</ul>
);
}01 · Status pill indicator (online/away/busy/offline) — Measured area 3.128% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
.pill--online .dot::before {
content: "";
position: absolute; inset: -4px;
border-radius: 50%;
border: 2px solid $color;
opacity: 0;
transform: scale(.6);
}
.stage.is-demo .pill--online .dot::before {
animation: ping $duration $easing infinite;
}
@keyframes ping {
0% { opacity: .55; transform: scale(.6); }
70%, 100% { opacity: 0; transform: scale(2.1); }
}
02Removable tag chip
What happens on screen
Clicking the × actually shrinks and removes a chip via scale and opacity, and the demo loops that same motion on its own. As the chip shrinks, an ::after border ring spreads outward from the same spot, leaving an afterglow-like ripple where the chip stood.

<main class="stage is-demo">
<ul class="chips" aria-label="Selected categories">
<li class="chip" data-chip>
<span>Design</span>
<button class="chip__remove" type="button" aria-label="Remove Design tag">
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true"><g fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity=".2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></g></svg>
</button>
</li>
<li class="chip" data-chip>
<span>Marketing</span>
<button class="chip__remove" type="button" aria-label="Remove Marketing tag">
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true"><g fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity=".2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></g></svg>
</button>
</li>
<li class="chip chip--demo" data-chip>
<span>Remote</span>
<button class="chip__remove" type="button" aria-label="Remove Remote tag">
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true"><g fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity=".2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></g></svg>
</button>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 제거 루프 한 주기
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-yellow);
.chips {
display: flex; flex-wrap: wrap; gap: $sp-2;
margin: 0; padding: 0; list-style: none;
max-width: 260px;
}
.chip {
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-1 $sp-1 $sp-1 $sp-3;
border-radius: $r-pill;
background: #fff;
box-shadow: $shadow-press;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
color: $subject-ink;
transform-origin: center;
}
.chip:nth-child(2) { background: mix(#fff, $subject-mint, 55%); }
.chip:nth-child(3) { background: mix(#fff, $subject-lilac, 55%); }
.chip__remove {
display: grid; place-items: center;
width: 20px; height: 20px;
border: 0; border-radius: 50%;
background: rgba($subject-ink, .08);
color: $subject-ink;
padding: 0;
cursor: pointer;
}
.chip__remove:hover { background: rgba($subject-ink, .16); }
// 진짜 클릭 제거 — transform·opacity 만
.chip--gone {
transition: transform $dur-quick $easing, opacity $dur-quick $easing;
transform: scale(.6);
opacity: 0;
}
// 데모 루프 — 세 번째 칩이 지워졌다 다시 나타나길 반복한다 (자동 재생용)
.stage.is-demo .chip--demo {
animation: chip-cycle $duration $easing infinite;
}
@keyframes chip-cycle {
0%, 38% { transform: scale(1); opacity: 1; }
55% { transform: scale(.55); opacity: 0; }
78% { transform: scale(.55); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
// ── 한 겹: 제거 순간 테두리 링 확산 (chip-cycle 55% 소멸 지점에 동기화) ──
.chip--demo { position: relative; }
.chip--demo::after {
content: "";
position: absolute; inset: 0;
border-radius: $r-pill;
border: 2px solid $color;
opacity: 0;
pointer-events: none;
}
.stage.is-demo .chip--demo::after {
animation: chip-ripple $duration $easing infinite;
}
@keyframes chip-ripple {
0%, 38% { opacity: 0; transform: scale(1); }
44% { opacity: .55; transform: scale(1.35); } // 칩이 줄며 사라질 때 링은 바깥으로
56%, 100% { opacity: 0; transform: scale(1.9); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .chip--demo { animation: none; }
.stage.is-demo .chip--demo::after { animation: none; opacity: 0; }
.chip--gone { 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);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
max-width: 260px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 4px 4px 12px;
border-radius: 999px;
background: #fff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 13px;
color: #17141a;
transform-origin: center;
}
.chip:nth-child(2) {
background: rgb(68.4117647059%, 92.4117647059%, 84.2941176471%);
}
.chip:nth-child(3) {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.chip__remove {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
padding: 0;
cursor: pointer;
}
.chip__remove:hover {
background: rgba(23, 20, 26, 0.16);
}
.chip--gone {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
transform: scale(0.6);
opacity: 0;
}
.stage.is-demo .chip--demo {
animation: chip-cycle 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes chip-cycle {
0%, 38% {
transform: scale(1);
opacity: 1;
}
55% {
transform: scale(0.55);
opacity: 0;
}
78% {
transform: scale(0.55);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.chip--demo {
position: relative;
}
.chip--demo::after {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
border: 2px solid #ff9ebb;
opacity: 0;
pointer-events: none;
}
.stage.is-demo .chip--demo::after {
animation: chip-ripple 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes chip-ripple {
0%, 38% {
opacity: 0;
transform: scale(1);
}
44% {
opacity: 0.55;
transform: scale(1.35);
}
56%, 100% {
opacity: 0;
transform: scale(1.9);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .chip--demo {
animation: none;
}
.stage.is-demo .chip--demo::after {
animation: none;
opacity: 0;
}
.chip--gone {
transition: none;
}
}// 실제 클릭 제거 — 데모 밖에서도 그대로 동작한다
document.querySelectorAll('.chip__remove').forEach(function (btn) {
btn.addEventListener('click', function () {
var chip = btn.closest('.chip');
if (!chip || chip.classList.contains('chip--gone')) return;
chip.classList.remove('chip--demo');
chip.classList.add('chip--gone');
chip.addEventListener('transitionend', function () { chip.remove(); }, { once: true });
});
});import { useState, type CSSProperties } from "react";
import styles from "./TagChipRemove.module.scss";
export type TagChipRemoveProps = {
duration?: number;
easing?: string;
color?: string;
};
const INITIAL = ["Design", "Marketing", "Remote"];
export default function TagChipRemove({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff9ebb",
}: TagChipRemoveProps) {
const [tags, setTags] = useState(INITIAL);
const [leaving, setLeaving] = useState<string | null>(null);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
function remove(tag: string) {
setLeaving(tag);
window.setTimeout(() => {
setTags((t) => t.filter((x) => x !== tag));
setLeaving(null);
}, 220);
}
return (
<ul className={styles.chips} style={vars} aria-label="selected categories">
{tags.map((tag, i) => (
<li
key={tag}
className={[styles.chip, i === 2 ? styles.demo : "", leaving === tag ? styles.gone : ""].join(" ")}
>
<span>{tag}</span>
<button
type="button"
className={styles.remove}
aria-label={`remove ${tag}`}
onClick={() => remove(tag)}
>
<svg viewBox="0 0 256 256" width={12} height={12} aria-hidden="true">
<g fill="currentColor">
<path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity=".2" />
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
</g>
</svg>
</button>
</li>
))}
</ul>
);
}02 · Removable tag chip — Measured area 2.362% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.chip--gone {
transition: transform $dur-quick $easing, opacity $dur-quick $easing;
transform: scale(.6);
opacity: 0;
}
.stage.is-demo .chip--demo {
animation: chip-cycle $duration $easing infinite;
}
@keyframes chip-cycle {
0%, 38% { transform: scale(1); opacity: 1; }
55%, 78% { transform: scale(.55); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
03Priority label badge (low/medium/high/urgent)
What moves
Only the urgent label flashes via opacity and scale, so it's the first thing you notice among labels that differ only in color. At the deepest flash, an ::after red ring ripples outward past the label edge and the text lightens for a beat, stacking color on top of motion to insist the warning repeats.

<main class="stage is-demo">
<ul class="labels" aria-label="Priority list">
<li class="label label--low">Low</li>
<li class="label label--medium">Medium</li>
<li class="label label--high">High</li>
<li class="label label--urgent">
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true"><g fill="currentColor"><path d="M224,56V176c-64,55.43-112-55.43-176,0V56C112,.57,160,111.43,224,56Z" opacity=".2"/><path d="M42.76,50A8,8,0,0,0,40,56V224a8,8,0,0,0,16,0V179.77c26.79-21.16,49.87-9.75,76.45,3.41,16.4,8.11,34.06,16.85,53,16.85,13.93,0,28.54-4.75,43.82-18a8,8,0,0,0,2.76-6V56A8,8,0,0,0,218.76,50c-28,24.23-51.72,12.49-79.21-1.12C111.07,34.76,78.78,18.79,42.76,50ZM216,172.25c-26.79,21.16-49.87,9.74-76.45-3.41-25-12.35-52.81-26.13-83.55-8.4V59.79c26.79-21.16,49.87-9.75,76.45,3.4,25,12.35,52.82,26.13,83.55,8.4Z"/></g></svg>
Urgent
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 긴급 라벨 깜빡임 한 주기
$easing: $ease-out;
$color: $error;
@include stage($stage-paper);
.labels {
display: flex; flex-direction: column; gap: $sp-2;
margin: 0; padding: 0; list-style: none;
}
.label {
display: inline-flex; align-items: center; gap: $sp-1;
width: fit-content;
padding: $sp-1 $sp-3;
border-radius: $r-xs;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 12px;
letter-spacing: .02em;
}
.label--low { background: #e5e7eb; color: #4b5563; }
.label--medium { background: #dbeafe; color: #7386ba; }
.label--high { background: #fef3c7; color: #a47551; }
.label--urgent {
background: $stage-paper;
color: $color;
transform-origin: center;
}
// 긴급만 애니메이션 — opacity 만, 레이아웃은 그대로
.stage.is-demo .label--urgent {
animation: urgent-flash $duration $easing infinite;
}
@keyframes urgent-flash {
0%, 40% { opacity: 1; transform: scale(1); }
55% { opacity: .5; transform: scale(1.05); }
70%, 100% { opacity: 1; transform: scale(1); }
}
// ── 한 겹: 긴급 강조(55%) 순간 레드 링 파문 + 색 전이 ──
// urgent-flash 의 scale·opacity 는 그대로 두고, 링과 글자색만 별도 keyframes 로 움직인다
.label--urgent { position: relative; }
.label--urgent::after {
content: "";
position: absolute;
inset: -3px;
border-radius: $r-xs;
border: 2px solid rgba($color, 0);
transform: scale(.9);
pointer-events: none;
}
.stage.is-demo .label--urgent {
animation: urgent-flash $duration $easing infinite, urgent-tint $duration $ease-out infinite;
}
.stage.is-demo .label--urgent::after {
animation: urgent-ring $duration $easing infinite;
}
@keyframes urgent-tint {
0%, 40% { color: $color; background-color: $stage-paper; }
55% { color: mix(#fff, $color, 22%); background-color: mix(#fff, $color, 92%); }
70%, 100% { color: $color; background-color: $stage-paper; }
}
@keyframes urgent-ring {
0%, 40% { transform: scale(.9); opacity: 0; border-color: rgba($color, 0); }
50% { transform: scale(1); opacity: .75; border-color: rgba(mix(#ffd9c9, $color, 35%), .85); }
70%, 100% { transform: scale(1.22); opacity: 0; border-color: rgba($color, 0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .label--urgent { animation: none; }
.stage.is-demo .label--urgent::after { animation: none; opacity: 0; }
}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);
}
.labels {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.label {
display: inline-flex;
align-items: center;
gap: 4px;
width: fit-content;
padding: 4px 12px;
border-radius: 4px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.02em;
}
.label--low {
background: #e5e7eb;
color: #4b5563;
}
.label--medium {
background: #dbeafe;
color: #7386ba;
}
.label--high {
background: #fef3c7;
color: #a47551;
}
.label--urgent {
background: #ffffff;
color: #c2361a;
transform-origin: center;
}
.stage.is-demo .label--urgent {
animation: urgent-flash 2s ease-out infinite;
}
@keyframes urgent-flash {
0%, 40% {
opacity: 1;
transform: scale(1);
}
55% {
opacity: 0.5;
transform: scale(1.05);
}
70%, 100% {
opacity: 1;
transform: scale(1);
}
}
.label--urgent {
position: relative;
}
.label--urgent::after {
content: "";
position: absolute;
inset: -3px;
border-radius: 4px;
border: 2px solid rgba(194, 54, 26, 0);
transform: scale(0.9);
pointer-events: none;
}
.stage.is-demo .label--urgent {
animation: urgent-flash 2s ease-out infinite, urgent-tint 2s ease-out infinite;
}
.stage.is-demo .label--urgent::after {
animation: urgent-ring 2s ease-out infinite;
}
@keyframes urgent-tint {
0%, 40% {
color: #c2361a;
background-color: #ffffff;
}
55% {
color: rgb(81.3411764706%, 38.5176470588%, 29.9529411765%);
background-color: rgb(98.0862745098%, 93.6941176471%, 92.8156862745%);
}
70%, 100% {
color: #c2361a;
background-color: #ffffff;
}
}
@keyframes urgent-ring {
0%, 40% {
transform: scale(0.9);
opacity: 0;
border-color: rgba(194, 54, 26, 0);
}
50% {
transform: scale(1);
opacity: 0.75;
border-color: rgba(84.4509803922%, 43.5490196078%, 34.2156862745%, 0.85);
}
70%, 100% {
transform: scale(1.22);
opacity: 0;
border-color: rgba(194, 54, 26, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .label--urgent {
animation: none;
}
.stage.is-demo .label--urgent::after {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./PriorityLabelBadge.module.scss";
export type PriorityLabelBadgeProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function PriorityLabelBadge({
duration = 2000,
easing = "ease-out",
color = "#c2361a",
}: PriorityLabelBadgeProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<ul className={styles.labels} style={vars} aria-label="priority list">
<li className={`${styles.label} ${styles.low}`}>Low</li>
<li className={`${styles.label} ${styles.medium}`}>Medium</li>
<li className={`${styles.label} ${styles.high}`}>High</li>
<li className={`${styles.label} ${styles.urgent}`}>
<svg viewBox="0 0 256 256" width={12} height={12} aria-hidden="true">
<g fill="currentColor">
<path d="M224,56V176c-64,55.43-112-55.43-176,0V56C112,.57,160,111.43,224,56Z" opacity=".2" />
<path d="M42.76,50A8,8,0,0,0,40,56V224a8,8,0,0,0,16,0V179.77c26.79-21.16,49.87-9.75,76.45,3.41,16.4,8.11,34.06,16.85,53,16.85,13.93,0,28.54-4.75,43.82-18a8,8,0,0,0,2.76-6V56A8,8,0,0,0,218.76,50c-28,24.23-51.72,12.49-79.21-1.12C111.07,34.76,78.78,18.79,42.76,50ZM216,172.25c-26.79,21.16-49.87,9.74-76.45-3.41-25-12.35-52.81-26.13-83.55-8.4V59.79c26.79-21.16,49.87-9.75,76.45,3.4,25,12.35,52.82,26.13,83.55,8.4Z" />
</g>
</svg>
Urgent
</li>
</ul>
);
}03 · Priority label badge (low/medium/high/urgent) — Measured area 1.069% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
.label--urgent {
background: $error-soft;
color: $color;
}
.stage.is-demo .label--urgent {
animation: urgent-flash $duration $easing infinite;
}
@keyframes urgent-flash {
0%, 40% { opacity: 1; transform: scale(1); }
55% { opacity: .5; transform: scale(1.05); }
70%, 100% { opacity: 1; transform: scale(1); }
}
04Corner ribbon fold-unfurl
What you see
A folded ribbon drops open from the top edge via scaleY around a top transform-origin, then rolls back up — a different move from the light-sweep ribbon in another post. The moment it settles, a coral ring spreads out along its border and the ribbon background lightens briefly before returning, confirming the landing twice.

<main class="stage is-demo">
<div class="card" aria-label="sale item">
<div class="thumb"></div>
<div class="ribbon-mount">
<span class="ribbon">SALE</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 펼쳐짐+되감김 한 주기
$easing: $ease-spring;
$color: #ddbebb; // 캔디 코럴
@include stage($stage-orange);
.card {
position: relative;
width: 200px; height: 140px;
border-radius: $r-card;
overflow: hidden;
background: $subject-cream;
box-shadow: $shadow-raised;
}
.thumb {
position: absolute; inset: 0;
background: linear-gradient(135deg, rgba($subject-pink, .18), rgba($subject-lilac, .1));
}
// 213편 리본(빛줄기 스윕)과 다른 메커니즘 — 접힌 리본이 위에서 펼쳐졌다 걷힌다
.ribbon-mount {
position: absolute;
top: 0; right: $sp-4;
width: 56px;
overflow: visible;
}
.ribbon {
display: block;
padding: $sp-2 0 $sp-3;
background: $color;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 12px;
letter-spacing: .06em;
text-align: center;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 82%, 0 100%);
box-shadow: $shadow-press;
transform: scaleY(0) rotate(0deg);
transform-origin: top center; // 규칙8: 리본 top에서 펼쳐짐
}
.stage.is-demo .ribbon {
animation: unfurl $duration $easing infinite;
}
@keyframes unfurl {
0%, 8% { transform: scaleY(0) rotate(0deg); }
30% { transform: scaleY(1.06) rotate(-2deg); }
42%, 78% { transform: scaleY(1) rotate(0deg); }
92%, 100% { transform: scaleY(0) rotate(0deg); }
}
// ── 한 겹: 펼침 안착(42%) 순간 코럴 링 파문 + 리본 색 전이 ──
// unfurl 의 transform 은 그대로 — 링은 .ribbon-mount 에(clip-path 밖이라 잘리지 않음), 색은 리본 배경에
.ribbon-mount { position: absolute; } // 기존 absolute 재확인 — ::after 기준점
.ribbon-mount::after {
content: "";
position: absolute;
inset: -2px -2px 6px;
border-radius: $r-xs $r-xs 0 0;
border: 2px solid rgba($color, 0);
transform: scale(.92);
opacity: 0;
pointer-events: none;
}
.stage.is-demo .ribbon {
animation: unfurl $duration $easing infinite, ribbon-settle $duration $ease-out infinite;
}
.stage.is-demo .ribbon-mount::after {
animation: unfurl-ring $duration $easing infinite;
}
@keyframes ribbon-settle {
0%, 8% { background-color: $color; }
42% { background-color: mix(#fff, $color, 30%); }
62%, 100% { background-color: $color; }
}
@keyframes unfurl-ring {
0%, 30% { transform: scale(.92); opacity: 0; border-color: rgba($color, 0); }
40% { transform: scale(1); opacity: .75; border-color: rgba(mix(#ffe3d1, $color, 40%), .85); }
66%, 100% { transform: scale(1.18); opacity: 0; border-color: rgba($color, 0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ribbon { animation: none; transform: scaleY(1); }
.stage.is-demo .ribbon-mount::after { animation: none; opacity: 0; }
}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);
}
.card {
position: relative;
width: 200px;
height: 140px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.thumb {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 158, 187, 0.18), rgba(185, 160, 255, 0.1));
}
.ribbon-mount {
position: absolute;
top: 0;
right: 16px;
width: 56px;
overflow: visible;
}
.ribbon {
display: block;
padding: 8px 0 12px;
background: #ddbebb;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 12px;
letter-spacing: 0.06em;
text-align: center;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 82%, 0 100%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scaleY(0) rotate(0deg);
transform-origin: top center;
}
.stage.is-demo .ribbon {
animation: unfurl 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes unfurl {
0%, 8% {
transform: scaleY(0) rotate(0deg);
}
30% {
transform: scaleY(1.06) rotate(-2deg);
}
42%, 78% {
transform: scaleY(1) rotate(0deg);
}
92%, 100% {
transform: scaleY(0) rotate(0deg);
}
}
.ribbon-mount {
position: absolute;
}
.ribbon-mount::after {
content: "";
position: absolute;
inset: -2px -2px 6px;
border-radius: 4px 4px 0 0;
border: 2px solid rgba(221, 190, 187, 0);
transform: scale(0.92);
opacity: 0;
pointer-events: none;
}
.stage.is-demo .ribbon {
animation: unfurl 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, ribbon-settle 2s ease-out infinite;
}
.stage.is-demo .ribbon-mount::after {
animation: unfurl-ring 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ribbon-settle {
0%, 8% {
background-color: #ddbebb;
}
42% {
background-color: rgb(90.6666666667%, 82.1568627451%, 81.3333333333%);
}
62%, 100% {
background-color: #ddbebb;
}
}
@keyframes unfurl-ring {
0%, 30% {
transform: scale(0.92);
opacity: 0;
border-color: rgba(221, 190, 187, 0);
}
40% {
transform: scale(1);
opacity: 0.75;
border-color: rgba(92%, 80.3137254902%, 76.7843137255%, 0.85);
}
66%, 100% {
transform: scale(1.18);
opacity: 0;
border-color: rgba(221, 190, 187, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ribbon {
animation: none;
transform: scaleY(1);
}
.stage.is-demo .ribbon-mount::after {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./CornerRibbonUnfurl.module.scss";
export type CornerRibbonUnfurlProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function CornerRibbonUnfurl({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff6f61",
label = "SALE",
}: CornerRibbonUnfurlProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.card} style={vars} aria-label="sale item">
<div className={styles.thumb} />
<div className={styles.mount}>
<span className={styles.ribbon}>{label}</span>
</div>
</div>
);
}04 · Corner ribbon fold-unfurl — Measured area 1.443% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
.ribbon {
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 82%, 0 100%);
transform: scaleY(0) rotate(0deg);
transform-origin: top center;
}
.stage.is-demo .ribbon {
animation: unfurl $duration $easing infinite;
}
@keyframes unfurl {
0%, 8% { transform: scaleY(0) rotate(0deg); }
30% { transform: scaleY(1.06) rotate(-2deg); }
42%, 78% { transform: scaleY(1) rotate(0deg); }
92%, 100% { transform: scaleY(0) rotate(0deg); }
}
05Notification dot double-pulse
What happens on screen
The icon stays still while the corner dot springs twice via scale, reading like two messages landing back to back. On the second spring the dot lightens one tone and a third ripple ring trails the first two, layering arrival upon arrival.

<main class="stage is-demo">
<div class="icon-wrap" aria-label="New message">
<svg class="icon" viewBox="0 0 256 256" width="40" height="40" aria-hidden="true"><g fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity=".2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-6.54-.67L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Z"/></g></svg>
<span class="dot" aria-hidden="true">
<span class="dot__ring"></span>
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 더블 펄스 + 휴지 한 주기
$easing: $ease-pop;
$color: $error;
@include stage($stage-ink);
.icon-wrap {
position: relative;
width: 64px; height: 64px;
display: grid; place-items: center;
}
.icon { color: $stage-yellow; }
.dot {
position: absolute;
top: 10px; right: 10px;
width: 10px; height: 10px;
border-radius: 50%;
background: $color;
box-shadow: 0 0 0 2px $stage-ink;
}
.dot__ring {
position: absolute; inset: 0;
border-radius: 50%;
border: 2px solid $color;
opacity: 0;
transform: scale(1);
}
// 아이콘은 그대로 — 점과 링만 두 번 튀듯 움직인다 (213의 단일 레이더 핑과 다른 리듬)
.stage.is-demo .dot {
animation: double-pulse $duration $easing infinite;
}
.stage.is-demo .dot__ring {
animation: double-ring $duration $easing infinite;
}
@keyframes double-pulse {
0%, 100% { transform: scale(1); }
8%, 24% { transform: scale(1.35); }
16% { transform: scale(1); }
32% { transform: scale(1); }
}
@keyframes double-ring {
0%, 6% { opacity: 0; transform: scale(1); }
20% { opacity: .5; transform: scale(2.4); }
34% { opacity: 0; transform: scale(2.6); }
100% { opacity: 0; transform: scale(1); }
}
// ── 한 겹: 두 번째 펄스(24%) 순간 도트 색 전이 + 세 번째 물결 링 ──
// double-pulse/double-ring 은 그대로 — dot 배경색만 잠깐 밝아지고, 물결 링은 두 링을 뒤따라 확산
.icon-wrap::after {
content: "";
position: absolute;
top: 5px; right: 5px; // dot(10px) 중심 — inset -3px 원형 링
width: 16px; height: 16px;
border-radius: 50%;
border: 2px solid rgba($color, 0);
opacity: 0;
pointer-events: none;
}
.stage.is-demo .dot {
animation: double-pulse $duration $easing infinite, dot-tint $duration $ease-out infinite;
}
.stage.is-demo .icon-wrap::after {
animation: wake-ripple $duration $easing infinite;
}
@keyframes dot-tint {
0%, 18% { background-color: $color; }
24% { background-color: mix(#fff, $color, 35%); }
38%, 100% { background-color: $color; }
}
@keyframes wake-ripple {
0%, 18% { opacity: 0; transform: scale(.9); border-color: rgba($color, 0); }
26% { opacity: .7; transform: scale(1); border-color: rgba(mix(#ffd9c9, $color, 35%), .85); }
52%, 100% { opacity: 0; transform: scale(2.2); border-color: rgba($color, 0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .dot { animation: none; }
.stage.is-demo .dot__ring { animation: none; opacity: 0; }
.stage.is-demo .icon-wrap::after { animation: none; opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.icon-wrap {
position: relative;
width: 64px;
height: 64px;
display: grid;
place-items: center;
}
.icon {
color: #ffd23f;
}
.dot {
position: absolute;
top: 10px;
right: 10px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #c2361a;
box-shadow: 0 0 0 2px #17141a;
}
.dot__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid #c2361a;
opacity: 0;
transform: scale(1);
}
.stage.is-demo .dot {
animation: double-pulse 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .dot__ring {
animation: double-ring 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes double-pulse {
0%, 100% {
transform: scale(1);
}
8%, 24% {
transform: scale(1.35);
}
16% {
transform: scale(1);
}
32% {
transform: scale(1);
}
}
@keyframes double-ring {
0%, 6% {
opacity: 0;
transform: scale(1);
}
20% {
opacity: 0.5;
transform: scale(2.4);
}
34% {
opacity: 0;
transform: scale(2.6);
}
100% {
opacity: 0;
transform: scale(1);
}
}
.icon-wrap::after {
content: "";
position: absolute;
top: 5px;
right: 5px;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(194, 54, 26, 0);
opacity: 0;
pointer-events: none;
}
.stage.is-demo .dot {
animation: double-pulse 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite, dot-tint 2s ease-out infinite;
}
.stage.is-demo .icon-wrap::after {
animation: wake-ripple 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes dot-tint {
0%, 18% {
background-color: #c2361a;
}
24% {
background-color: rgb(84.4509803922%, 48.7647058824%, 41.6274509804%);
}
38%, 100% {
background-color: #c2361a;
}
}
@keyframes wake-ripple {
0%, 18% {
opacity: 0;
transform: scale(0.9);
border-color: rgba(194, 54, 26, 0);
}
26% {
opacity: 0.7;
transform: scale(1);
border-color: rgba(84.4509803922%, 43.5490196078%, 34.2156862745%, 0.85);
}
52%, 100% {
opacity: 0;
transform: scale(2.2);
border-color: rgba(194, 54, 26, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .dot {
animation: none;
}
.stage.is-demo .dot__ring {
animation: none;
opacity: 0;
}
.stage.is-demo .icon-wrap::after {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./NotificationDotPulse.module.scss";
export type NotificationDotPulseProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function NotificationDotPulse({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#c2361a",
}: NotificationDotPulseProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.wrap} style={vars} aria-label="new message">
<svg className={styles.icon} viewBox="0 0 256 256" width={40} height={40} aria-hidden="true">
<g fill="currentColor">
<path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity=".2" />
<path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-6.54-.67L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Z" />
</g>
</svg>
<span className={styles.dot}>
<span className={styles.ring} />
</span>
</div>
);
}05 · Notification dot double-pulse — Measured area 0.648% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.dot {
background: $color;
box-shadow: 0 0 0 2px $stage-ink;
}
.stage.is-demo .dot {
animation: double-pulse $duration $easing infinite;
}
.stage.is-demo .dot__ring {
animation: double-ring $duration $easing infinite;
}
@keyframes double-pulse {
0%, 100% { transform: scale(1); }
8%, 24% { transform: scale(1.35); }
16%, 32% { transform: scale(1); }
}
@keyframes double-ring {
0%, 6% { opacity: 0; transform: scale(1); }
20% { opacity: .5; transform: scale(2.4); }
34% { opacity: 0; transform: scale(2.6); }
100% { opacity: 0; transform: scale(1); }
}
06Tech-stack tag cloud
What moves
Six skill tags rise and fade via opacity and translateY in sequence, driven entirely by per-tag delay values.

<main class="stage is-demo">
<ul class="cloud" aria-label="Tech stack">
<li style="--i:0">CSS</li>
<li style="--i:1">React</li>
<li style="--i:2">TypeScript</li>
<li style="--i:3">SCSS</li>
<li style="--i:4">Figma</li>
<li style="--i:5">Node</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 등장→유지→퇴장 한 주기
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.cloud {
display: flex; flex-wrap: wrap; gap: $sp-2;
margin: 0; padding: 0; list-style: none;
max-width: 260px;
justify-content: center;
}
.cloud li {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba($color, .14);
border: 1px solid rgba($color, .4);
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 12px;
transform: translateY(10px);
opacity: 0;
}
.cloud li:nth-child(1) { background: rgba($subject-sky, .14); border-color: rgba($subject-sky, .4); }
.cloud li:nth-child(2) { background: rgba($subject-lilac, .14); border-color: rgba($subject-lilac, .4); }
.cloud li:nth-child(3) { background: rgba($subject-mint, .14); border-color: rgba($subject-mint, .4); }
.cloud li:nth-child(4) { background: rgba($subject-pink, .14); border-color: rgba($subject-pink, .4); }
.cloud li:nth-child(5) { background: rgba($subject-sky, .14); border-color: rgba($subject-sky, .4); }
.cloud li:nth-child(6) { background: rgba($subject-lilac, .14); border-color: rgba($subject-lilac, .4); }
.stage.is-demo .cloud li {
animation: rise $duration $easing infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes rise {
0% { opacity: 0; transform: translateY(10px); }
22%, 70% { opacity: 1; transform: translateY(0); }
92%, 100% { opacity: 0; transform: translateY(-6px); }
}
// ── 한 겹: 안착(22%) 순간 테두리 색 전이 ──────────────────
// rise 와 같은 주기·같은 delay(var(--i)) — li 색그룹별 밝은 파스텔로 잠깐 밝아졌다 안정 색으로
.cloud li { --glow: mix(#fff, $color, 40%); --glow-base: rgba($color, .4); }
.cloud li:nth-child(2), .cloud li:nth-child(6) { --glow: mix(#fff, $subject-lilac, 40%); --glow-base: rgba($subject-lilac, .4); }
.cloud li:nth-child(3) { --glow: mix(#fff, $subject-mint, 40%); --glow-base: rgba($subject-mint, .4); }
.cloud li:nth-child(4) { --glow: mix(#fff, $subject-pink, 40%); --glow-base: rgba($subject-pink, .4); }
.stage.is-demo .cloud li {
animation: rise $duration $easing infinite, border-glow $duration $easing infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes border-glow {
0% { border-color: var(--glow-base); }
22% { border-color: var(--glow); }
46%, 100% { border-color: var(--glow-base); }
}
@media (prefers-reduced-motion: reduce) {
// border-glow 도 animation: none 으로 함께 꺼진다 (rise 와 같은 선언)
.stage.is-demo .cloud li { animation: none; opacity: 1; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
max-width: 260px;
justify-content: center;
}
.cloud li {
padding: 4px 12px;
border-radius: 999px;
background: rgba(110, 200, 255, 0.14);
border: 1px solid rgba(110, 200, 255, 0.4);
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 600;
font-size: 12px;
transform: translateY(10px);
opacity: 0;
}
.cloud li:nth-child(1) {
background: rgba(110, 200, 255, 0.14);
border-color: rgba(110, 200, 255, 0.4);
}
.cloud li:nth-child(2) {
background: rgba(185, 160, 255, 0.14);
border-color: rgba(185, 160, 255, 0.4);
}
.cloud li:nth-child(3) {
background: rgba(76, 212, 166, 0.14);
border-color: rgba(76, 212, 166, 0.4);
}
.cloud li:nth-child(4) {
background: rgba(255, 158, 187, 0.14);
border-color: rgba(255, 158, 187, 0.4);
}
.cloud li:nth-child(5) {
background: rgba(110, 200, 255, 0.14);
border-color: rgba(110, 200, 255, 0.4);
}
.cloud li:nth-child(6) {
background: rgba(185, 160, 255, 0.14);
border-color: rgba(185, 160, 255, 0.4);
}
.stage.is-demo .cloud li {
animation: rise 2s ease-out infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes rise {
0% {
opacity: 0;
transform: translateY(10px);
}
22%, 70% {
opacity: 1;
transform: translateY(0);
}
92%, 100% {
opacity: 0;
transform: translateY(-6px);
}
}
.cloud li {
--glow: mix(#fff, $color, 40%);
--glow-base: rgba($color, .4);
}
.cloud li:nth-child(2), .cloud li:nth-child(6) {
--glow: mix(#fff, $subject-lilac, 40%);
--glow-base: rgba($subject-lilac, .4);
}
.cloud li:nth-child(3) {
--glow: mix(#fff, $subject-mint, 40%);
--glow-base: rgba($subject-mint, .4);
}
.cloud li:nth-child(4) {
--glow: mix(#fff, $subject-pink, 40%);
--glow-base: rgba($subject-pink, .4);
}
.stage.is-demo .cloud li {
animation: rise 2s ease-out infinite, border-glow 2s ease-out infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes border-glow {
0% {
border-color: var(--glow-base);
}
22% {
border-color: var(--glow);
}
46%, 100% {
border-color: var(--glow-base);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .cloud li {
animation: none;
opacity: 1;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./TechStackTagCloud.module.scss";
export type TechStackTagCloudProps = {
duration?: number;
easing?: string;
color?: string;
};
const TAGS = ["CSS", "React", "TypeScript", "SCSS", "Figma", "Node"];
export default function TechStackTagCloud({
duration = 2000,
easing = "ease-out",
color = "#6ec8ff",
}: TechStackTagCloudProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<ul className={styles.cloud} style={vars} aria-label="tech stack">
{TAGS.map((tag, i) => (
<li key={tag} style={{ animationDelay: `${i * 90}ms` }}>{tag}</li>
))}
</ul>
);
}06 · Tech-stack tag cloud — Measured area 8.515% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
.cloud li {
transform: translateY(10px);
opacity: 0;
}
.stage.is-demo .cloud li {
animation: rise $duration $easing infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes rise {
0% { opacity: 0; transform: translateY(10px); }
22%, 70% { opacity: 1; transform: translateY(0); }
92%, 100% { opacity: 0; transform: translateY(-6px); }
}
07Achievement badge unlock animation
What you see
A dulled trophy springs back to full color via scale, and a diagonal shine sweeps across it once to mark the moment.

<main class="stage is-demo">
<div class="badge" aria-label="Achievement unlocked — First step">
<svg class="badge__icon" viewBox="0 0 256 256" width="36" height="36" aria-hidden="true"><g fill="currentColor"><path d="M200,48v63.1c0,39.7-31.75,72.6-71.45,72.9A72,72,0,0,1,56,112V48Z" opacity=".2"/><path d="M232,64H208V48a8,8,0,0,0-8-8H56a8,8,0,0,0-8,8V64H24A16,16,0,0,0,8,80V96a40,40,0,0,0,40,40h3.65A80.13,80.13,0,0,0,120,191.61V216H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16H136V191.58c31.94-3.23,58.44-25.64,68.08-55.58H208a40,40,0,0,0,40-40V80A16,16,0,0,0,232,64ZM48,120A24,24,0,0,1,24,96V80H48v32q0,4,.39,8Zm144-8.9c0,35.52-29,64.64-64,64.9a64,64,0,0,1-64-64V56H192ZM232,96a24,24,0,0,1-24,24h-.5a81.81,81.81,0,0,0,.5-8.9V80h24Z"/></g></svg>
<span class="badge__shine" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 잠금 해제 바운스+샤인 한 주기
$easing: $ease-pop;
$color: #baa774; // 트로피 골드
@include stage($stage-yellow);
.badge {
position: relative;
width: 84px; height: 84px;
display: grid; place-items: center;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, mix(#fff, $color, 35%), $color 70%);
box-shadow: $shadow-raised;
overflow: hidden;
transform: scale(.85);
filter: grayscale(1) brightness(.85);
}
.badge__icon {
color: #fff;
position: relative;
z-index: 1;
}
.badge__shine {
position: absolute;
top: -20%; left: -60%;
width: 40%; height: 140%;
background: linear-gradient(115deg, transparent, rgba(255, 255, 255, .85), transparent);
transform: translateX(0) rotate(12deg);
opacity: 0;
}
.stage.is-demo .badge {
animation: unlock $duration $easing infinite;
}
.stage.is-demo .badge__shine {
animation: shine $duration $easing infinite;
}
@keyframes unlock {
0%, 30% { transform: scale(.85); filter: grayscale(1) brightness(.85); }
55% { transform: scale(1.12); filter: grayscale(0) brightness(1); }
70% { transform: scale(.96); }
82%, 100% { transform: scale(1); filter: grayscale(0) brightness(1); }
}
@keyframes shine {
0%, 55% { opacity: 0; transform: translateX(0) rotate(12deg); }
62% { opacity: 1; }
78%, 100% { opacity: 0; transform: translateX(340%) rotate(12deg); }
}
// ── 한 겹: unlock(55%) 순간 골드 링 파문 ──────────────────
// badge 는 overflow:hidden(샤인 클리핑) 이므로 링은 테두리 안쪽에서 바깥(테두리)로 확산되는 안팎 파문
.badge::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid mix(#fff, #baa774, 25%);
transform: scale(.62);
opacity: 0;
}
.stage.is-demo .badge::after {
animation: unlock-ring $duration $easing infinite;
}
@keyframes unlock-ring {
0%, 45% { transform: scale(.62); opacity: 0; }
55% { transform: scale(.8); opacity: .8; }
82%, 100% { transform: scale(1.15); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .badge { animation: none; transform: scale(1); filter: none; }
.stage.is-demo .badge__shine { animation: none; opacity: 0; }
.stage.is-demo .badge::after { animation: none; opacity: 0; }
}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);
}
.badge {
position: relative;
width: 84px;
height: 84px;
display: grid;
place-items: center;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgb(82.4117647059%, 77.568627451%, 64.568627451%), #baa774 70%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
transform: scale(0.85);
filter: grayscale(1) brightness(0.85);
}
.badge__icon {
color: #fff;
position: relative;
z-index: 1;
}
.badge__shine {
position: absolute;
top: -20%;
left: -60%;
width: 40%;
height: 140%;
background: linear-gradient(115deg, transparent, rgba(255, 255, 255, 0.85), transparent);
transform: translateX(0) rotate(12deg);
opacity: 0;
}
.stage.is-demo .badge {
animation: unlock 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .badge__shine {
animation: shine 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes unlock {
0%, 30% {
transform: scale(0.85);
filter: grayscale(1) brightness(0.85);
}
55% {
transform: scale(1.12);
filter: grayscale(0) brightness(1);
}
70% {
transform: scale(0.96);
}
82%, 100% {
transform: scale(1);
filter: grayscale(0) brightness(1);
}
}
@keyframes shine {
0%, 55% {
opacity: 0;
transform: translateX(0) rotate(12deg);
}
62% {
opacity: 1;
}
78%, 100% {
opacity: 0;
transform: translateX(340%) rotate(12deg);
}
}
.badge::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid rgb(79.7058823529%, 74.1176470588%, 59.1176470588%);
transform: scale(0.62);
opacity: 0;
}
.stage.is-demo .badge::after {
animation: unlock-ring 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes unlock-ring {
0%, 45% {
transform: scale(0.62);
opacity: 0;
}
55% {
transform: scale(0.8);
opacity: 0.8;
}
82%, 100% {
transform: scale(1.15);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .badge {
animation: none;
transform: scale(1);
filter: none;
}
.stage.is-demo .badge__shine {
animation: none;
opacity: 0;
}
.stage.is-demo .badge::after {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./AchievementBadgeUnlock.module.scss";
export type AchievementBadgeUnlockProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function AchievementBadgeUnlock({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#f5b301",
}: AchievementBadgeUnlockProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.badge} style={vars} aria-label="achievement unlocked — first step">
<svg className={styles.icon} viewBox="0 0 256 256" width={36} height={36} aria-hidden="true">
<g fill="currentColor">
<path d="M200,48v63.1c0,39.7-31.75,72.6-71.45,72.9A72,72,0,0,1,56,112V48Z" opacity=".2" />
<path d="M232,64H208V48a8,8,0,0,0-8-8H56a8,8,0,0,0-8,8V64H24A16,16,0,0,0,8,80V96a40,40,0,0,0,40,40h3.65A80.13,80.13,0,0,0,120,191.61V216H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16H136V191.58c31.94-3.23,58.44-25.64,68.08-55.58H208a40,40,0,0,0,40-40V80A16,16,0,0,0,232,64ZM48,120A24,24,0,0,1,24,96V80H48v32q0,4,.39,8Zm144-8.9c0,35.52-29,64.64-64,64.9a64,64,0,0,1-64-64V56H192ZM232,96a24,24,0,0,1-24,24h-.5a81.81,81.81,0,0,0,.5-8.9V80h24Z" />
</g>
</svg>
<span className={styles.shine} aria-hidden="true" />
</div>
);
}07 · Achievement badge unlock animation — Measured area 7.692% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
.badge {
filter: grayscale(1) brightness(.85);
transform: scale(.85);
}
.stage.is-demo .badge {
animation: unlock $duration $easing infinite;
}
@keyframes unlock {
0%, 30% { transform: scale(.85); filter: grayscale(1) brightness(.85); }
55% { transform: scale(1.12); filter: grayscale(0) brightness(1); }
70% { transform: scale(.96); }
82%, 100% { transform: scale(1); filter: grayscale(0) brightness(1); }
}
08Premium / verified badge flip
What happens on screen
The whole card spins in 3D via rotateY, alternating between a gold Premium face and a blue Verified face.

<main class="stage is-demo">
<div class="flip" aria-label="Premium verified account">
<div class="flip__inner">
<div class="flip__face flip__face--front">
<svg viewBox="0 0 256 256" width="26" height="26" aria-hidden="true"><g fill="currentColor"><path d="M216.71,99.73l-15.6,93.59a8,8,0,0,1-7.89,6.68H62.78a8,8,0,0,1-7.89-6.68L39.29,99.73l.08,0a19.82,19.82,0,0,0,9.22-4.16h0L88,144l32-73.65h0a20,20,0,0,0,15.92,0h0L168,144l39.39-48.48h0a19.82,19.82,0,0,0,9.22,4.16Z" opacity=".2"/><path d="M248,80a28,28,0,1,0-51.12,15.77l-26.79,33L146,73.4a28,28,0,1,0-36.06,0L85.91,128.74l-26.79-33a28,28,0,1,0-26.6,12L47,194.63A16,16,0,0,0,62.78,208H193.22A16,16,0,0,0,209,194.63l14.47-86.85A28,28,0,0,0,248,80ZM128,40a12,12,0,1,1-12,12A12,12,0,0,1,128,40ZM24,80A12,12,0,1,1,36,92,12,12,0,0,1,24,80ZM193.22,192H62.78L48.86,108.52,81.79,149A8,8,0,0,0,88,152a7.83,7.83,0,0,0,1.08-.07,8,8,0,0,0,6.26-4.74l29.3-67.4a27,27,0,0,0,6.72,0l29.3,67.4a8,8,0,0,0,6.26,4.74A7.83,7.83,0,0,0,168,152a8,8,0,0,0,6.21-3l32.93-40.52ZM220,92a12,12,0,1,1,12-12A12,12,0,0,1,220,92Z"/></g></svg>
<span>Premium</span>
</div>
<div class="flip__face flip__face--back">
<svg viewBox="0 0 256 256" width="26" height="26" aria-hidden="true"><g fill="currentColor"><path d="M216,56v56c0,96-88,120-88,120S40,208,40,112V56a8,8,0,0,1,8-8H208A8,8,0,0,1,216,56Z" opacity=".2"/><path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.26,47,25.53a8,8,0,0,0,4.2,0c1-.27,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.6,89.42A129.3,129.3,0,0,1,128,223.62a128.25,128.25,0,0,1-38.92-21.81C61.82,179.51,48,149.3,48,112l0-56,160,0ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z"/></g></svg>
<span>Verified</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 앞뒤 한 번씩 뒤집는 한 주기
$easing: $ease-spring;
$color: #b7a46d; // 프리미엄 골드
@include stage($stage-paper);
.flip {
width: 128px; height: 46px;
perspective: 600px;
}
.flip__inner {
position: relative;
width: 100%; height: 100%;
transform-style: preserve-3d;
transform: rotateY(0deg);
}
// 213-03(SVG stroke-dashoffset 그리기)과 다른 메커니즘 — 카드 자체가 rotateY 로 뒤집힌다
.flip__face {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center; gap: $sp-2;
border-radius: $r-pill;
backface-visibility: hidden;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 14px;
box-shadow: $shadow-press;
}
.flip__face--front {
background: linear-gradient(135deg, mix(#fff, $color, 25%), $color);
color: #4a3400;
}
.flip__face--back {
background: linear-gradient(135deg, mix(#fff, $subject-blue, 20%), $subject-blue);
color: #fff;
transform: rotateY(180deg);
}
.stage.is-demo .flip__inner {
animation: flip $duration $easing infinite;
}
@keyframes flip {
0%, 40% { transform: rotateY(0deg); }
55%, 85% { transform: rotateY(180deg); }
100% { transform: rotateY(360deg); }
}
// ── 한 겹: 플립 뒤집힘(55~85%) 구간 앞면 box-shadow 색 전이 ──
// blur/spread 는 $shadow-press 그대로, 색만 골드 → 스카이 파스텔 로 (레이아아웃 불변)
.stage.is-demo .flip__face--front {
animation: face-glow $duration $easing infinite;
}
@keyframes face-glow {
0%, 50% { box-shadow: 0 6px 12px rgba(#b7a46d, .32); }
62% { box-shadow: 0 6px 12px rgba($subject-sky, .38); }
85%, 100% { box-shadow: 0 6px 12px rgba(#b7a46d, .32); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .flip__inner { animation: none; }
.stage.is-demo .flip__face--front { animation: 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);
}
.flip {
width: 128px;
height: 46px;
perspective: 600px;
}
.flip__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transform: rotateY(0deg);
}
.flip__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 999px;
backface-visibility: hidden;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 14px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.flip__face--front {
background: linear-gradient(135deg, rgb(78.8235294118%, 73.2352941176%, 57.0588235294%), #b7a46d);
color: #4a3400;
}
.flip__face--back {
background: linear-gradient(135deg, rgb(34.7450980392%, 54.1960784314%, 97.1764705882%), #2f6df6);
color: #fff;
transform: rotateY(180deg);
}
.stage.is-demo .flip__inner {
animation: flip 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes flip {
0%, 40% {
transform: rotateY(0deg);
}
55%, 85% {
transform: rotateY(180deg);
}
100% {
transform: rotateY(360deg);
}
}
.stage.is-demo .flip__face--front {
animation: face-glow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes face-glow {
0%, 50% {
box-shadow: 0 6px 12px rgba(183, 164, 109, 0.32);
}
62% {
box-shadow: 0 6px 12px rgba(110, 200, 255, 0.38);
}
85%, 100% {
box-shadow: 0 6px 12px rgba(183, 164, 109, 0.32);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .flip__inner {
animation: none;
}
.stage.is-demo .flip__face--front {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./PremiumVerifiedBadgeFlip.module.scss";
export type PremiumVerifiedBadgeFlipProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function PremiumVerifiedBadgeFlip({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#d4a418",
}: PremiumVerifiedBadgeFlipProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.flip} style={vars} aria-label="premium verified account">
<div className={styles.inner}>
<div className={`${styles.face} ${styles.front}`}>
<svg viewBox="0 0 256 256" width={26} height={26} aria-hidden="true">
<g fill="currentColor">
<path d="M216.71,99.73l-15.6,93.59a8,8,0,0,1-7.89,6.68H62.78a8,8,0,0,1-7.89-6.68L39.29,99.73l.08,0a19.82,19.82,0,0,0,9.22-4.16h0L88,144l32-73.65h0a20,20,0,0,0,15.92,0h0L168,144l39.39-48.48h0a19.82,19.82,0,0,0,9.22,4.16Z" opacity=".2" />
<path d="M248,80a28,28,0,1,0-51.12,15.77l-26.79,33L146,73.4a28,28,0,1,0-36.06,0L85.91,128.74l-26.79-33a28,28,0,1,0-26.6,12L47,194.63A16,16,0,0,0,62.78,208H193.22A16,16,0,0,0,209,194.63l14.47-86.85A28,28,0,0,0,248,80ZM128,40a12,12,0,1,1-12,12A12,12,0,0,1,128,40ZM24,80A12,12,0,1,1,36,92,12,12,0,0,1,24,80ZM193.22,192H62.78L48.86,108.52,81.79,149A8,8,0,0,0,88,152a7.83,7.83,0,0,0,1.08-.07,8,8,0,0,0,6.26-4.74l29.3-67.4a27,27,0,0,0,6.72,0l29.3,67.4a8,8,0,0,0,6.26,4.74A7.83,7.83,0,0,0,168,152a8,8,0,0,0,6.21-3l32.93-40.52ZM220,92a12,12,0,1,1,12-12A12,12,0,0,1,220,92Z" />
</g>
</svg>
<span>Premium</span>
</div>
<div className={`${styles.face} ${styles.back}`}>
<svg viewBox="0 0 256 256" width={26} height={26} aria-hidden="true">
<g fill="currentColor">
<path d="M216,56v56c0,96-88,120-88,120S40,208,40,112V56a8,8,0,0,1,8-8H208A8,8,0,0,1,216,56Z" opacity=".2" />
<path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.26,47,25.53a8,8,0,0,0,4.2,0c1-.27,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.6,89.42A129.3,129.3,0,0,1,128,223.62a128.25,128.25,0,0,1-38.92-21.81C61.82,179.51,48,149.3,48,112l0-56,160,0ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z" />
</g>
</svg>
<span>Verified</span>
</div>
</div>
</div>
);
}08 · Premium / verified badge flip — Measured area 4.885% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.flip__inner {
transform-style: preserve-3d;
}
.flip__face {
backface-visibility: hidden;
}
.flip__face--back {
transform: rotateY(180deg);
}
.stage.is-demo .flip__inner {
animation: flip $duration $easing infinite;
}
@keyframes flip {
0%, 40% { transform: rotateY(0deg); }
55%, 85% { transform: rotateY(180deg); }
100% { transform: rotateY(360deg); }
}
09Countdown expiry chip
What moves
Ten digits stack vertically and step down one at a time via translateY, reading like a mechanical flip counter.

<main class="stage is-demo">
<div class="chip" aria-label="Expires in 12 minutes">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M188.82,82,128,128,67.2,82.4A8,8,0,0,1,64,76V40a8,8,0,0,1,8-8H184a8,8,0,0,1,8,8V75.64A8,8,0,0,1,188.82,82ZM64,180v36a8,8,0,0,0,8,8H184a8,8,0,0,0,8-8V180.36a8,8,0,0,0-3.18-6.38L128,128,67.2,173.6A8,8,0,0,0,64,180Z" opacity=".2"/><path d="M200,75.64V40a16,16,0,0,0-16-16H72A16,16,0,0,0,56,40V76a16.07,16.07,0,0,0,6.4,12.8L114.67,128,62.4,167.2A16.07,16.07,0,0,0,56,180v36a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V180.36a16.09,16.09,0,0,0-6.35-12.77L141.27,128l52.38-39.59A16.09,16.09,0,0,0,200,75.64ZM184,216H72V180l56-42,56,42.35Zm0-140.36L128,118,72,76V40H184Z"/></g></svg>
<span class="chip__label">Ends in 00:1</span>
<span class="chip__digit" aria-hidden="true"><span class="chip__digit-track">
<span>9</span><span>8</span><span>7</span><span>6</span><span>5</span><span>4</span><span>3</span><span>2</span><span>1</span><span>0</span>
</span></span>
<span class="sr-only">12 minutes left</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 숫자 10칸이 한 바퀴 도는 시간
$easing: steps(10);
$color: $stage-orange;
@include stage($stage-orange);
.chip {
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-2 $sp-3;
border-radius: $r-pill;
overflow: hidden;
background: #fff;
color: $subject-orange-deep;
box-shadow: $shadow-press;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 13px;
white-space: nowrap;
}
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
// 초 자리만 플립 카운터처럼 세로로 넘어간다 — 213-08 의 scaleX 드레인 바와 다른 메커니즘
// 별도 고정 크기 박스라 주변 텍스트의 줄 높이와 섞이지 않는다
.chip__digit {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 26px;
overflow: hidden;
border-radius: $r-xs;
background: rgba($color, .1);
font-size: 19px;
flex: 0 0 auto;
}
.chip__digit-track {
display: flex;
flex-direction: column;
transform: translateY(0);
span {
display: block;
width: 18px; height: 26px;
line-height: 26px;
text-align: center;
// 트랙은 칩 밖(주황 무대) 위를 지나가므로 주황 면 위 글자 규칙대로 잉크로 — $subject-orange-deep 은 밝은 면 전용
color: $subject-ink;
}
}
.stage.is-demo .chip__digit-track {
animation: tick $duration $easing infinite;
}
@keyframes tick {
0% { transform: translateY(0); }
100% { transform: translateY(-260px); }
}
// ── 한 겹: 0 도달(주기 끝 92~100%) 레드→코럴 링 확산 + 칩 배경 색 전이 ──
// tick 의 steps(10) 와 달리 별도 keyframes — ease-out 으로 색만 움직인다
.chip { position: relative; }
.chip::after {
content: "";
position: absolute;
inset: -3px;
border-radius: $r-pill;
border: 2px solid rgba($stage-orange, .0);
transform: scale(.9);
pointer-events: none;
}
.stage.is-demo .chip {
animation: chip-expire $duration ease-out infinite;
}
.stage.is-demo .chip::after {
animation: expire-ring $duration ease-out infinite;
}
@keyframes chip-expire {
0%, 88% { background-color: #fff; }
94% { background-color: mix(#fff, $stage-orange, 88%); }
100% { background-color: #fff; }
}
@keyframes expire-ring {
0%, 88% { transform: scale(.9); opacity: 0; border-color: rgba($stage-orange, 0); }
92% { transform: scale(1); opacity: .8; border-color: rgba(mix(#ffb8a3, $stage-orange, 30%), .85); }
100% { transform: scale(1.12); opacity: 0; border-color: rgba(mix(#ffb8a3, $stage-orange, 30%), 0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .chip__digit-track { animation: none; }
.stage.is-demo .chip { animation: none; }
.stage.is-demo .chip::after { animation: none; opacity: 0; }
}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);
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-radius: 999px;
overflow: hidden;
background: #fff;
color: #c93000;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 13px;
white-space: nowrap;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.chip__digit {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 26px;
overflow: hidden;
border-radius: 4px;
background: rgba(255, 77, 31, 0.1);
font-size: 19px;
flex: 0 0 auto;
}
.chip__digit-track {
display: flex;
flex-direction: column;
transform: translateY(0);
}
.chip__digit-track span {
display: block;
width: 18px;
height: 26px;
line-height: 26px;
text-align: center;
color: #17141a;
}
.stage.is-demo .chip__digit-track {
animation: tick 2s steps(10) infinite;
}
@keyframes tick {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-260px);
}
}
.chip {
position: relative;
}
.chip::after {
content: "";
position: absolute;
inset: -3px;
border-radius: 999px;
border: 2px solid rgba(255, 77, 31, 0);
transform: scale(0.9);
pointer-events: none;
}
.stage.is-demo .chip {
animation: chip-expire 2s ease-out infinite;
}
.stage.is-demo .chip::after {
animation: expire-ring 2s ease-out infinite;
}
@keyframes chip-expire {
0%, 88% {
background-color: #fff;
}
94% {
background-color: rgb(100%, 91.6235294118%, 89.4588235294%);
}
100% {
background-color: #fff;
}
}
@keyframes expire-ring {
0%, 88% {
transform: scale(0.9);
opacity: 0;
border-color: rgba(255, 77, 31, 0);
}
92% {
transform: scale(1);
opacity: 0.8;
border-color: rgba(100%, 42.7843137255%, 27.6862745098%, 0.85);
}
100% {
transform: scale(1.12);
opacity: 0;
border-color: rgba(100%, 42.7843137255%, 27.6862745098%, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .chip__digit-track {
animation: none;
}
.stage.is-demo .chip {
animation: none;
}
.stage.is-demo .chip::after {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./CountdownExpiryChip.module.scss";
export type CountdownExpiryChipProps = {
duration?: number;
easing?: string;
color?: string;
};
const DIGITS = ["9", "8", "7", "6", "5", "4", "3", "2", "1", "0"];
export default function CountdownExpiryChip({
duration = 2000,
easing = "steps(10)",
color = "#ff4d1f",
}: CountdownExpiryChipProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.chip} style={vars} aria-label="expires in 12 minutes">
<svg viewBox="0 0 256 256" width={16} height={16} aria-hidden="true">
<g fill="currentColor">
<path d="M188.82,82,128,128,67.2,82.4A8,8,0,0,1,64,76V40a8,8,0,0,1,8-8H184a8,8,0,0,1,8,8V75.64A8,8,0,0,1,188.82,82ZM64,180v36a8,8,0,0,0,8,8H184a8,8,0,0,0,8-8V180.36a8,8,0,0,0-3.18-6.38L128,128,67.2,173.6A8,8,0,0,0,64,180Z" opacity=".2" />
<path d="M200,75.64V40a16,16,0,0,0-16-16H72A16,16,0,0,0,56,40V76a16.07,16.07,0,0,0,6.4,12.8L114.67,128,62.4,167.2A16.07,16.07,0,0,0,56,180v36a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V180.36a16.09,16.09,0,0,0-6.35-12.77L141.27,128l52.38-39.59A16.09,16.09,0,0,0,200,75.64ZM184,216H72V180l56-42,56,42.35Zm0-140.36L128,118,72,76V40H184Z" />
</g>
</svg>
<span className={styles.label}>Ends in 00:1</span>
<span className={styles.digit} aria-hidden="true">
<span className={styles.track}>
{DIGITS.map((d) => <span key={d}>{d}</span>)}
</span>
</span>
<span className={styles.srOnly}>12 minutes left</span>
</div>
);
}09 · Countdown expiry chip — Measured area 3.926% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
.chip__digit {
width: 18px; height: 26px;
overflow: hidden;
}
.chip__digit-track span {
height: 26px; line-height: 26px;
}
.stage.is-demo .chip__digit-track {
animation: tick $duration $easing infinite;
}
@keyframes tick {
0% { transform: translateY(0); }
100% { transform: translateY(-260px); }
}
Where it breaks — a digit stuffed into a line of text breaks its box
The countdown chip's ones-place digit first sat inline with the surrounding text, just scaled up to a bigger font-size. The digit spilled outside the chip and sat on the background — the chip is inline-flex, so its line height follows the surrounding text, and without overflow: hidden, a taller inline child simply overflows past the pill's edge. Pulling the digit into its own fixed-size box (18×26px) and adding overflow: hidden to the chip itself kept every digit inside. Blow the same glow up to section size and the bloom math matches AI Landing Page Design Kit. The zip password is cwww6ahb, and typing it exactly as shown opens all nine folders. The 3D flip badge has a similar trap — skip backface-visibility: hidden on either face, and the back text shows through mirrored mid-spin.
Accessibility
All nine drop their animation under prefers-reduced-motion: reduce and settle on a final state. The online ring keeps just the dot, the achievement badge stays at its unlocked color, and the countdown digit stops advancing. Every state reads from color and shape alone once motion is off. See MDN's prefers-reduced-motion docs for how to set it.
FAQ
Can I recolor these badge design ideas?
Yes — most demos take one $color variable at the top of the SCSS. The premium/verified flip (08) is the exception, since each face has its own gradient to edit. More badge ideas live in 9 CSS badge designs. Badges that sell rather than report state, new and sale, get their own nine in CSS Badge Design Ideas.
Does the removable tag chip need JavaScript?
CSS alone can't remove an element. The tag chip (02) ships with about ten lines of vanilla JS that actually deletes it on click; the React version moves the same logic into a couple of useState hooks.
Does the countdown chip track real time?
No — it's a looping demo built to show the motion, not wired to a clock. To count down for real, compare the current time to a target with setInterval and swap in the digit. See About for how these demos get built and checked.









