ステータス バッジ UI 9選 — タグ・優先度・認証バッジ
ステータス バッジ ui は、オンライン・優先度・認証といった状態を小さな点やラベル一つで伝える部品です。動きを加えるとそのまま使えるほど目を引きます。ステータス バッジ ui のアイデアを9つ集めました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ステータスピル(オンライン・退席・取り込み中・オフライン)
- 02 削除ボタン付きタグチップ
- 03 優先度ラベルバッジ(低・中・高・緊急)
- 04 折りたたみリボンの展開バッジ
- 05 通知ドットのダブルパルス
- 06 技術スタックタグクラウド
- 07 実績バッジのロック解除アニメーション
- 08 プレミアム・認証バッジの反転
- 09 期限間近カウントダウンチップ
9つは人気順ではなく、実際の画面で出会う順番に並べました。ログインするとまずチームメンバーのオンライン状態(01)を見て、興味のあるタグを選んだり消したり(02)し、タスクに優先度(03)を付けます。閲覧しているとセールリボン(04)に出会い、通知 ui デザインとしての新着通知(05)が届きます。プロフィールには技術スタック(06)や達成したばかりの実績バッジ(07)、認証バッジ(08)が並び、最後にクーポンの期限カウントダウン(09)が残ります。バッジが付いた後に起きる入力判定はCSS バリデーション アニメーションが担当し、初めて来た人にそのバッジの意味を伝える仕掛けはチュートリアル UI デザインにまとめてあります。
01ステータスピル(オンライン・退席・取り込み中・オフライン)
ラベル デザイン cssとして何が見えるか
オンラインドットの背後に隠れたリングがopacityとscaleで一度だけ広がり、残り3つの状態は色が違うだけで、動きません。そのリングの3分の1周期遅れて2番目の::after波紋が追って広がり、ピル背景も一瞬グリーンに染まるので、状態が変わったばかりなことを2つの層で知らせます。

<main class="stage is-demo">
<ul class="pills" aria-label="ステータス一覧">
<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 · ステータスピル(オンライン・退席・取り込み中・オフライン) — 計測 累積面積 3.128% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
.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); }
}
02削除ボタン付きタグチップ
画面で起きること
チップの×ボタンを押すとscaleとopacityで縮みながら実際に消え、デモでは同じ動きを一つのチップが自動で繰り返します。チップが縮む瞬間、::afterの境界リングが同じ場所から外へ広がり、消えた跡に余韻のような波紋を残します。

<main class="stage is-demo">
<ul class="chips" aria-label="選択したカテゴリ">
<li class="chip" data-chip>
<span>Design</span>
<button class="chip__remove" type="button" aria-label="Designタグを削除">
<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="Marketingタグを削除">
<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="Remoteタグを削除">
<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 · 削除ボタン付きタグチップ — 計測 累積面積 2.362% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
.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; }
}
03優先度ラベルバッジ(低・中・高・緊急)
どんな動きか
緊急ラベルだけがopacityとscaleで点滅するため、色だけが違う低・中・高の中でも最初に目に留まります。点滅が最も強い瞬間、::afterの赤いリングがラベルの外へ波紋のように広がり、文字色も一トーン明るくなって戻るので、警告が繰り返されていることを色でも重ねます。

<main class="stage is-demo">
<ul class="labels" aria-label="優先度一覧">
<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 · 優先度ラベルバッジ(低・中・高・緊急) — 計測 累積面積 1.069% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
.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); }
}
04折りたたみリボンの展開バッジ
何が見えるか
カード上端で折りたたまれたリボンがtransform-origin: topを基準にscaleYで開き、また巻き上がります。開ききった瞬間、リボンの境界に沿ってコーラルのリングが外へ広がり、背景も一瞬明るくなって元の色へ戻るため、着地を2回確認できます。

<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 · 折りたたみリボンの展開バッジ — 計測 累積面積 1.443% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ほかの回で紹介した、光の帯が流れるリボンとは動き方が異なります。
.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); }
}
05通知ドットのダブルパルス
画面で起きること
アイコンは動かさず、角のドットだけがscaleで素早く2回跳ね、メッセージが続けて届いたようなリズムを作ります。2回目の跳ねでドットの色が一トーン明るくなり、3番目の波紋リングが最初の2つを追って広がって、通知が重なるリズムを視覚的に足します。

<main class="stage is-demo">
<div class="icon-wrap" aria-label="新着メッセージあり">
<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 · 通知ドットのダブルパルス — 計測 累積面積 0.648% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
.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); }
}
06技術スタックタグクラウド
どんな動きか
6つのスキルタグがopacityとtranslateYで順番に浮かび上がっては消えることを繰り返し、タグごとの遅延だけで波打つような登場を見せます。

<main class="stage is-demo">
<ul class="cloud" aria-label="技術スタック">
<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 · 技術スタックタグクラウド — 計測 累積面積 8.515% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
.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); }
}
07実績バッジのロック解除アニメーション
何が見えるか
くすんだトロフィーがscaleで弾みながら色を取り戻す瞬間、対角線の光が一度だけ通り過ぎて達成を強調します。

<main class="stage is-demo">
<div class="badge" aria-label="実績解除 — はじめの一歩">
<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 · 実績バッジのロック解除アニメーション — 計測 累積面積 7.692% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
.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); }
}
08プレミアム・認証バッジの反転
画面で起きること
カード全体がrotateYで3D回転し、金色のPremium面と青のVerified面を交互に見せます。

<main class="stage is-demo">
<div class="flip" aria-label="プレミアム認証アカウント">
<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 · プレミアム・認証バッジの反転 — 計測 累積面積 4.885% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
.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); }
}
09期限間近カウントダウンチップ
どんな動きか
秒の桁の数字10個を縦に並べ、translateYで一段ずつ送ることで、機械式フリップカウンターのように数字がめくれます。

<main class="stage is-demo">
<div class="chip" aria-label="12分後に終了">
<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">締切間近 · 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分</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 · 期限間近カウントダウンチップ — 計測 累積面積 3.926% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
.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); }
}
どこで壊れるか — 数字を文中に詰め込むと崩れます
カウントダウンチップの一の位の数字は、最初は周囲の文字と同じ行の中でfont-sizeだけを大きくして入れていました。すると数字がチップの外にはみ出し、背景の上に乗ってしまいました。原因は、チップがinline-flexで行の高さを周囲のテキストに合わせて決めており、overflow: hiddenがないと大きな文字がそのままチップの外側にはみ出してしまうことにあります。数字を固定サイズ(18×26px)の独立した箱に切り離し、チップ自体にもoverflow: hiddenを掛けたことで、数字は常にチップの中でだけ切り替わるようになりました。同じ発光表現をセクションの大きさまで広げると、ai ランディングページ 光るオーブ デザインキットのにじみ計算とそろいます。解凍時に聞かれるパスワードはcwww6ahbで、届いたファイル名のまま入力すると9つのフォルダがそのまま開きます。08番の3D回転バッジも同様の落とし穴があり、両面にbackface-visibility: hiddenを付け忘れると、回転中に裏面の文字が鏡写しのまま透けて見えます。
アクセシビリティ
9つともprefers-reduced-motion: reduceでアニメーションが止まり、最終状態のまま固定されます。01番のオンラインリングはリングが消えてドットだけが残り、07番の実績バッジは解除後の色のまま止まり、09番のカウントダウン数字もそれ以上は進みません。動きが止まっても色と形だけで状態が分かるように設計しています。設定方法はMDNのprefers-reduced-motionドキュメントにまとまっています。
FAQ
github ステータス バッジのようにバッジの色は自由に変えられますか?
はい、ほとんどのデモはSCSS冒頭の$color一つを変えるだけで色が変わります。08番のプレミアム・認証バッジは例外で、両面それぞれのグラデーションを直す必要があります。ほかのバッジのアイデアはバッジデザインUI 9選でご覧いただけます。NEWやSALEのように状態ではなく宣伝を担うバッジはcss バッジ デザインに別途九つあります。
削除できるタグチップにJavaScriptは必要ですか?
CSSだけでは要素の削除はできません。02番のタグチップにはクリックで実際に削除するバニラJSが10行ほど付いています。React版では同じ処理をuseStateフックで管理しています。
カウントダウンの数字は実際の時間を計測していますか?
いいえ、動きを見せるためのループで、実際の時計とは連動していません。本当に残り時間を数えるには、setIntervalで目標時刻との差を計算し、数字だけ差し替えます。制作の様子は紹介ページでご覧いただけます。









