컨페티 효과 축하 애니메이션 CSS 9종
회원가입이나 결제가 끝난 순간 화면에 색종이 조각이 잠깐 터지는 컨페티 효과, 다들 한 번은 보셨을 겁니다. 버튼 클릭이나 체크 표시 하나로 이런 축하 반응을 켜는 CSS 아홉 가지를 한 줄 복붙으로 쓸 수 있게 모았습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 버튼 컨페티 폭발
- 02 컨페티 비
- 03 반짝이는 버튼
- 04 반짝이는 텍스트
- 05 불꽃놀이 도트
- 06 이모지 팝
- 07 의사요소만으로 컨페티
- 08 :has(:checked) 축하 트리거
- 09 커서 반짝임 트레일
아홉 개는 화려한 순서가 아니라 화면에서 컨페티가 실제로 쓰이는 순서를 따라갑니다. 버튼 하나를 눌러 터지는 신호(01)와 배경에 계속 흩날리는 신호(02)를 지나, 버튼과 제목 글자 자체가 반짝이는 강조(03~04)로, 그다음은 더 큰 임팩트를 주는 폭발형 반응(05~06)을 거쳐, 마지막은 마크업을 늘리지 않거나 자바스크립트 없이 트리거를 거는 구현 방식(07~08)과 마우스를 따라다니는 인터랙션(09)으로 끝납니다.
01버튼 컨페티 폭발
가입이나 결제가 끝난 버튼을 누르면 작은 컨페티 조각 12개가 SCSS @for 루프로 미리 흩뿌려진 각도를 타고 사방으로 튀어나갔다가 옅어지며 사라집니다. 다시 누르면 클래스를 떼었다 붙이는 자바스크립트 7줄이 keyframes를 처음부터 재생합니다.
@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; }
}
02컨페티 비
당첨 배너 위에서 조각 18개가 각자 다른 높이(top)와 속도로 떨어지도록 짰습니다. 시작 높이를 가로 위치와 다른 순서로 섞지 않으면 조각들이 한 줄로 뭉쳐 같은 대각선을 그리며 같이 출발해 버려서, 그 순서를 일부러 흩어 뒀습니다.
$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); }
}
03반짝이는 버튼
프리미엄 버튼에 마우스를 올리면 테두리 바깥 작은 별 4개가 서로 다른 animation-delay로 커졌다 작아지며 반짝입니다. 호버를 떼는 순간 트랜지션 없이 바로 멈춰 화면이 깜빡이지 않습니다.
@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; }
04반짝이는 텍스트
축하 제목 글자 둘레에 반짝임 3개를 두고 각각 다른 시작 시점에 커졌다 작아지게 했습니다. 버튼이 아니라 글자 자체가 주인공이라 배경이 하얀 화면에서도 시선이 제목으로 먼저 갑니다.
.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; }
05불꽃놀이 도트
점 하나가 --angle 커스텀 속성을 8등분해 나눠 가진 점 8개로 갈라져, rotate(var(--angle))와 translateY만으로 불꽃처럼 퍼졌다가 옅어집니다. 마크업엔 빈 span 8개뿐이라 위치 계산은 전부 SCSS가 대신합니다.
// 점 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; }
}
06이모지 팝
응원 버튼을 누르면 이모지 3개가 서로 다른 지연으로 translateY와 scale이 겹친 키프레임을 타고 튀어올랐다가 사라집니다. 다시 누르면 01번과 같은 방식으로 짧은 자바스크립트가 keyframes를 재생합니다.
@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; }
07의사요소만으로 컨페티
완료 카드의 마크업엔 요소를 하나도 더하지 않고, ::before·::after 두 개에만 box-shadow로 점 6개씩을 찍어 떨어뜨립니다. 두 의사요소는 시작 위치와 지연만 다르게 둬서 서로 겹쳐 보이지 않습니다.
// 마크업엔 요소를 하나도 더하지 않는다 — ::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;
}
08:has(:checked) 축하 트리거
체크박스를 눌러 "할 일 완료"를 표시하면, 자바스크립트 없이 부모 label이 :has(:checked)로 상태를 읽어 컨페티 조각 8개를 한꺼번에 켭니다. 체크를 풀면 트리거 조건이 거짓이 되어 keyframes가 다시 걸리지 않습니다.
@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; }
09커서 반짝임 트레일
배너 위에서 마우스를 움직이면 좌표를 --mx·--my 픽셀 값으로 저장하고, 반짝임 6개가 각자의 오프셋을 더한 translate()로 그 지점을 따라옵니다. 헤드리스로 찍는 데모용 is-demo는 마우스 대신 --mx·--my 자체를 원을 그리며 돌립니다.
@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; }
컨페티 효과는 어디서 깨지는가
아홉 개 중 가장 먼저 걸려 넘어진 건 02번입니다. 조각마다 시작 딜레이만 다르게 줬을 땐 한 줄로 뭉쳐 같이 출발하는 대각선이 됐고, 시작 높이(top)를 가로 위치와 다른 순서로 흩어 놓고 나서야 진짜 비처럼 보였습니다. 08번의 :has()와 09번의 @property도 비교적 최근 기능이라 Chrome·Edge·최신 Safari·Firefox에서는 잘 동작하지만, 오래된 브라우저에서는 트리거가 아예 안 걸리거나 좌표가 부드럽게 보간되지 않고 툭 튑니다. zip bjwrdptn 압축을 풀 때 필요한 비밀번호는 이 문단 속 평범한 글자로 남겨 뒀으니 천천히 읽으면 찾을 수 있습니다.
접근성 — 아홉 개가 reduced-motion에서 남기는 것
운영체제의 동작 줄이기 설정이 켜져 있으면 아홉 개 데모 모두 @media (prefers-reduced-motion: reduce) 블록이 걸려 컨페티 애니메이션을 멈춥니다. 01·02·05·06·07처럼 장식용 컨페티는 그냥 멈춘 정지 화면으로 충분하지만, 03·04의 반짝임과 09의 커서 트레일은 진행 여부 자체가 정보가 아니라 장식이라 꺼도 버튼과 텍스트 내용은 그대로 다 보입니다. 08번은 :has(:checked)가 애니메이션과 무관하게 체크 상태를 그대로 읽으므로, 동작을 줄여도 체크박스 자체의 완료 표시는 사라지지 않습니다.
비슷한 카드형 반응을 찾는다면 CSS 벤토 그리드 레이아웃 호버 애니메이션 9종 글을 같이 보시고, 다른 모음 글은 CSS 애니메이션 카테고리에서 둘러볼 수 있습니다.
FAQ — 자주 묻는 질문
컨페티는 꼭 자바스크립트가 있어야 터지나요?
아닙니다. 08번의 :has(:checked)처럼 체크박스 상태만으로 트리거를 거는 항목은 자바스크립트가 한 줄도 없고, 05·07번의 불꽃놀이·의사요소 컨페티도 CSS만으로 자동 반복됩니다. 01·06·09번처럼 클릭이나 마우스 좌표가 필요한 항목만 6~8줄짜리 짧은 자바스크립트를 씁니다.
컨페티 조각 개수를 늘리면 느려지나요?
조각 12~18개 정도는 transform과 opacity만 움직이므로 레이아웃을 다시 계산하지 않아 대부분의 기기에서 부드럽습니다. 다만 조각을 수십 개 이상으로 크게 늘리면 합성 레이어가 늘어나 저사양 기기에서는 체감 프레임이 떨어질 수 있어, 화면에 한 번에 보이는 개수는 20개 안팎을 넘기지 않는 걸 권합니다.
zip 속 vanilla 코드와 React 코드는 실행 결과가 다른가요?
아니요, 움직임은 같습니다. 바닐라 판은 SCSS 변수 세 개를 파일 위쪽에 두고 클래스로 애니메이션을 걸고, React 판은 그 값을 컴포넌트 props로 받아 인라인 스타일의 CSS 커스텀 속성으로 넘기는 것만 다릅니다.