9 CSS Confetti Animation & Sparkle Effects
A confetti animation is the small colored burst a screen fires when something succeeds — a finished signup, a completed order — and a css confetti animation
01 confetti-celebration-confetti-burst-button
02 confetti-celebration-confetti-rain
03 confetti-celebration-sparkle-button
04 confetti-celebration-sparkle-text
05 confetti-celebration-firework-dots
06 confetti-celebration-emoji-pop
07 confetti-celebration-pseudo-only-confetti
08 confetti-celebration-has-checked-celebrate
09 confetti-celebration-sparkle-cursor-trail
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Confetti burst on button
- 02 Confetti rain
- 03 Sparkle button
- 04 Sparkle text
- 05 Firework dots
- 06 Emoji pop
- 07 Pseudo-element-only confetti
- 08 Celebrate trigger via :has(:checked)
- 09 Sparkle cursor trail
These nine aren't ranked by how flashy they look — they follow how a screen actually earns each kind of celebration. First comes the payoff for an action someone just finished (01), then a piece that keeps celebrating on its own in the background (02), then two accents that draw the eye toward a button or a headline (03–04), then two showier bursts that launch from a single point (05–06), then two builds that skip extra markup or JavaScript entirely (07–08), and last, one that follows wherever the cursor goes (09). For confetti drifting behind a translucent panel, the glassmorphism effects supply the backdrop, and for paper that has to bounce, the bounce animations supply the fall.
01Confetti burst on button
When to use it
Press a button right after a signup or checkout finishes, and twelve small pieces launch out from behind it in every direction, spinning as they travel, then fade away. An SCSS @for loop hands each of the twelve its own angle, travel distance, and start delay, so none of them move in lockstep.

<main class="stage">
<div class="cb-wrap is-demo">
<button class="cb-btn" type="button">Signed up</button>
<span class="cb-piece"></span><span class="cb-piece"></span><span class="cb-piece"></span>
<span class="cb-piece"></span><span class="cb-piece"></span><span class="cb-piece"></span>
<span class="cb-piece"></span><span class="cb-piece"></span><span class="cb-piece"></span>
<span class="cb-piece"></span><span class="cb-piece"></span><span class="cb-piece"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 900ms;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
$piece-colors: $color, $stage-yellow, $stage-orange, $subject-cream;
.cb-wrap {
position: relative;
width: min(220px, 80vw);
display: grid;
place-items: center;
}
.cb-btn {
position: relative;
z-index: 1;
border: 0;
border-radius: $r-control;
padding: $sp-3 $sp-6;
background: $subject-blue-deep;
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
}
.cb-piece {
position: absolute;
top: 50%;
left: 50%;
width: 9px;
height: 9px;
border-radius: $r-xs;
opacity: 0;
pointer-events: none;
}
// 조각마다 각도·거리·색·지연을 SCSS @for 로 흩뿌린다 — 마크업은 빈 span 12개뿐
@for $i from 1 through 12 {
.cb-piece:nth-child(#{$i + 1}) {
background: nth($piece-colors, ($i - 1) % 4 + 1);
--angle: #{$i * 30deg};
--dist: #{64px + ($i % 3) * 22px};
animation-delay: #{($i % 4) * 40ms};
}
}
@keyframes cb-fly {
0% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(.4); opacity: 0; }
15% { opacity: 1; }
100% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(-1 * var(--dist))) scale(1); opacity: 0; }
}
// 실사용: 클릭이 is-burst 를 다시 붙여 keyframes 를 재생한다 (위 <script>)
.cb-wrap.is-burst .cb-piece,
.cb-wrap.is-demo .cb-piece { animation: cb-fly $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.cb-wrap.is-burst .cb-piece,
.cb-wrap.is-demo .cb-piece { animation: none !important; opacity: 0 !important; }
}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);
}
.cb-wrap {
position: relative;
width: min(220px, 80vw);
display: grid;
place-items: center;
}
.cb-btn {
position: relative;
z-index: 1;
border: 0;
border-radius: 12px;
padding: 12px 24px;
background: #0a4fe8;
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
}
.cb-piece {
position: absolute;
top: 50%;
left: 50%;
width: 9px;
height: 9px;
border-radius: 4px;
opacity: 0;
pointer-events: none;
}
.cb-piece:nth-child(2) {
background: #2f6df6;
--angle: 30deg;
--dist: 86px;
animation-delay: 40ms;
}
.cb-piece:nth-child(3) {
background: #ffd23f;
--angle: 60deg;
--dist: 108px;
animation-delay: 80ms;
}
.cb-piece:nth-child(4) {
background: #ff4d1f;
--angle: 90deg;
--dist: 64px;
animation-delay: 120ms;
}
.cb-piece:nth-child(5) {
background: #fff7e6;
--angle: 120deg;
--dist: 86px;
animation-delay: 0ms;
}
.cb-piece:nth-child(6) {
background: #2f6df6;
--angle: 150deg;
--dist: 108px;
animation-delay: 40ms;
}
.cb-piece:nth-child(7) {
background: #ffd23f;
--angle: 180deg;
--dist: 64px;
animation-delay: 80ms;
}
.cb-piece:nth-child(8) {
background: #ff4d1f;
--angle: 210deg;
--dist: 86px;
animation-delay: 120ms;
}
.cb-piece:nth-child(9) {
background: #fff7e6;
--angle: 240deg;
--dist: 108px;
animation-delay: 0ms;
}
.cb-piece:nth-child(10) {
background: #2f6df6;
--angle: 270deg;
--dist: 64px;
animation-delay: 40ms;
}
.cb-piece:nth-child(11) {
background: #ffd23f;
--angle: 300deg;
--dist: 86px;
animation-delay: 80ms;
}
.cb-piece:nth-child(12) {
background: #ff4d1f;
--angle: 330deg;
--dist: 108px;
animation-delay: 120ms;
}
.cb-piece:nth-child(13) {
background: #fff7e6;
--angle: 360deg;
--dist: 64px;
animation-delay: 0ms;
}
@keyframes cb-fly {
0% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(0.4);
opacity: 0;
}
15% {
opacity: 1;
}
100% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(-1 * var(--dist))) scale(1);
opacity: 0;
}
}
.cb-wrap.is-burst .cb-piece,
.cb-wrap.is-demo .cb-piece {
animation: cb-fly 900ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.cb-wrap.is-burst .cb-piece,
.cb-wrap.is-demo .cb-piece {
animation: none !important;
opacity: 0 !important;
}
}var wrap = document.querySelector('.cb-wrap');
var btn = document.querySelector('.cb-btn');
btn.addEventListener('click', function () {
wrap.classList.remove('is-burst');
void wrap.offsetWidth;
wrap.classList.add('is-burst');
});import { CSSProperties, useState } from "react";
import styles from "./ConfettiBurstButton.module.scss";
type Vars = CSSProperties & Record<string, string>;
export interface ConfettiBurstButtonProps {
duration?: string;
easing?: string;
color?: string;
}
const COLORS = ["var(--color)", "#ffd23f", "#ff4d1f", "#fff7e6"];
// 실사용: 클릭마다 burstKey 를 올려 pieces 래퍼를 리마운트한다 — 같은 클래스를 다시 붙이는 것만으로는
// 이미 끝난 keyframes 가 재생되지 않는다 (바닐라판은 classList remove+reflow+add 로 같은 일을 한다)
export default function ConfettiBurstButton({
duration = "900ms",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: ConfettiBurstButtonProps) {
const [burstKey, setBurstKey] = useState(0);
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={style}>
<button className={styles.btn} type="button" onClick={() => setBurstKey((k) => k + 1)}>
가입 완료
</button>
<span key={burstKey} className={styles.pieces}>
{Array.from({ length: 12 }, (_, i) => {
const pieceStyle: Vars = {
background: COLORS[i % COLORS.length],
"--angle": `${(i + 1) * 30}deg`,
"--dist": `${64 + (i % 3) * 22}px`,
animationDelay: `${(i % 4) * 40}ms`,
};
return <span key={i} className={styles.piece} style={pieceStyle} />;
})}
</span>
</div>
);
}01 · Confetti burst on button — Measured area 2.569% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
Pressing the button again replays the whole burst through 7 lines of plain JavaScript that just re-adds the animation class.
@for $i from 1 through 12 {
.cb-piece:nth-child(#{$i + 1}) {
background: nth($piece-colors, ($i - 1) % 4 + 1);
--angle: #{$i * 30deg};
--dist: #{64px + ($i % 3) * 22px};
animation-delay: #{($i % 4) * 40ms};
}
}
@keyframes cb-fly {
0% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(.4); opacity: 0; }
15% { opacity: 1; }
100% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(-1 * var(--dist))) scale(1); opacity: 0; }
}
02Confetti rain
What you see
Above a banner announcing a win, eighteen pieces of different sizes keep falling non-stop, each drifting down at its own pace so the scene reads as scattered rain rather than one falling column.

<main class="stage">
<div class="cr-wrap is-demo">
<span class="cr-banner">Congrats on your prize</span>
<span class="cr-piece"></span><span class="cr-piece"></span><span class="cr-piece"></span>
<span class="cr-piece"></span><span class="cr-piece"></span><span class="cr-piece"></span>
<span class="cr-piece"></span><span class="cr-piece"></span><span class="cr-piece"></span>
<span class="cr-piece"></span><span class="cr-piece"></span><span class="cr-piece"></span>
<span class="cr-piece"></span><span class="cr-piece"></span><span class="cr-piece"></span>
<span class="cr-piece"></span><span class="cr-piece"></span><span class="cr-piece"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s;
$easing: linear;
$color: $subject-blue;
@include stage($stage-yellow);
$piece-colors: $color, $stage-orange, $subject-ink, $subject-cream;
.cr-wrap {
position: relative;
width: min(320px, 88vw);
height: min(220px, 62vh);
overflow: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .06);
}
.cr-banner {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: $sp-4;
box-sizing: border-box;
text-align: center;
color: $subject-ink;
font: 700 18px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cr-piece {
position: absolute;
top: -220px;
width: 9px;
height: 16px;
border-radius: $r-xs;
opacity: .95;
animation-duration: $duration;
}
// 조각 18개가 카드 높이 전체에 걸쳐 서로 다른 top 시작값을 갖는다 — delay 만 다르면 전부 위쪽에서
// 한 줄로 뭉쳐 같이 출발해 버린다 (실측). top 순서를 왼쪽 순서(left%)와 다르게 섞어야 대각선 한 줄이
// 아니라 흩뿌린 비처럼 보인다 (실측: left·top 을 같은 $i 로 선형 배치하면 완벽한 대각선이 된다)
$tops: -40px, 160px, -180px, 20px, 100px, -120px, 60px, -200px, 140px, -20px, 180px, -100px, 40px, -160px, 120px, -60px, 200px, -140px;
@for $i from 1 through 18 {
.cr-piece:nth-child(#{$i + 1}) {
left: percentage($i / 19);
top: nth($tops, $i);
background: nth($piece-colors, $i % 4 + 1);
animation-duration: #{1700ms + ($i % 5) * 140ms};
}
}
@keyframes cr-fall {
0% { transform: translateY(0) rotate(0deg); }
100% { transform: translateY(520px) rotate(300deg); }
}
// duration 은 shorthand 로 주지 않는다 — 그러면 specificity 가 더 높아 항목마다 다른 animation-duration(위) 을
// 덮어써 18개가 전부 같은 박자로 떨어져 버린다 (실측). name·timing·iteration 만 여기서, duration 은 조각마다
.cr-wrap.is-demo .cr-piece { animation-name: cr-fall; animation-timing-function: $easing; animation-iteration-count: infinite; }
@media (prefers-reduced-motion: reduce) {
.cr-wrap.is-demo .cr-piece { animation: none !important; opacity: 0 !important; }
}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);
}
.cr-wrap {
position: relative;
width: min(320px, 88vw);
height: min(220px, 62vh);
overflow: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
}
.cr-banner {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
box-sizing: border-box;
text-align: center;
color: #17141a;
font: 700 18px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cr-piece {
position: absolute;
top: -220px;
width: 9px;
height: 16px;
border-radius: 4px;
opacity: 0.95;
animation-duration: 2s;
}
.cr-piece:nth-child(2) {
left: 5.2631578947%;
top: -40px;
background: #ff4d1f;
animation-duration: 1840ms;
}
.cr-piece:nth-child(3) {
left: 10.5263157895%;
top: 160px;
background: #17141a;
animation-duration: 1980ms;
}
.cr-piece:nth-child(4) {
left: 15.7894736842%;
top: -180px;
background: #fff7e6;
animation-duration: 2120ms;
}
.cr-piece:nth-child(5) {
left: 21.0526315789%;
top: 20px;
background: #2f6df6;
animation-duration: 2260ms;
}
.cr-piece:nth-child(6) {
left: 26.3157894737%;
top: 100px;
background: #ff4d1f;
animation-duration: 1700ms;
}
.cr-piece:nth-child(7) {
left: 31.5789473684%;
top: -120px;
background: #17141a;
animation-duration: 1840ms;
}
.cr-piece:nth-child(8) {
left: 36.8421052632%;
top: 60px;
background: #fff7e6;
animation-duration: 1980ms;
}
.cr-piece:nth-child(9) {
left: 42.1052631579%;
top: -200px;
background: #2f6df6;
animation-duration: 2120ms;
}
.cr-piece:nth-child(10) {
left: 47.3684210526%;
top: 140px;
background: #ff4d1f;
animation-duration: 2260ms;
}
.cr-piece:nth-child(11) {
left: 52.6315789474%;
top: -20px;
background: #17141a;
animation-duration: 1700ms;
}
.cr-piece:nth-child(12) {
left: 57.8947368421%;
top: 180px;
background: #fff7e6;
animation-duration: 1840ms;
}
.cr-piece:nth-child(13) {
left: 63.1578947368%;
top: -100px;
background: #2f6df6;
animation-duration: 1980ms;
}
.cr-piece:nth-child(14) {
left: 68.4210526316%;
top: 40px;
background: #ff4d1f;
animation-duration: 2120ms;
}
.cr-piece:nth-child(15) {
left: 73.6842105263%;
top: -160px;
background: #17141a;
animation-duration: 2260ms;
}
.cr-piece:nth-child(16) {
left: 78.9473684211%;
top: 120px;
background: #fff7e6;
animation-duration: 1700ms;
}
.cr-piece:nth-child(17) {
left: 84.2105263158%;
top: -60px;
background: #2f6df6;
animation-duration: 1840ms;
}
.cr-piece:nth-child(18) {
left: 89.4736842105%;
top: 200px;
background: #ff4d1f;
animation-duration: 1980ms;
}
.cr-piece:nth-child(19) {
left: 94.7368421053%;
top: -140px;
background: #17141a;
animation-duration: 2120ms;
}
@keyframes cr-fall {
0% {
transform: translateY(0) rotate(0deg);
}
100% {
transform: translateY(520px) rotate(300deg);
}
}
.cr-wrap.is-demo .cr-piece {
animation-name: cr-fall;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.cr-wrap.is-demo .cr-piece {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./ConfettiRain.module.scss";
type Vars = CSSProperties & Record<string, string>;
export interface ConfettiRainProps {
duration?: string;
easing?: string;
color?: string;
}
const COLORS = ["var(--color)", "#ff4d1f", "#17141a", "#fff7e6"];
// top 을 left% 순서와 다르게 섞는다 — 둘을 같은 인덱스로 선형 배치하면 낙하가 아니라 대각선 한 줄로 보인다
const TOPS = [-40, 160, -180, 20, 100, -120, 60, -200, 140, -20, 180, -100, 40, -160, 120, -60, 200, -140];
export default function ConfettiRain({
duration = "2s",
easing = "linear",
color = "#2f6df6",
}: ConfettiRainProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={style}>
<span className={styles.banner}>당첨을 축하드려요</span>
{TOPS.map((top, i) => {
const pieceStyle: Vars = {
left: `${((i + 1) / (TOPS.length + 1)) * 100}%`,
top: `${top}px`,
background: COLORS[i % COLORS.length],
animationDuration: `${1700 + (i % 5) * 140}ms`,
};
return <span key={i} className={styles.piece} style={pieceStyle} />;
})}
</div>
);
}02 · Confetti rain — Measured area 12.233% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
Every piece gets its own animation-duration from an SCSS loop, and each one also starts from a different height that's deliberately out of step with its left-to-right position — lining the two up the same way made every piece leave at once along a single diagonal band instead of scattering.
$tops: -40px, 160px, -180px, 20px, 100px, -120px, 60px, -200px, 140px, -20px, 180px, -100px, 40px, -160px, 120px, -60px, 200px, -140px;
@for $i from 1 through 18 {
.cr-piece:nth-child(#{$i + 1}) {
left: percentage($i / 19);
top: nth($tops, $i);
background: nth($piece-colors, $i % 4 + 1);
animation-duration: #{1700ms + ($i % 5) * 140ms};
}
}
@keyframes cr-fall {
0% { transform: translateY(0) rotate(0deg); }
100% { transform: translateY(520px) rotate(300deg); }
}
03Sparkle button
Where it fits
Hover a premium or upgrade button and four small stars around its edge blink in and out one after another, each on its own animation-delay so they never flash together.

<main class="stage">
<div class="sk-wrap is-demo">
<button class="sk-btn" type="button">Upgrade to Premium</button>
<span class="sk-star sk-star--1">✦</span>
<span class="sk-star sk-star--2">✦</span>
<span class="sk-star sk-star--3">✦</span>
<span class="sk-star sk-star--4">✦</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.1s;
$easing: $ease-out;
$color: $stage-yellow;
@include stage($stage-orange);
.sk-wrap {
position: relative;
width: min(240px, 82vw);
display: grid;
place-items: center;
}
.sk-btn {
position: relative;
z-index: 1;
border: 0;
border-radius: $r-control;
padding: $sp-3 $sp-6;
background: $subject-ink;
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
transition: transform $dur-quick $easing;
}
.sk-wrap:hover .sk-btn { transform: scale(1.03); }
.sk-star {
position: absolute;
color: $color;
font-size: 16px;
line-height: 1;
opacity: 0;
transform: scale(.3);
pointer-events: none;
}
.sk-star--1 { top: -6px; left: 6%; }
.sk-star--2 { top: -14px; right: 10%; }
.sk-star--3 { bottom: -10px; left: 16%; }
.sk-star--4 { bottom: -4px; right: 4%; }
@keyframes sk-twinkle {
0%, 100% { opacity: 0; transform: scale(.3) rotate(0deg); }
50% { opacity: 1; transform: scale(1) rotate(90deg); }
}
// 실사용: 호버 중에만 반짝인다. 캡처용 is-demo 는 항상 켠다
.sk-wrap:hover .sk-star,
.sk-wrap.is-demo .sk-star { animation: sk-twinkle $duration $easing infinite; }
.sk-star--2 { animation-delay: 160ms; }
.sk-star--3 { animation-delay: 320ms; }
.sk-star--4 { animation-delay: 480ms; }
@media (prefers-reduced-motion: reduce) {
.sk-wrap:hover .sk-star,
.sk-wrap.is-demo .sk-star { animation: none !important; opacity: 0 !important; }
}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);
}
.sk-wrap {
position: relative;
width: min(240px, 82vw);
display: grid;
place-items: center;
}
.sk-btn {
position: relative;
z-index: 1;
border: 0;
border-radius: 12px;
padding: 12px 24px;
background: #17141a;
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
transition: transform 220ms ease-out;
}
.sk-wrap:hover .sk-btn {
transform: scale(1.03);
}
.sk-star {
position: absolute;
color: #ffd23f;
font-size: 16px;
line-height: 1;
opacity: 0;
transform: scale(0.3);
pointer-events: none;
}
.sk-star--1 {
top: -6px;
left: 6%;
}
.sk-star--2 {
top: -14px;
right: 10%;
}
.sk-star--3 {
bottom: -10px;
left: 16%;
}
.sk-star--4 {
bottom: -4px;
right: 4%;
}
@keyframes sk-twinkle {
0%, 100% {
opacity: 0;
transform: scale(0.3) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(90deg);
}
}
.sk-wrap:hover .sk-star,
.sk-wrap.is-demo .sk-star {
animation: sk-twinkle 1.1s ease-out infinite;
}
.sk-star--2 {
animation-delay: 160ms;
}
.sk-star--3 {
animation-delay: 320ms;
}
.sk-star--4 {
animation-delay: 480ms;
}
@media (prefers-reduced-motion: reduce) {
.sk-wrap:hover .sk-star,
.sk-wrap.is-demo .sk-star {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./SparkleButton.module.scss";
type Vars = CSSProperties & Record<string, string>;
export interface SparkleButtonProps {
duration?: string;
easing?: string;
color?: string;
}
// 실사용: 진짜 :hover 중에만 반짝인다 (module.scss 의 .wrap:hover). props 는 변수 3개만 넘긴다
export default function SparkleButton({
duration = "1.1s",
easing = "ease-out",
color = "#ffd23f",
}: SparkleButtonProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={style}>
<button className={styles.btn} type="button">
프리미엄 업그레이드
</button>
<span className={`${styles.star} ${styles.star1}`}>✦</span>
<span className={`${styles.star} ${styles.star2}`}>✦</span>
<span className={`${styles.star} ${styles.star3}`}>✦</span>
<span className={`${styles.star} ${styles.star4}`}>✦</span>
</div>
);
}03 · Sparkle button — Measured area 0.213% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
Move the mouse away and they simply stop, with no lingering fade-out.
.sk-star {
position: absolute;
color: $color;
font-size: 16px;
line-height: 1;
opacity: 0;
transform: scale(.3);
pointer-events: none;
}
.sk-star--1 { top: -6px; left: 6%; }
.sk-star--2 { top: -14px; right: 10%; }
.sk-star--3 { bottom: -10px; left: 16%; }
.sk-star--4 { bottom: -4px; right: 4%; }
@keyframes sk-twinkle {
0%, 100% { opacity: 0; transform: scale(.3) rotate(0deg); }
50% { opacity: 1; transform: scale(1) rotate(90deg); }
}
04Sparkle text
What moves
Around a celebration headline, three small sparkles blink on their own timing, pulling attention to the words themselves rather than to a button nearby.

<main class="stage">
<div class="st-wrap is-demo">
<h2 class="st-title">Congratulations!</h2>
<span class="st-star st-star--1">✦</span>
<span class="st-star st-star--2">✦</span>
<span class="st-star st-star--3">✦</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.3s;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.st-wrap {
position: relative;
display: grid;
place-items: center;
padding: $sp-6;
}
.st-title {
margin: 0;
color: $subject-ink;
font: 800 30px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.st-star {
position: absolute;
color: $color;
font-size: 18px;
line-height: 1;
opacity: 0;
transform: scale(.2);
pointer-events: none;
}
.st-star--1 { top: -14px; left: -18px; color: $stage-orange; }
.st-star--2 { top: -20px; right: -14px; }
.st-star--3 { bottom: -16px; left: 40%; color: $stage-orange; }
@keyframes st-twinkle {
0%, 100% { opacity: 0; transform: scale(.2) rotate(0deg); }
50% { opacity: 1; transform: scale(1) rotate(70deg); }
}
.st-wrap.is-demo .st-star { animation: st-twinkle $duration $easing infinite; }
.st-star--2 { animation-delay: 260ms; }
.st-star--3 { animation-delay: 480ms; }
@media (prefers-reduced-motion: reduce) {
.st-wrap.is-demo .st-star { animation: none !important; opacity: 0 !important; }
}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);
}
.st-wrap {
position: relative;
display: grid;
place-items: center;
padding: 24px;
}
.st-title {
margin: 0;
color: #17141a;
font: 800 30px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.st-star {
position: absolute;
color: #2f6df6;
font-size: 18px;
line-height: 1;
opacity: 0;
transform: scale(0.2);
pointer-events: none;
}
.st-star--1 {
top: -14px;
left: -18px;
color: #ff4d1f;
}
.st-star--2 {
top: -20px;
right: -14px;
}
.st-star--3 {
bottom: -16px;
left: 40%;
color: #ff4d1f;
}
@keyframes st-twinkle {
0%, 100% {
opacity: 0;
transform: scale(0.2) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(70deg);
}
}
.st-wrap.is-demo .st-star {
animation: st-twinkle 1.3s ease-out infinite;
}
.st-star--2 {
animation-delay: 260ms;
}
.st-star--3 {
animation-delay: 480ms;
}
@media (prefers-reduced-motion: reduce) {
.st-wrap.is-demo .st-star {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./SparkleText.module.scss";
type Vars = CSSProperties & Record<string, string>;
export interface SparkleTextProps {
duration?: string;
easing?: string;
color?: string;
}
export default function SparkleText({
duration = "1.3s",
easing = "ease-out",
color = "#2f6df6",
}: SparkleTextProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={style}>
<h2 className={styles.title}>축하합니다!</h2>
<span className={`${styles.star} ${styles.star1}`}>✦</span>
<span className={`${styles.star} ${styles.star2}`}>✦</span>
<span className={`${styles.star} ${styles.star3}`}>✦</span>
</div>
);
}04 · Sparkle text — Measured area 0.193% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
Each one scales up and fades in on its own animation-delay, so the shimmer feels scattered instead of synchronized.
.st-star {
position: absolute;
color: $color;
font-size: 18px;
line-height: 1;
opacity: 0;
transform: scale(.2);
pointer-events: none;
}
.st-star--1 { top: -14px; left: -18px; color: $stage-orange; }
.st-star--2 { top: -20px; right: -14px; }
.st-star--3 { bottom: -16px; left: 40%; color: $stage-orange; }
@keyframes st-twinkle {
0%, 100% { opacity: 0; transform: scale(.2) rotate(0deg); }
50% { opacity: 1; transform: scale(1) rotate(70deg); }
}
.st-wrap.is-demo .st-star { animation: st-twinkle $duration $easing infinite; }
.st-star--2 { animation-delay: 260ms; }
.st-star--3 { animation-delay: 480ms; }
05Firework dots
Where a css confetti cannon fits
One center point splits into eight, each dot carrying its own --angle custom property, and they shoot outward like a firework before shrinking away to nothing.

<main class="stage">
<div class="fw-wrap is-demo">
<span class="fw-dot"></span><span class="fw-dot"></span><span class="fw-dot"></span><span class="fw-dot"></span>
<span class="fw-dot"></span><span class="fw-dot"></span><span class="fw-dot"></span><span class="fw-dot"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s;
$easing: $ease-out;
$color: $stage-yellow;
@include stage($stage-ink);
$piece-colors: $color, $subject-blue, $stage-orange, $subject-cream;
.fw-wrap {
position: relative;
width: 8px;
height: 8px;
}
.fw-dot {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
border-radius: $r-pill;
opacity: 0;
}
// 점 8개가 --angle 을 30~360deg 로 나눠 갖고, rotate + translateY 로 불꽃처럼 퍼진다
@for $i from 1 through 8 {
.fw-dot:nth-child(#{$i}) {
background: nth($piece-colors, ($i - 1) % 4 + 1);
--angle: #{$i * 45deg};
}
}
@keyframes fw-burst {
0% { transform: rotate(var(--angle)) translateY(0) scale(1); opacity: 1; }
70% { opacity: 1; }
100% { transform: rotate(var(--angle)) translateY(-92px) scale(.3); opacity: 0; }
}
.fw-wrap.is-demo .fw-dot { animation: fw-burst $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fw-wrap.is-demo .fw-dot { animation: none !important; opacity: 0 !important; }
}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);
}
.fw-wrap {
position: relative;
width: 8px;
height: 8px;
}
.fw-dot {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
border-radius: 999px;
opacity: 0;
}
.fw-dot:nth-child(1) {
background: #ffd23f;
--angle: 45deg;
}
.fw-dot:nth-child(2) {
background: #2f6df6;
--angle: 90deg;
}
.fw-dot:nth-child(3) {
background: #ff4d1f;
--angle: 135deg;
}
.fw-dot:nth-child(4) {
background: #fff7e6;
--angle: 180deg;
}
.fw-dot:nth-child(5) {
background: #ffd23f;
--angle: 225deg;
}
.fw-dot:nth-child(6) {
background: #2f6df6;
--angle: 270deg;
}
.fw-dot:nth-child(7) {
background: #ff4d1f;
--angle: 315deg;
}
.fw-dot:nth-child(8) {
background: #fff7e6;
--angle: 360deg;
}
@keyframes fw-burst {
0% {
transform: rotate(var(--angle)) translateY(0) scale(1);
opacity: 1;
}
70% {
opacity: 1;
}
100% {
transform: rotate(var(--angle)) translateY(-92px) scale(0.3);
opacity: 0;
}
}
.fw-wrap.is-demo .fw-dot {
animation: fw-burst 1s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.fw-wrap.is-demo .fw-dot {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./FireworkDots.module.scss";
type Vars = CSSProperties & Record<string, string>;
export interface FireworkDotsProps {
duration?: string;
easing?: string;
color?: string;
}
const COLORS = ["var(--color)", "#2f6df6", "#ff4d1f", "#fff7e6"];
export default function FireworkDots({
duration = "1s",
easing = "ease-out",
color = "#ffd23f",
}: FireworkDotsProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={style}>
{Array.from({ length: 8 }, (_, i) => {
const dotStyle: Vars = {
background: COLORS[i % COLORS.length],
"--angle": `${(i + 1) * 45}deg`,
};
return <span key={i} className={styles.dot} style={dotStyle} />;
})}
</div>
);
}05 · Firework dots — Measured area 1.597% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
The markup is just eight empty spans — every angle and color comes from an SCSS @for loop rather than hand-placed CSS.
@for $i from 1 through 8 {
.fw-dot:nth-child(#{$i}) {
background: nth($piece-colors, ($i - 1) % 4 + 1);
--angle: #{$i * 45deg};
}
}
@keyframes fw-burst {
0% { transform: rotate(var(--angle)) translateY(0) scale(1); opacity: 1; }
70% { opacity: 1; }
100% { transform: rotate(var(--angle)) translateY(-92px) scale(.3); opacity: 0; }
}
06Emoji pop
What the user notices
Tap a react button and three emoji hop up above it on staggered delays, each riding a keyframe that combines translateY and scale before fading out.

<main class="stage">
<div class="ep-wrap is-demo">
<button class="ep-btn" type="button" aria-pressed="false">👏 Cheer</button>
<span class="ep-emoji ep-emoji--1">🎉</span>
<span class="ep-emoji ep-emoji--2">✨</span>
<span class="ep-emoji ep-emoji--3">🎊</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 700ms;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-yellow);
.ep-wrap {
position: relative;
width: min(220px, 80vw);
display: grid;
place-items: center;
}
.ep-btn {
position: relative;
z-index: 1;
border: 0;
border-radius: $r-pill;
padding: $sp-3 $sp-6;
background: $subject-ink;
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
}
.ep-emoji {
position: absolute;
top: -8px;
font-size: 22px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
.ep-emoji--1 { left: 10%; }
.ep-emoji--2 { left: 44%; }
.ep-emoji--3 { right: 10%; }
@keyframes ep-pop {
0% { transform: translateY(0) scale(.4); opacity: 0; }
30% { opacity: 1; }
100% { transform: translateY(-64px) scale(1.1); opacity: 0; }
}
// 실사용: 클릭이 is-pop 을 다시 붙인다 (위 <script>)
.ep-wrap.is-pop .ep-emoji,
.ep-wrap.is-demo .ep-emoji { animation: ep-pop $duration $easing infinite; }
.ep-emoji--2 { animation-delay: 110ms; }
.ep-emoji--3 { animation-delay: 220ms; }
@media (prefers-reduced-motion: reduce) {
.ep-wrap.is-pop .ep-emoji,
.ep-wrap.is-demo .ep-emoji { animation: none !important; opacity: 0 !important; }
}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);
}
.ep-wrap {
position: relative;
width: min(220px, 80vw);
display: grid;
place-items: center;
}
.ep-btn {
position: relative;
z-index: 1;
border: 0;
border-radius: 999px;
padding: 12px 24px;
background: #17141a;
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
}
.ep-emoji {
position: absolute;
top: -8px;
font-size: 22px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
.ep-emoji--1 {
left: 10%;
}
.ep-emoji--2 {
left: 44%;
}
.ep-emoji--3 {
right: 10%;
}
@keyframes ep-pop {
0% {
transform: translateY(0) scale(0.4);
opacity: 0;
}
30% {
opacity: 1;
}
100% {
transform: translateY(-64px) scale(1.1);
opacity: 0;
}
}
.ep-wrap.is-pop .ep-emoji,
.ep-wrap.is-demo .ep-emoji {
animation: ep-pop 700ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.ep-emoji--2 {
animation-delay: 110ms;
}
.ep-emoji--3 {
animation-delay: 220ms;
}
@media (prefers-reduced-motion: reduce) {
.ep-wrap.is-pop .ep-emoji,
.ep-wrap.is-demo .ep-emoji {
animation: none !important;
opacity: 0 !important;
}
}var wrap = document.querySelector('.ep-wrap');
var btn = document.querySelector('.ep-btn');
btn.addEventListener('click', function () {
wrap.classList.remove('is-pop');
void wrap.offsetWidth;
wrap.classList.add('is-pop');
});import { CSSProperties, useState } from "react";
import styles from "./EmojiPop.module.scss";
type Vars = CSSProperties & Record<string, string>;
export interface EmojiPopProps {
duration?: string;
easing?: string;
color?: string;
}
const EMOJI = ["🎉", "✨", "🎊"];
export default function EmojiPop({
duration = "700ms",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: EmojiPopProps) {
const [popKey, setPopKey] = useState(0);
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.wrap} style={style}>
<button className={styles.btn} type="button" onClick={() => setPopKey((k) => k + 1)}>
👏 응원하기
</button>
<span key={popKey} className={styles.emojis}>
{EMOJI.map((e, i) => (
<span
key={i}
className={styles.emoji}
style={{ left: `${10 + i * 34}%`, animationDelay: `${i * 110}ms` } as Vars}
>
{e}
</span>
))}
</span>
</div>
);
}06 · Emoji pop — Measured area 1.865% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
Like item 01, pressing the button again replays the pop through the same short 7-line script that re-adds the animation class.
.ep-emoji--1 { left: 10%; }
.ep-emoji--2 { left: 44%; }
.ep-emoji--3 { right: 10%; }
@keyframes ep-pop {
0% { transform: translateY(0) scale(.4); opacity: 0; }
30% { opacity: 1; }
100% { transform: translateY(-64px) scale(1.1); opacity: 0; }
}
07Pseudo-element-only confetti
Where this shows up
A finished-task card sheds confetti without a single extra element in its markup — only its ::before and ::after exist, and each one draws six dots at once through a stacked box-shadow list.

<main class="stage">
<div class="pc-card is-demo">
<span class="pc-label">Done</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.2s;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-orange);
.pc-card {
position: relative;
width: 190px;
height: 116px;
border-radius: $r-card;
background: $subject-ink;
overflow: hidden;
display: grid;
place-items: center;
}
.pc-label {
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
}
// 마크업엔 요소를 하나도 더하지 않는다 — ::before·::after 각각 box-shadow 로 점 6개씩을 그린다
.pc-card::before,
.pc-card::after {
content: "";
position: absolute;
top: -18px;
left: 14px;
width: 6px;
height: 6px;
border-radius: $r-pill;
background: $color;
box-shadow:
22px -8px 0 -1px $stage-yellow,
44px 6px 0 0 $stage-orange,
66px -12px 0 -1px $subject-cream,
88px 4px 0 0 $color,
110px -6px 0 -1px $stage-yellow,
132px 8px 0 0 $subject-cream;
opacity: 0;
}
.pc-card::after { left: 4px; }
@keyframes pc-fall {
0% { transform: translateY(0) rotate(0deg); opacity: 0; }
12% { opacity: 1; }
100% { transform: translateY(150px) rotate(220deg); opacity: 0; }
}
.pc-card.is-demo::before,
.pc-card.is-demo::after { animation: pc-fall $duration $easing infinite; }
.pc-card.is-demo::after { animation-delay: 260ms; }
@media (prefers-reduced-motion: reduce) {
.pc-card.is-demo::before,
.pc-card.is-demo::after { animation: none !important; opacity: 0 !important; }
}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);
}
.pc-card {
position: relative;
width: 190px;
height: 116px;
border-radius: 16px;
background: #17141a;
overflow: hidden;
display: grid;
place-items: center;
}
.pc-label {
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
}
.pc-card::before,
.pc-card::after {
content: "";
position: absolute;
top: -18px;
left: 14px;
width: 6px;
height: 6px;
border-radius: 999px;
background: #2f6df6;
box-shadow: 22px -8px 0 -1px #ffd23f, 44px 6px 0 0 #ff4d1f, 66px -12px 0 -1px #fff7e6, 88px 4px 0 0 #2f6df6, 110px -6px 0 -1px #ffd23f, 132px 8px 0 0 #fff7e6;
opacity: 0;
}
.pc-card::after {
left: 4px;
}
@keyframes pc-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 0;
}
12% {
opacity: 1;
}
100% {
transform: translateY(150px) rotate(220deg);
opacity: 0;
}
}
.pc-card.is-demo::before,
.pc-card.is-demo::after {
animation: pc-fall 1.2s ease-out infinite;
}
.pc-card.is-demo::after {
animation-delay: 260ms;
}
@media (prefers-reduced-motion: reduce) {
.pc-card.is-demo::before,
.pc-card.is-demo::after {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./PseudoOnlyConfetti.module.scss";
type Vars = CSSProperties & Record<string, string>;
export interface PseudoOnlyConfettiProps {
duration?: string;
easing?: string;
color?: string;
}
// 마크업엔 요소를 하나도 더하지 않는다 — ::before·::after(module.scss) 만으로 점을 그린다
export default function PseudoOnlyConfetti({
duration = "1.2s",
easing = "ease-out",
color = "#2f6df6",
}: PseudoOnlyConfettiProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.card} style={style}>
<span className={styles.label}>완료</span>
</div>
);
}07 · Pseudo-element-only confetti — Measured area 2.233% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
The two pseudo-elements start at slightly different positions and delays so their dots don't fall as an obvious mirrored pair.
.pc-card::before,
.pc-card::after {
content: "";
position: absolute;
top: -18px;
left: 14px;
width: 6px;
height: 6px;
border-radius: $r-pill;
background: $color;
box-shadow:
22px -8px 0 -1px $stage-yellow,
44px 6px 0 0 $stage-orange,
66px -12px 0 -1px $subject-cream,
88px 4px 0 0 $color,
110px -6px 0 -1px $stage-yellow,
132px 8px 0 0 $subject-cream;
opacity: 0;
}
.pc-card::after { left: 4px; }
08Celebrate trigger via :has(:checked)
What happens on screen
Check off a to-do item and eight confetti pieces fire around the checkbox with no JavaScript anywhere on the page — the parent label reads the checkbox's checked state directly with :has(:checked) and turns the animation on by itself.

<main class="stage">
<label class="hc-wrap is-demo">
<input class="hc-check" type="checkbox">
<span class="hc-box" aria-hidden="true"></span>
<span class="hc-text">Task done</span>
<span class="hc-piece"></span><span class="hc-piece"></span><span class="hc-piece"></span>
<span class="hc-piece"></span><span class="hc-piece"></span><span class="hc-piece"></span>
<span class="hc-piece"></span><span class="hc-piece"></span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 800ms;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
$piece-colors: $color, $stage-orange, $stage-yellow;
.hc-wrap {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-5;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
}
.hc-check { position: absolute; width: 1px; height: 1px; opacity: 0; }
.hc-box {
width: 18px;
height: 18px;
border-radius: $r-xs;
border: 2px solid $subject-cream;
box-sizing: border-box;
transition: background $dur-quick, border-color $dur-quick;
}
// JS 없이: 부모가 :has(:checked) 로 체크 상태를 읽어 박스와 컨페티를 함께 켠다
.hc-wrap:has(.hc-check:checked) .hc-box { background: $color; border-color: $color; }
.hc-piece {
position: absolute;
top: 50%;
left: 24px;
width: 8px;
height: 8px;
border-radius: $r-xs;
opacity: 0;
pointer-events: none;
}
@for $i from 1 through 8 {
.hc-piece:nth-child(#{$i + 3}) {
background: nth($piece-colors, ($i - 1) % 3 + 1);
--angle: #{$i * 45deg};
}
}
@keyframes hc-fly {
0% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(.4); opacity: 0; }
20% { opacity: 1; }
100% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-58px) scale(1); opacity: 0; }
}
.hc-wrap:has(.hc-check:checked) .hc-piece,
.hc-wrap.is-demo .hc-piece { animation: hc-fly $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.hc-wrap:has(.hc-check:checked) .hc-piece,
.hc-wrap.is-demo .hc-piece { animation: none !important; opacity: 0 !important; }
}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);
}
.hc-wrap {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
}
.hc-check {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.hc-box {
width: 18px;
height: 18px;
border-radius: 4px;
border: 2px solid #fff7e6;
box-sizing: border-box;
transition: background 220ms, border-color 220ms;
}
.hc-wrap:has(.hc-check:checked) .hc-box {
background: #2f6df6;
border-color: #2f6df6;
}
.hc-piece {
position: absolute;
top: 50%;
left: 24px;
width: 8px;
height: 8px;
border-radius: 4px;
opacity: 0;
pointer-events: none;
}
.hc-piece:nth-child(4) {
background: #2f6df6;
--angle: 45deg;
}
.hc-piece:nth-child(5) {
background: #ff4d1f;
--angle: 90deg;
}
.hc-piece:nth-child(6) {
background: #ffd23f;
--angle: 135deg;
}
.hc-piece:nth-child(7) {
background: #2f6df6;
--angle: 180deg;
}
.hc-piece:nth-child(8) {
background: #ff4d1f;
--angle: 225deg;
}
.hc-piece:nth-child(9) {
background: #ffd23f;
--angle: 270deg;
}
.hc-piece:nth-child(10) {
background: #2f6df6;
--angle: 315deg;
}
.hc-piece:nth-child(11) {
background: #ff4d1f;
--angle: 360deg;
}
@keyframes hc-fly {
0% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(0.4);
opacity: 0;
}
20% {
opacity: 1;
}
100% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-58px) scale(1);
opacity: 0;
}
}
.hc-wrap:has(.hc-check:checked) .hc-piece,
.hc-wrap.is-demo .hc-piece {
animation: hc-fly 800ms cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.hc-wrap:has(.hc-check:checked) .hc-piece,
.hc-wrap.is-demo .hc-piece {
animation: none !important;
opacity: 0 !important;
}
}import { CSSProperties } from "react";
import styles from "./HasCheckedCelebrate.module.scss";
type Vars = CSSProperties & Record<string, string>;
export interface HasCheckedCelebrateProps {
duration?: string;
easing?: string;
color?: string;
}
const COLORS = ["var(--color)", "#ff4d1f", "#ffd23f"];
// JS 없이: 부모 label 이 :has(input:checked) 로 상태를 읽어 체크박스와 컨페티를 함께 켠다
export default function HasCheckedCelebrate({
duration = "800ms",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
}: HasCheckedCelebrateProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<label className={styles.wrap} style={style}>
<input className={styles.check} type="checkbox" />
<span className={styles.box} aria-hidden="true" />
<span className={styles.text}>할 일 완료</span>
{Array.from({ length: 8 }, (_, i) => {
const pieceStyle: Vars = {
background: COLORS[i % COLORS.length],
"--angle": `${(i + 1) * 45}deg`,
};
return <span key={i} className={styles.piece} style={pieceStyle} />;
})}
</label>
);
}08 · Celebrate trigger via :has(:checked) — Measured area 1.599% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
Uncheck it and the condition goes false again, so nothing replays until it's checked once more.
@keyframes hc-fly {
0% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(.4); opacity: 0; }
20% { opacity: 1; }
100% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-58px) scale(1); opacity: 0; }
}
.hc-wrap:has(.hc-check:checked) .hc-piece,
.hc-wrap.is-demo .hc-piece { animation: hc-fly $duration $easing infinite; }
09Sparkle cursor trail
What it looks like
Move the mouse across a promo banner and six sparkles trail the pointer, flickering on and off as they follow along.

<main class="stage">
<div class="sc-wrap is-demo">
<span class="sc-piece">✦</span><span class="sc-piece">✦</span><span class="sc-piece">✦</span>
<span class="sc-piece">✦</span><span class="sc-piece">✦</span><span class="sc-piece">✦</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 900ms;
$easing: $ease-out;
$color: $stage-yellow;
@include stage($stage-ink);
$piece-colors: $color, $subject-blue, $stage-orange;
$offsets: (0px -28px), (24px -14px), (24px 14px), (0px 28px), (-24px 14px), (-24px -14px);
// 커서 좌표를 px 길이로 등록해야 is-demo 의 드리프트 keyframes 가 값을 보간한다
@property --mx { syntax: '<length>'; inherits: true; initial-value: 0px; }
@property --my { syntax: '<length>'; inherits: true; initial-value: 0px; }
.sc-wrap {
position: relative;
width: min(280px, 84vw);
height: min(176px, 58vh);
border-radius: $r-card;
background: $subject-ink;
overflow: hidden;
--mx: 140px;
--my: 88px;
}
.sc-piece {
position: absolute;
top: 0;
left: 0;
font-size: 14px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
@for $i from 1 through 6 {
.sc-piece:nth-child(#{$i}) {
$pair: nth($offsets, $i);
--ox: #{nth($pair, 1)};
--oy: #{nth($pair, 2)};
color: nth($piece-colors, ($i - 1) % 3 + 1);
animation-delay: #{($i % 3) * 140ms};
}
}
// 실사용: <script> 가 --mx·--my 를 갱신하고, 이 keyframes 는 그 값을 읽어 트랜슬레이트만 애니메이션한다
@keyframes sc-twinkle {
0%, 100% { transform: translate(calc(var(--mx) + var(--ox)), calc(var(--my) + var(--oy))) translate(-50%, -50%) scale(0); opacity: 0; }
50% { transform: translate(calc(var(--mx) + var(--ox)), calc(var(--my) + var(--oy))) translate(-50%, -50%) scale(1); opacity: 1; }
}
// 캡처용: is-demo 는 --mx·--my 자체를 원을 그리며 돈다 (헤드리스는 pointermove 를 못 낸다)
@keyframes sc-drift {
0% { --mx: 44px; --my: 40px; }
25% { --mx: 236px; --my: 34px; }
50% { --mx: 228px; --my: 140px; }
75% { --mx: 52px; --my: 132px; }
100% { --mx: 44px; --my: 40px; }
}
.sc-wrap.is-demo { animation: sc-drift 2s linear infinite; }
.sc-wrap .sc-piece { animation: sc-twinkle $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.sc-wrap.is-demo { animation: none !important; }
.sc-wrap .sc-piece { animation: none !important; opacity: 0 !important; }
}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);
}
@property --mx {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
@property --my {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
.sc-wrap {
position: relative;
width: min(280px, 84vw);
height: min(176px, 58vh);
border-radius: 16px;
background: #17141a;
overflow: hidden;
--mx: 140px;
--my: 88px;
}
.sc-piece {
position: absolute;
top: 0;
left: 0;
font-size: 14px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
.sc-piece:nth-child(1) {
--ox: 0px;
--oy: -28px;
color: #ffd23f;
animation-delay: 140ms;
}
.sc-piece:nth-child(2) {
--ox: 24px;
--oy: -14px;
color: #2f6df6;
animation-delay: 280ms;
}
.sc-piece:nth-child(3) {
--ox: 24px;
--oy: 14px;
color: #ff4d1f;
animation-delay: 0ms;
}
.sc-piece:nth-child(4) {
--ox: 0px;
--oy: 28px;
color: #ffd23f;
animation-delay: 140ms;
}
.sc-piece:nth-child(5) {
--ox: -24px;
--oy: 14px;
color: #2f6df6;
animation-delay: 280ms;
}
.sc-piece:nth-child(6) {
--ox: -24px;
--oy: -14px;
color: #ff4d1f;
animation-delay: 0ms;
}
@keyframes sc-twinkle {
0%, 100% {
transform: translate(calc(var(--mx) + var(--ox)), calc(var(--my) + var(--oy))) translate(-50%, -50%) scale(0);
opacity: 0;
}
50% {
transform: translate(calc(var(--mx) + var(--ox)), calc(var(--my) + var(--oy))) translate(-50%, -50%) scale(1);
opacity: 1;
}
}
@keyframes sc-drift {
0% {
--mx: 44px;
--my: 40px;
}
25% {
--mx: 236px;
--my: 34px;
}
50% {
--mx: 228px;
--my: 140px;
}
75% {
--mx: 52px;
--my: 132px;
}
100% {
--mx: 44px;
--my: 40px;
}
}
.sc-wrap.is-demo {
animation: sc-drift 2s linear infinite;
}
.sc-wrap .sc-piece {
animation: sc-twinkle 900ms ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.sc-wrap.is-demo {
animation: none !important;
}
.sc-wrap .sc-piece {
animation: none !important;
opacity: 0 !important;
}
}var wrap = document.querySelector('.sc-wrap');
wrap.addEventListener('pointermove', function (e) {
wrap.classList.remove('is-demo');
var r = wrap.getBoundingClientRect();
wrap.style.setProperty('--mx', (e.clientX - r.left) + 'px');
wrap.style.setProperty('--my', (e.clientY - r.top) + 'px');
});import { CSSProperties, PointerEvent, useCallback, useRef } from "react";
import styles from "./SparkleCursorTrail.module.scss";
type Vars = CSSProperties & Record<string, string>;
export interface SparkleCursorTrailProps {
duration?: string;
easing?: string;
color?: string;
}
const COLORS = ["var(--color)", "#2f6df6", "#ff4d1f"];
const OFFSETS: [number, number][] = [
[0, -28], [24, -14], [24, 14], [0, 28], [-24, 14], [-24, -14],
];
// 실사용: 진짜 pointermove 가 --mx·--my(px) 를 갱신한다. 반짝임은 그 좌표를 calc() 로 읽어 따라온다
export default function SparkleCursorTrail({
duration = "900ms",
easing = "ease-out",
color = "#ffd23f",
}: SparkleCursorTrailProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const handleMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
const el = wrapRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
el.style.setProperty("--mx", `${e.clientX - r.left}px`);
el.style.setProperty("--my", `${e.clientY - r.top}px`);
}, []);
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color, "--mx": "140px", "--my": "88px" };
return (
<div className={styles.wrap} style={style} ref={wrapRef} onPointerMove={handleMove}>
{OFFSETS.map(([ox, oy], i) => {
const pieceStyle: Vars = {
color: COLORS[i % COLORS.length],
"--ox": `${ox}px`,
"--oy": `${oy}px`,
animationDelay: `${(i % 3) * 140}ms`,
};
return (
<span key={i} className={styles.piece} style={pieceStyle}>
✦
</span>
);
})}
</div>
);
}09 · Sparkle cursor trail — Measured area 1.717% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
The banner stores the live cursor position in two custom properties, --mx and --my, each registered through @property with syntax: '<length>' so the browser can smoothly interpolate the pixel values between mouse positions instead of snapping between them.
@property --mx { syntax: '<length>'; inherits: true; initial-value: 0px; }
@property --my { syntax: '<length>'; inherits: true; initial-value: 0px; }
.sc-wrap {
position: relative;
width: min(280px, 84vw);
height: min(176px, 58vh);
border-radius: $r-card;
background: $subject-ink;
overflow: hidden;
--mx: 140px;
--my: 88px;
}
Where does a css confetti animation break?
What goes wrong
The first real bug in this set showed up in item 02, not in the css confetti on click burst that opens the list. Every piece originally got its own animation-delay and nothing else, and instead of scattered rain they all fell as one tight, synchronized diagonal band — pieces marching down together rather than spreading out. Giving each piece its own animation-duration looked like the fix, but that alone didn't work: the shared rule set $duration through an animation: shorthand, and a shorthand carries higher specificity than a lone animation-duration declared earlier on each piece, so it silently overwrote all eighteen custom durations back to one identical value. The real fix splits the shorthand apart — animation-name, animation-timing-function, and animation-iteration-count stay in one shared rule, while animation-duration is left alone on each numbered piece — paired with shuffling every piece's starting top offset out of sync with its left position, since matching the two in the same order rebuilt the very same diagonal band. Two of these nine also lean on fairly recent CSS: item 08's :has(:checked) trigger and item 09's @property registration.
How to avoid it
Pale paper vanishing over an even paler field is exactly the spot the candy pastel landing kit solves by stacking lightness into layers.
A browser that doesn't understand :has() just skips that whole rule block, so the checkbox still checks fine and nothing looks broken — the confetti simply never fires. One that ignores @property still animates --mx and --my, but without registering them as <length> first, the values can't be smoothly interpolated, so the sparkles jump between spots instead of drifting. The zip linked below needs a password to unlock it, and it's written into this very paragraph as an ordinary word rather than a code block: bjwrdptn. Copy it exactly as it appears here and the files open.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Bigger burst | Item 01's piece count and angle spacing changed from 12 pieces at 30deg apart to 20 pieces at 18deg apart | The burst reads as a full circle instead of a dozen spokes |
| Slower rain | Item 02's animation-duration range shifted from 1700ms–2400ms to 2600ms–3600ms |
The banner feels calmer, closer to snow than a downpour |
| Calmer twinkle | Item 03's $duration stretched from 1.1s to 2.2s |
The stars blink half as often, less distracting behind a CTA someone is trying to read |
Accessibility
What reduced motion leaves
All nine drop their motion under prefers-reduced-motion: reduce, but what's left behind depends on the item. For six of them — items 01, 02, 05, 06, 07, and the checked state in 08 — the celebration was decorative to begin with, so a still frame or an unanimated end state loses nothing a visitor needs: the button still shows it was pressed, the checkbox still shows it's checked, the banner still names the winner. Items 03, 04, and 09 are the same — their sparkle and cursor trail never carried information on their own, so switching them off just leaves the button, headline, or banner exactly as readable as before. Item 08's :has(:checked) state has nothing to do with the animation either way and keeps working with motion turned off entirely.
Related reading
The same :has(:checked) technique behind item 08 gets a longer, checkbox-focused write-up in 9 CSS Checkbox Radio Animations, and item 06's click-triggered pop shares its short replay script with 9 CSS Heart Animations.
FAQ
Is a confetti animation css only build possible without JavaScript?
Only three of the nine do, and each uses about 7 lines. Items 01 and 06 replay their burst by re-adding an animation class on click, and item 09 tracks the real cursor position with a pointermove listener. The rest lean on nothing but selectors — :hover for a mouse, :checked for a box, :has() to read that checked box from outside it — never a script tag.
Will more confetti pieces slow the page down?
Twelve to eighteen pieces, the range items 01 and 02 use, animate only transform and opacity, so the browser skips layout recalculation and most devices handle it smoothly. Pushing the count into the hundreds adds enough composited layers that a low-power device can start dropping frames, so keeping whatever's visible at once under roughly twenty pieces is the safer ceiling.
Is the React version in the zip different from the plain CSS one?
The motion is identical. The plain CSS files keep three SCSS variables — $duration, $easing, $color — at the top of the file and drive everything through classes, while the React components take those same values as props and pass them through as inline CSS custom properties instead. When the greeting has to arrive letter by letter, those methods are collected in the CSS text reveal animations.









