9 CSS Badge Design Ideas — New, Sale, Verified
A badge is the small label on a thumbnail or name that shows status at a glance — new, sale, verified, sold out.
01 badge-new-ribbon
02 badge-sale-percent
03 badge-verified-check
04 badge-beta-pill
05 badge-live-pulse-dot
06 badge-count-notification
07 badge-trending-flame
08 badge-limited-stock
09 badge-sold-out-overlay
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Corner ribbon badge
- 02 Sale tag badge
- 03 Verified check badge
- 04 Beta gradient pill badge
- 05 Live pulse dot badge
- 06 Notification count badge
- 07 Trending flame badge
- 08 Limited-stock bar badge
- 09 Sold-out stripe overlay
The order below follows how a shopper actually meets these badges, not a list of badge design ideas ranked by popularity. It opens with the first thing that catches an eye — new (01) and a price-shaking sale (02) — then moves to trust signals: verified (03) and an honest beta (04). From there it covers what's happening right now — live (05) and piling-up notifications (06) — before closing with proof of popularity (07) and the two warnings that end a purchase, low stock (08) and sold out (09). Bend the same small label toward refracting light and it becomes 9 CSS Holographic Card Effects; sink it toward form right-and-wrong and it becomes 9 Form Validation CSS Animations.
01Corner ribbon badge
What you see
A ribbon tilted with rotate(-45deg) sits across the card corner, and a white light streak layered on top of it (an ::after gradient) sweeps across it on a loop by animating only background-position.

<main class="stage is-demo">
<div class="card" aria-label="new arrival">
<div class="thumb"></div>
<div class="ribbon-wrap">
<span class="ribbon">NEW</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 빛줄기 한 주기
$easing: $ease-out;
$color: #50a26e; // NEW 리본 그린
@include stage($stage-yellow);
.card {
position: relative;
width: 220px; 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-blue, .12), rgba($subject-blue, .04));
}
.ribbon-wrap {
position: absolute;
top: $sp-6; left: -$sp-10;
width: 160px;
transform: rotate(-45deg);
overflow: hidden;
}
.ribbon {
position: relative;
display: block;
padding: $sp-1 0;
background: $color;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 13px;
letter-spacing: .06em;
text-align: center;
box-shadow: $shadow-press;
}
// 빛줄기 ::after — background-position 만 움직인다 (레이아웃은 그대로)
.ribbon::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(115deg, transparent 15%, rgba(255, 255, 255, .7) 45%, transparent 75%);
background-size: 220% 100%;
background-position: 140% 0;
}
.stage.is-demo .ribbon::after {
animation: shine-sweep $duration $easing infinite;
}
@keyframes shine-sweep {
0%, 15% { background-position: 140% 0; }
55% { background-position: -40% 0; }
100% { background-position: -40% 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ribbon::after { animation: none; background-position: 30% 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);
}
.card {
position: relative;
width: 220px;
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(47, 109, 246, 0.12), rgba(47, 109, 246, 0.04));
}
.ribbon-wrap {
position: absolute;
top: 24px;
left: -40px;
width: 160px;
transform: rotate(-45deg);
overflow: hidden;
}
.ribbon {
position: relative;
display: block;
padding: 4px 0;
background: #50a26e;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.06em;
text-align: center;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ribbon::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(115deg, transparent 15%, rgba(255, 255, 255, 0.7) 45%, transparent 75%);
background-size: 220% 100%;
background-position: 140% 0;
}
.stage.is-demo .ribbon::after {
animation: shine-sweep 2s ease-out infinite;
}
@keyframes shine-sweep {
0%, 15% {
background-position: 140% 0;
}
55% {
background-position: -40% 0;
}
100% {
background-position: -40% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ribbon::after {
animation: none;
background-position: 30% 0;
}
}import type { CSSProperties } from "react";
import styles from "./BadgeNewRibbon.module.scss";
export type BadgeNewRibbonProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function BadgeNewRibbon({
duration = 2000,
easing = "ease-out",
color = "#16a34a",
label = "NEW",
}: BadgeNewRibbonProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.card} style={vars} aria-label="new arrival">
<div className={styles.thumb} />
<div className={styles.ribbonWrap}>
<span className={styles.ribbon}>{label}</span>
</div>
</div>
);
}01 · Corner ribbon badge — Measured area 1.912% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Even a static NEW label keeps pulling the eye this way.
.ribbon-wrap {
position: absolute;
top: $sp-6; left: -$sp-10;
transform: rotate(-45deg);
}
.ribbon::after {
background: linear-gradient(115deg, transparent 15%, rgba(255, 255, 255, .7) 45%, transparent 75%);
background-size: 220% 100%;
}
.stage.is-demo .ribbon::after {
animation: shine-sweep $duration $easing infinite;
}
@keyframes shine-sweep {
0%, 15% { background-position: 140% 0; }
55%, 100% { background-position: -40% 0; }
}
02Sale tag badge
What happens on screen
clip-path cuts the badge into an actual price-tag silhouette, notch included, and it rocks and scales in a loop so it reads like a real paper tag swinging on a string.

<main class="stage is-demo">
<div class="tag" aria-label="30% off">
<span class="tag__hole"></span>
<span class="tag__text">-30%</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 흔들림 한 주기
$easing: $ease-spring;
$color: $stage-orange; // 세일 오렌지레드
@include stage($stage-ink);
.tag {
position: relative;
width: 132px; height: 92px;
// 진짜 가격표 모양 — 오른쪽이 뾰족한 오각형
clip-path: polygon(0 0, 76% 0, 100% 50%, 76% 100%, 0 100%);
background: $color;
display: grid; place-items: center;
box-shadow: $shadow-raised;
transform-origin: 8% 50%; // 규칙8: 구멍 핀 고정 흔들림
}
.tag__hole {
position: absolute;
left: $sp-2; top: 50%;
width: $sp-2; height: $sp-2;
margin-top: -$sp-1;
border-radius: 50%;
background: $stage-ink;
}
.tag__text {
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 26px;
letter-spacing: -.01em;
padding-left: $sp-2;
}
.stage.is-demo .tag {
animation: tag-swing $duration $easing infinite;
}
@keyframes tag-swing {
0%, 100% { transform: rotate(0deg) scale(1); box-shadow: $shadow-raised; }
30% { transform: rotate(-6deg) scale(1.05); box-shadow: 0 18px 30px rgba(23, 20, 26, .4); }
60% { transform: rotate(3deg) scale(1.02); }
80% { transform: rotate(-1deg) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .tag { animation: 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);
}
.tag {
position: relative;
width: 132px;
height: 92px;
clip-path: polygon(0 0, 76% 0, 100% 50%, 76% 100%, 0 100%);
background: #ff4d1f;
display: grid;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: 8% 50%;
}
.tag__hole {
position: absolute;
left: 8px;
top: 50%;
width: 8px;
height: 8px;
margin-top: -4px;
border-radius: 50%;
background: #17141a;
}
.tag__text {
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 26px;
letter-spacing: -0.01em;
padding-left: 8px;
}
.stage.is-demo .tag {
animation: tag-swing 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tag-swing {
0%, 100% {
transform: rotate(0deg) scale(1);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
30% {
transform: rotate(-6deg) scale(1.05);
box-shadow: 0 18px 30px rgba(23, 20, 26, 0.4);
}
60% {
transform: rotate(3deg) scale(1.02);
}
80% {
transform: rotate(-1deg) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .tag {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./BadgeSalePercent.module.scss";
export type BadgeSalePercentProps = {
duration?: number;
easing?: string;
color?: string;
percent?: number;
};
export default function BadgeSalePercent({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff4d1f",
percent = 30,
}: BadgeSalePercentProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.tag} style={vars} aria-label={`${percent}% off`}>
<span className={styles.hole} />
<span className={styles.text}>-{percent}%</span>
</div>
);
}02 · Sale tag badge — Measured area 3.387% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.tag {
clip-path: polygon(0 0, 76% 0, 100% 50%, 76% 100%, 0 100%);
transform-origin: 8% 50%;
}
.stage.is-demo .tag {
animation: tag-swing $duration $easing infinite;
}
@keyframes tag-swing {
0%, 100% { transform: rotate(0deg) scale(1); }
30% { transform: rotate(-6deg) scale(1.05); }
60% { transform: rotate(3deg) scale(1.02); }
80% { transform: rotate(-1deg) scale(1); }
}
03Verified check badge
What moves
The verified badge beside the name is a single Phosphor SVG icon, and the moment it is confirmed transform: scale pops it 1 → 1.28 → 0.94 → 1 and settles.

<main class="stage is-demo">
<div class="row">
<span class="avatar"></span>
<span class="name">
godrich
<svg class="check" viewBox="0 0 256 256" width="20" height="20" aria-label="verified account">
<g fill="currentColor"><path d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" opacity=".2"/><path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18m-11.55 39.29c-4.79 5-9.75 10.17-12.38 16.52c-2.52 6.1-2.63 13.07-2.73 19.82c-.1 7-.21 14.33-3.32 17.43s-10.39 3.22-17.43 3.32c-6.75.1-13.72.21-19.82 2.73c-6.35 2.63-11.52 7.59-16.52 12.38S132 224 128 224s-9.15-4.92-14.11-9.69s-10.17-9.75-16.52-12.38c-6.1-2.52-13.07-2.63-19.82-2.73c-7-.1-14.33-.21-17.43-3.32s-3.22-10.39-3.32-17.43c-.1-6.75-.21-13.72-2.73-19.82c-2.63-6.35-7.59-11.52-12.38-16.52S32 132 32 128s4.92-9.15 9.69-14.11s9.75-10.17 12.38-16.52c2.52-6.1 2.63-13.07 2.73-19.82c.1-7 .21-14.33 3.32-17.43s10.39-3.22 17.43-3.32c6.75-.1 13.72-.21 19.82-2.73c6.35-2.63 11.52-7.59 16.52-12.38S124 32 128 32s9.15 4.92 14.11 9.69s10.17 9.75 16.52 12.38c6.1 2.52 13.07 2.63 19.82 2.73c7 .1 14.33.21 17.43 3.32s3.22 10.39 3.32 17.43c.1 6.75.21 13.72 2.73 19.82c2.63 6.35 7.59 11.52 12.38 16.52S224 124 224 128s-4.92 9.15-9.69 14.11m-40.65-43.77a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g>
</svg>
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 그리기+팝 한 주기
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.row {
display: flex; align-items: center; gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.avatar {
width: $sp-10; height: $sp-10;
border-radius: 50%;
background: linear-gradient(135deg, $subject-blue, $subject-ink);
}
.name {
display: inline-flex; align-items: center; gap: $sp-1;
font-size: 20px; font-weight: 700; color: $subject-ink;
}
.check {
transform-origin: center;
color: $color; // Phosphor duotone: g fill="currentColor" 가 이 값을 물려받는다
}
.stage.is-demo .check {
animation: pop $duration $easing infinite;
}
@keyframes pop {
0%, 50% { transform: scale(1); }
62% { transform: scale(1.28); }
74% { transform: scale(0.94); }
86%, 100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .check { 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);
}
.row {
display: flex;
align-items: center;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #2f6df6, #17141a);
}
.name {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 20px;
font-weight: 700;
color: #17141a;
}
.check {
transform-origin: center;
color: #2f6df6;
}
.stage.is-demo .check {
animation: pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes pop {
0%, 50% {
transform: scale(1);
}
62% {
transform: scale(1.28);
}
74% {
transform: scale(0.94);
}
86%, 100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .check {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./BadgeVerifiedCheck.module.scss";
export type BadgeVerifiedCheckProps = {
duration?: number;
easing?: string;
color?: string;
name?: string;
};
export default function BadgeVerifiedCheck({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
name = "godrich",
}: BadgeVerifiedCheckProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.row} style={vars}>
<span className={styles.avatar} />
<span className={styles.name}>
{name}
<svg className={styles.check} viewBox="0 0 256 256" width={20} height={20} aria-label="verified account">
<g fill="currentColor"><path d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" opacity=".2" /><path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18m-11.55 39.29c-4.79 5-9.75 10.17-12.38 16.52c-2.52 6.1-2.63 13.07-2.73 19.82c-.1 7-.21 14.33-3.32 17.43s-10.39 3.22-17.43 3.32c-6.75.1-13.72.21-19.82 2.73c-6.35 2.63-11.52 7.59-16.52 12.38S132 224 128 224s-9.15-4.92-14.11-9.69s-10.17-9.75-16.52-12.38c-6.1-2.52-13.07-2.63-19.82-2.73c-7-.1-14.33-.21-17.43-3.32s-3.22-10.39-3.32-17.43c-.1-6.75-.21-13.72-2.73-19.82c-2.63-6.35-7.59-11.52-12.38-16.52S32 132 32 128s4.92-9.15 9.69-14.11s9.75-10.17 12.38-16.52c2.52-6.1 2.63-13.07 2.73-19.82c.1-7 .21-14.33 3.32-17.43s10.39-3.22 17.43-3.32c6.75-.1 13.72-.21 19.82-2.73c6.35-2.63 11.52-7.59 16.52-12.38S124 32 128 32s9.15 4.92 14.11 9.69s10.17 9.75 16.52 12.38c6.1 2.52 13.07 2.63 19.82 2.73c7 .1 14.33.21 17.43 3.32s3.22 10.39 3.32 17.43c.1 6.75.21 13.72 2.73 19.82c2.63 6.35 7.59 11.52 12.38 16.52S224 124 224 128s-4.92 9.15-9.69 14.11m-40.65-43.77a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0" /></g>
</svg>
</span>
</div>
);
}03 · Verified check badge — Measured area 0.231% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
.check {
transform-origin: center;
color: $color; /* the Phosphor duotone fill="currentColor" inherits this value */
}
.stage.is-demo .check {
animation: pop $duration $easing infinite;
}
@keyframes pop {
0%, 50% { transform: scale(1); }
62% { transform: scale(1.28); }
74% { transform: scale(0.94); }
86%, 100% { transform: scale(1); }
}
04Beta gradient pill badge
What you see
A purple gradient layer sits on top of the pill's base color, and only its opacity rises from 0 to 1 and back, so the two colors seem to bleed into each other across the BETA label.

<main class="stage is-demo">
<span class="pill" aria-label="beta feature">
<span class="pill__glow"></span>
<span class="pill__text">BETA</span>
</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 색이 번갈아 드러나는 한 주기
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-orange);
.pill {
position: relative;
display: inline-block;
padding: $sp-2 $sp-6;
border-radius: $r-pill;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 22px;
letter-spacing: .08em;
color: #fff;
background: $color;
box-shadow: $shadow-raised;
overflow: hidden;
}
// 보라 레이어를 겹쳐 두고 opacity 만 오르내린다 — background-position 은
// 리페인트가 스크린샷 타이밍과 안 맞아 프레임 사이 변화가 문턱 아래로 묻혔다 (213 실측)
.pill__glow {
position: absolute; inset: 0;
background: linear-gradient(90deg, $stage-yellow, #c3add7);
opacity: 0;
}
.pill__text {
position: relative;
}
.stage.is-demo .pill__glow {
animation: glow-cross $duration $easing infinite;
}
@keyframes glow-cross {
0%, 10% { opacity: 0; }
50% { opacity: 1; }
90%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pill__glow { animation: none; opacity: .5; }
}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);
}
.pill {
position: relative;
display: inline-block;
padding: 8px 24px;
border-radius: 999px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 22px;
letter-spacing: 0.08em;
color: #fff;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pill__glow {
position: absolute;
inset: 0;
background: linear-gradient(90deg, #ffd23f, #c3add7);
opacity: 0;
}
.pill__text {
position: relative;
}
.stage.is-demo .pill__glow {
animation: glow-cross 2s ease-out infinite;
}
@keyframes glow-cross {
0%, 10% {
opacity: 0;
}
50% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pill__glow {
animation: none;
opacity: 0.5;
}
}import type { CSSProperties } from "react";
import styles from "./BadgeBetaPill.module.scss";
export type BadgeBetaPillProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function BadgeBetaPill({
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
label = "BETA",
}: BadgeBetaPillProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<span className={styles.pill} style={vars} aria-label="beta feature">
<span className={styles.glow} />
<span className={styles.text}>{label}</span>
</span>
);
}04 · Beta gradient pill badge — Measured area 2.951% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
.pill { position: relative; overflow: hidden; }
.pill__glow {
position: absolute; inset: 0;
background: linear-gradient(90deg, $stage-yellow, #a855f7);
opacity: 0;
}
.stage.is-demo .pill__glow {
animation: glow-cross $duration $easing infinite;
}
@keyframes glow-cross {
0%, 10% { opacity: 0; }
50% { opacity: 1; }
90%, 100% { opacity: 0; }
}
05Live pulse dot badge
What happens on screen
A ::before circle behind the dot scales outward while its opacity fades to 0 on a loop, reading like a radar ping broadcasting outward from a live source.

<main class="stage is-demo">
<span class="badge" aria-label="live now">
<span class="dot"></span>
LIVE
</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 핑 한 주기
$easing: $ease-out;
$color: $error;
@include stage($stage-ink);
.badge {
display: inline-flex; align-items: center; gap: $sp-2;
padding: $sp-2 $sp-4;
border-radius: $r-pill;
background: rgba($stage-paper, .08);
color: $stage-paper;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 18px;
letter-spacing: .1em;
}
.dot {
position: relative;
width: $sp-2; height: $sp-2;
border-radius: 50%;
background: $color;
}
.dot::before {
content: "";
position: absolute; inset: 0;
border-radius: 50%;
background: $color;
}
.stage.is-demo .dot::before {
animation: ping $duration $easing infinite;
}
@keyframes ping {
0% { transform: scale(1); opacity: .7; }
70%, 100% { transform: scale(2.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .dot::before { 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);
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 18px;
letter-spacing: 0.1em;
}
.dot {
position: relative;
width: 8px;
height: 8px;
border-radius: 50%;
background: #c2361a;
}
.dot::before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: #c2361a;
}
.stage.is-demo .dot::before {
animation: ping 2s ease-out infinite;
}
@keyframes ping {
0% {
transform: scale(1);
opacity: 0.7;
}
70%, 100% {
transform: scale(2.6);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .dot::before {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./BadgeLivePulseDot.module.scss";
export type BadgeLivePulseDotProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function BadgeLivePulseDot({
duration = 2000,
easing = "ease-out",
color = "#c2361a",
label = "LIVE",
}: BadgeLivePulseDotProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<span className={styles.badge} style={vars} aria-label="live now">
<span className={styles.dot} />
{label}
</span>
);
}05 · Live pulse dot badge — Measured area 0.147% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.dot { position: relative; }
.dot::before {
content: "";
position: absolute; inset: 0;
border-radius: 50%;
background: $color;
}
.stage.is-demo .dot::before {
animation: ping $duration $easing infinite;
}
@keyframes ping {
0% { transform: scale(1); opacity: .7; }
70%, 100% { transform: scale(2.6); opacity: 0; }
}
06Notification count badge
What moves in this notification badge css
The small circle springs from scale(0) past 1.15 and settles at 1, and right at that moment the bell icon itself rocks with a short rotate, signaling a notification just landed.

<main class="stage is-demo">
<div class="bell-wrap" aria-label="5 new notifications">
<svg class="bell" viewBox="0 0 256 256" width="40" height="40" aria-hidden="true">
<g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g>
</svg>
<span class="count">5</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 흔들림+팝 한 주기
$easing: $ease-pop;
$color: $error;
@include stage($stage-ink);
.bell-wrap {
position: relative;
width: 64px; height: 64px;
display: grid; place-items: center;
}
.bell {
transform-origin: 50% 12%; // 규칙8: 종 꼭지점 흔들림
color: $stage-yellow; // Phosphor duotone: g fill="currentColor" 가 이 값을 물려받는다
}
.count {
position: absolute;
top: 2px; right: 0;
min-width: $sp-4; height: $sp-4;
border-radius: $r-pill;
background: $color;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 11px;
display: grid; place-items: center;
padding: 0 $sp-1;
box-shadow: 0 0 0 2px $stage-ink;
transform: scale(0);
}
.stage.is-demo .bell {
animation: shake $duration $easing infinite;
}
.stage.is-demo .count {
animation: pop-in $duration $easing infinite;
}
@keyframes shake {
0%, 55%, 100% { transform: rotate(0deg); }
60% { transform: rotate(-12deg); }
65% { transform: rotate(10deg); }
70% { transform: rotate(-7deg); }
75% { transform: rotate(4deg); }
80% { transform: rotate(0deg); }
}
@keyframes pop-in {
0%, 52% { transform: scale(0); }
62% { transform: scale(1.15); }
74%, 100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bell { animation: none; }
.stage.is-demo .count { animation: none; transform: scale(1); }
}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);
}
.bell-wrap {
position: relative;
width: 64px;
height: 64px;
display: grid;
place-items: center;
}
.bell {
transform-origin: 50% 12%;
color: #ffd23f;
}
.count {
position: absolute;
top: 2px;
right: 0;
min-width: 16px;
height: 16px;
border-radius: 999px;
background: #c2361a;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 700;
font-size: 11px;
display: grid;
place-items: center;
padding: 0 4px;
box-shadow: 0 0 0 2px #17141a;
transform: scale(0);
}
.stage.is-demo .bell {
animation: shake 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .count {
animation: pop-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes shake {
0%, 55%, 100% {
transform: rotate(0deg);
}
60% {
transform: rotate(-12deg);
}
65% {
transform: rotate(10deg);
}
70% {
transform: rotate(-7deg);
}
75% {
transform: rotate(4deg);
}
80% {
transform: rotate(0deg);
}
}
@keyframes pop-in {
0%, 52% {
transform: scale(0);
}
62% {
transform: scale(1.15);
}
74%, 100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bell {
animation: none;
}
.stage.is-demo .count {
animation: none;
transform: scale(1);
}
}import type { CSSProperties } from "react";
import styles from "./BadgeCountNotification.module.scss";
export type BadgeCountNotificationProps = {
duration?: number;
easing?: string;
color?: string;
count?: number;
};
export default function BadgeCountNotification({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#c2361a",
count = 5,
}: BadgeCountNotificationProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.bellWrap} style={vars} aria-label={`${count} new notifications`}>
<svg className={styles.bell} viewBox="0 0 256 256" width={40} height={40} aria-hidden="true">
<g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" /><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" /></g>
</svg>
<span className={styles.count}>{count}</span>
</div>
);
}06 · Notification count badge — Measured area 0.864% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
.count {
transform: scale(0);
}
.stage.is-demo .bell {
animation: shake $duration $easing infinite;
}
.stage.is-demo .count {
animation: pop-in $duration $easing infinite;
}
@keyframes pop-in {
0%, 52% { transform: scale(0); }
62% { transform: scale(1.15); }
74%, 100% { transform: scale(1); }
}
07Trending flame badge
What you see
The flame SVG gets filter: brightness and hue-rotate nudged unevenly frame to frame, so the flicker never settles into a predictable beat, the way real fire doesn't either.

<main class="stage is-demo">
<span class="badge" aria-label="trending">
<svg class="flame" viewBox="0 0 256 256" width="26" height="26" aria-hidden="true">
<g fill="currentColor"><path d="M208 144a80 80 0 0 1-160 0c0-30.57 14.42-58.26 31-80l33 32l26.27-72C159.86 41.92 208 88.15 208 144" opacity=".2"/><path d="M183.89 153.34a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68ZM216 144a88 88 0 0 1-176 0c0-27.92 11-56.47 32.66-84.85a8 8 0 0 1 11.93-.89l24.12 23.41l22-60.41a8 8 0 0 1 12.63-3.41C165.21 36 216 84.55 216 144m-16 0c0-46.09-35.79-85.92-58.21-106.33l-22.27 61.07a8 8 0 0 1-13.09 3L80.06 76.16C64.09 99.21 56 122 56 144a72 72 0 0 0 144 0"/></g>
</svg>
TRENDING
</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 900ms; // 불꽃 한 번 일렁임 — 깜빡임은 빨라야 산다
$easing: $ease-out;
$color: $stage-orange;
@include stage($stage-yellow);
.badge {
display: inline-flex; align-items: center; gap: $sp-2;
padding: $sp-2 $sp-4;
border-radius: $r-pill;
background: $stage-orange;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 16px;
letter-spacing: .06em;
box-shadow: 0 0 $sp-2 rgba($color, .5);
}
.flame {
color: $stage-yellow; // Phosphor duotone: g fill="currentColor" 가 이 값을 물려받는다
filter: brightness(1) hue-rotate(0deg);
}
.stage.is-demo .flame {
animation: flicker $duration $easing infinite alternate;
}
.stage.is-demo .badge {
animation: glow-pulse #{$duration * 2} $easing infinite;
}
// 프레임마다 어긋나는 정지 구간 — 규칙적인 깜빡임이 아니라 진짜 불처럼
@keyframes flicker {
0% { filter: brightness(1) hue-rotate(0deg); transform: scale(1) skewX(0deg); }
18% { filter: brightness(1.25) hue-rotate(-6deg); transform: scale(1.05) skewX(-2deg); }
40% { filter: brightness(.92) hue-rotate(4deg); transform: scale(.97) skewX(1deg); }
63% { filter: brightness(1.15) hue-rotate(-3deg); transform: scale(1.03) skewX(-1deg); }
81% { filter: brightness(.97) hue-rotate(2deg); transform: scale(1) skewX(1deg); }
100% { filter: brightness(1.2) hue-rotate(-5deg); transform: scale(1.04) skewX(-2deg); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 $sp-2 rgba($color, .5); }
50% { box-shadow: 0 0 $sp-6 rgba($color, .85), 0 0 $sp-2 rgba($stage-yellow, .5); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .flame { animation: none; }
.stage.is-demo .badge { animation: 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);
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 999px;
background: #ff4d1f;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 16px;
letter-spacing: 0.06em;
box-shadow: 0 0 8px rgba(255, 77, 31, 0.5);
}
.flame {
color: #ffd23f;
filter: brightness(1) hue-rotate(0deg);
}
.stage.is-demo .flame {
animation: flicker 900ms ease-out infinite alternate;
}
.stage.is-demo .badge {
animation: glow-pulse 1800ms ease-out infinite;
}
@keyframes flicker {
0% {
filter: brightness(1) hue-rotate(0deg);
transform: scale(1) skewX(0deg);
}
18% {
filter: brightness(1.25) hue-rotate(-6deg);
transform: scale(1.05) skewX(-2deg);
}
40% {
filter: brightness(0.92) hue-rotate(4deg);
transform: scale(0.97) skewX(1deg);
}
63% {
filter: brightness(1.15) hue-rotate(-3deg);
transform: scale(1.03) skewX(-1deg);
}
81% {
filter: brightness(0.97) hue-rotate(2deg);
transform: scale(1) skewX(1deg);
}
100% {
filter: brightness(1.2) hue-rotate(-5deg);
transform: scale(1.04) skewX(-2deg);
}
}
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 0 8px rgba(255, 77, 31, 0.5);
}
50% {
box-shadow: 0 0 24px rgba(255, 77, 31, 0.85), 0 0 8px rgba(255, 210, 63, 0.5);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .flame {
animation: none;
}
.stage.is-demo .badge {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./BadgeTrendingFlame.module.scss";
export type BadgeTrendingFlameProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function BadgeTrendingFlame({
duration = 900,
easing = "ease-out",
color = "#ff4d1f",
label = "TRENDING",
}: BadgeTrendingFlameProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<span className={styles.badge} style={vars} aria-label="trending">
<svg className={styles.flame} viewBox="0 0 256 256" width={26} height={26} aria-hidden="true">
<g fill="currentColor"><path d="M208 144a80 80 0 0 1-160 0c0-30.57 14.42-58.26 31-80l33 32l26.27-72C159.86 41.92 208 88.15 208 144" opacity=".2" /><path d="M183.89 153.34a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68ZM216 144a88 88 0 0 1-176 0c0-27.92 11-56.47 32.66-84.85a8 8 0 0 1 11.93-.89l24.12 23.41l22-60.41a8 8 0 0 1 12.63-3.41C165.21 36 216 84.55 216 144m-16 0c0-46.09-35.79-85.92-58.21-106.33l-22.27 61.07a8 8 0 0 1-13.09 3L80.06 76.16C64.09 99.21 56 122 56 144a72 72 0 0 0 144 0" /></g>
</svg>
{label}
</span>
);
}07 · Trending flame badge — Measured area 3.995% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
.stage.is-demo .flame {
animation: flicker $duration $easing infinite alternate;
}
@keyframes flicker {
0% { filter: brightness(1) hue-rotate(0deg); transform: scale(1); }
18% { filter: brightness(1.25) hue-rotate(-6deg); transform: scale(1.05); }
40% { filter: brightness(.92) hue-rotate(4deg); transform: scale(.97); }
63% { filter: brightness(1.15) hue-rotate(-3deg); transform: scale(1.03); }
81% { filter: brightness(.97) hue-rotate(2deg); transform: scale(1); }
100% { filter: brightness(1.2) hue-rotate(-5deg); transform: scale(1.04); }
}
08Limited-stock bar badge
What happens on screen
A thin bar inside the badge shrinks scaleX from 1 toward 0.25 around transform-origin: left, while its background-color shifts from green to orange to red, so length and color drain together.

<main class="stage is-demo">
<div class="badge" aria-label="3 left in stock">
<span class="label">3 LEFT</span>
<span class="track"><span class="fill"></span></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 재고바가 줄어드는 한 주기
$easing: $ease-out;
$color: #50a26e; // 재고 넉넉함(시작 색)
@include stage($stage-paper);
.badge {
width: 148px;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.label {
display: block;
margin-bottom: $sp-2;
color: #fff;
font-weight: 700;
font-size: 15px;
letter-spacing: .04em;
}
.track {
display: block;
width: 100%; height: $sp-2;
border-radius: $r-xs;
background: rgba(255, 255, 255, .15);
overflow: hidden;
}
.fill {
display: block;
width: 100%; height: 100%;
border-radius: $r-xs;
background: $color;
transform: scaleX(1);
transform-origin: left; // 규칙8: 재고게이지 left 기준
}
.stage.is-demo .fill {
animation: drain $duration $easing infinite;
}
@keyframes drain {
0%, 10% { transform: scaleX(1); background: $color; }
50% { transform: scaleX(.6); background: $stage-orange; }
90%, 100% { transform: scaleX(.25); background: $error; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .fill { animation: none; transform: scaleX(.25); background: $error; }
}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);
}
.badge {
width: 148px;
padding: 12px 16px;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.label {
display: block;
margin-bottom: 8px;
color: #fff;
font-weight: 700;
font-size: 15px;
letter-spacing: 0.04em;
}
.track {
display: block;
width: 100%;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.15);
overflow: hidden;
}
.fill {
display: block;
width: 100%;
height: 100%;
border-radius: 4px;
background: #50a26e;
transform: scaleX(1);
transform-origin: left;
}
.stage.is-demo .fill {
animation: drain 2s ease-out infinite;
}
@keyframes drain {
0%, 10% {
transform: scaleX(1);
background: #50a26e;
}
50% {
transform: scaleX(0.6);
background: #ff4d1f;
}
90%, 100% {
transform: scaleX(0.25);
background: #c2361a;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .fill {
animation: none;
transform: scaleX(0.25);
background: #c2361a;
}
}import type { CSSProperties } from "react";
import styles from "./BadgeLimitedStock.module.scss";
export type BadgeLimitedStockProps = {
duration?: number;
easing?: string;
color?: string;
left?: number;
};
export default function BadgeLimitedStock({
duration = 2000,
easing = "ease-out",
color = "#16a34a",
left = 3,
}: BadgeLimitedStockProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.badge} style={vars} aria-label={`${left} left in stock`}>
<span className={styles.label}>{left} LEFT</span>
<span className={styles.track}>
<span className={styles.fill} />
</span>
</div>
);
}08 · Limited-stock bar badge — Measured area 0.817% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.fill {
transform: scaleX(1);
transform-origin: left;
}
.stage.is-demo .fill {
animation: drain $duration $easing infinite;
}
@keyframes drain {
0%, 10% { transform: scaleX(1); background: $color; }
50% { transform: scaleX(.6); background: $stage-orange; }
90%, 100% { transform: scaleX(.25); background: $error; }
}
09Sold-out stripe overlay
What moves
A repeating-linear-gradient of diagonal stripes keeps scrolling across the whole card by animating background-position, while the SOLD OUT stamp in the middle breathes with a slow scale pulse.

<main class="stage is-demo">
<div class="card" aria-label="sold out">
<div class="thumb"></div>
<div class="overlay">
<span class="stamp">SOLD OUT</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 줄무늬 한 바퀴
$easing: linear;
$color: $stage-ink;
@include stage($stage-paper);
.card {
position: relative;
width: 220px; height: 140px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.thumb {
position: absolute; inset: 0;
background: linear-gradient(135deg, rgba($subject-blue, .25), rgba($subject-blue, .08));
}
.overlay {
position: absolute; inset: 0;
display: grid; place-items: center;
background:
repeating-linear-gradient(
-45deg,
rgba($color, .82) 0 $sp-4,
rgba($color, .62) $sp-4 #{$sp-4 * 2}
);
background-size: #{$sp-4 * 4} #{$sp-4 * 4};
}
.stamp {
padding: $sp-2 $sp-4;
border: 2px solid #fff;
border-radius: $r-sm;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 16px;
letter-spacing: .1em;
transform: scale(1) rotate(-6deg);
}
.stage.is-demo .overlay {
animation: stripes-flow $duration $easing infinite;
}
.stage.is-demo .stamp {
animation: stamp-breathe #{$duration} ease-in-out infinite;
}
@keyframes stripes-flow {
0% { background-position: 0 0; }
100% { background-position: #{$sp-4 * 4} 0; }
}
@keyframes stamp-breathe {
0%, 100% { transform: scale(1) rotate(-6deg); }
50% { transform: scale(1.08) rotate(-6deg); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay { animation: none; }
.stage.is-demo .stamp { 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);
}
.card {
position: relative;
width: 220px;
height: 140px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.thumb {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(47, 109, 246, 0.25), rgba(47, 109, 246, 0.08));
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: repeating-linear-gradient(-45deg, rgba(23, 20, 26, 0.82) 0 16px, rgba(23, 20, 26, 0.62) 16px 32px);
background-size: 64px 64px;
}
.stamp {
padding: 8px 16px;
border: 2px solid #fff;
border-radius: 8px;
color: #fff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-weight: 800;
font-size: 16px;
letter-spacing: 0.1em;
transform: scale(1) rotate(-6deg);
}
.stage.is-demo .overlay {
animation: stripes-flow 2s linear infinite;
}
.stage.is-demo .stamp {
animation: stamp-breathe 2s ease-in-out infinite;
}
@keyframes stripes-flow {
0% {
background-position: 0 0;
}
100% {
background-position: 64px 0;
}
}
@keyframes stamp-breathe {
0%, 100% {
transform: scale(1) rotate(-6deg);
}
50% {
transform: scale(1.08) rotate(-6deg);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay {
animation: none;
}
.stage.is-demo .stamp {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./BadgeSoldOutOverlay.module.scss";
export type BadgeSoldOutOverlayProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
};
export default function BadgeSoldOutOverlay({
duration = 2000,
easing = "linear",
color = "#17141a",
label = "SOLD OUT",
}: BadgeSoldOutOverlayProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={styles.card} style={vars} aria-label="sold out">
<div className={styles.thumb} />
<div className={styles.overlay}>
<span className={styles.stamp}>{label}</span>
</div>
</div>
);
}09 · Sold-out stripe overlay — Measured area 21.144% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
.overlay {
background: repeating-linear-gradient(-45deg,
rgba($color, .82) 0 $sp-4,
rgba($color, .62) $sp-4 #{$sp-4 * 2});
background-size: #{$sp-4 * 4} #{$sp-4 * 4};
}
.stage.is-demo .overlay {
animation: stripes-flow $duration $easing infinite;
}
@keyframes stripes-flow {
0% { background-position: 0 0; }
100% { background-position: #{$sp-4 * 4} 0; }
}
Where this breaks — motion that leans on repaint vanishes on camera
Badge 04's gradient sheen originally moved by sliding background-position. To the eye it clearly flowed, but comparing the same stage frame by frame showed almost no difference between consecutive frames — background-position leaves its repaint timing entirely to the browser, and when that falls out of step with the capture, the change sinks below the threshold. Layering a purple sheet on top and animating only its opacity from 0 to 1 kept the same effect sharp in every frame. Pick properties that run on the compositor, like opacity and transform, over ones that need a repaint, like background position or a spreading shadow, and what you see stops disagreeing with what the recording keeps. Filling the screen with pencil marks instead of color is the corner where Crayon Web Design lives. The password for the zip is wpxqsgtk, typed exactly as shown, and it opens all nine folders at once. Badge 03's verified check went the same way — instead of drawing a stroke in, the whole icon pops with transform: scale.
Accessibility
All nine drop to animation: none under prefers-reduced-motion: reduce. Badges that loop forever, like 07's flame flicker and 09's stripe overlay, still read correctly at rest — color and shape carry the meaning on their own. Badge 03's checkmark simply stays at its natural scale(1) when motion is off, so the verified mark itself never disappears. The full spec for this setting lives in MDN's prefers-reduced-motion page.
FAQ
Can I build this notification badge html css without any image files?
Yes — all nine use only CSS, plus a short inline SVG icon for badges 03, 06, and 07. Backgrounds, shadows, and clip-path shapes are enough to draw ribbons, tag notches, and diagonal stripes. More CSS-only decorations turn up under site search if you look for "badge". The work-a-day angle — status and priority labels rather than shopper hooks — is collected in Status Badge Design UI.
Can I swap these colors for my own brand?
Yes, for most of them. Every demo keeps a single $color variable at the top of its SCSS (03's check icon is fill="currentColor", so it inherits that same color), and the React version exposes the same thing as a color prop — change that one value and the whole badge follows. Badge 08, which shifts color across several stages, needs each stage inside its @keyframes updated too.
My badge animation looks washed out in a screen recording. Why?
Change which property moves. Background position and spreading shadows need a repaint, so the browser decides when they land and that can fall out of step with the capture, while opacity and transform run on the compositor and survive frame by frame. First time on this site? The about page explains how these demos get built and checked before anything ships.









