GODRICH

CSS 하트 애니메이션 좋아요·별점 9종 — 한 줄 복붙으로

CSS 하트 애니메이션 아홉 개를 한 번에 모았습니다. 좋아요 버튼의 하트부터 별점 입력, 박수·이모지 리액션까지 누르고 고르는 순간 화면이 확실히 반응하는 장면만 골랐고, 전부 transform과 opacity만 움직입니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 인기순이 아니라 누른 뒤 화면이 반응하는 순서로 놓았습니다. 먼저 하트가 튀어 오르고(01~02) 옆 숫자가 따라가고(03), 그다음은 값을 매기는 별점(04~05), 반복해서 누르는 박수(06), 나중에 다시 찾을 저장(07), 확인 피드백(08), 마지막은 여러 개 중 하나를 고르는 리액션(09)입니다. 아래 코드 조각은 항목마다 동작을 만드는 핵심 줄만 뽑았고, 완결본은 zip 안 파일을 열어야 나옵니다.

01하트 팝 등장

좋아요를 누르면 인라인 하트의 채움 레이어가 가운데를 기준으로 0에서 1.15배까지 튀어 올랐다가 1배로 가라앉습니다. 실제 상태를 갖는 체크박스라서 새로고침 없이도 눌렀는지 확인할 수 있습니다.

::before/::after 하트:checked0.22초
.heart__shape--fill {
  color: $color;
  opacity: 0;
  transform: scale(.4) rotate(-8deg);
  transition: opacity $duration $easing, transform $duration $easing;
}
.heart__input:checked ~ .heart__icon .heart__shape--fill {
  opacity: 1;
  transform: scale(1) rotate(0deg);
}
@keyframes heart-pop-loop {
  35%      { opacity: 1; transform: scale(1.15) rotate(6deg); }
  55%, 80% { opacity: 1; transform: scale(1) rotate(0deg); }
}

02파티클 터짐

체크되는 순간 하트 둘레로 작은 점 여덟 개가 CSS 커스텀 속성 --i로 계산한 각도만큼 흩어지며 사라집니다. 이미지 하나 없이 span 여덟 개와 원 하나로 만든 파티클입니다.

--i 커스텀 속성translate+opacity스태거
.burst__dot {
  position: absolute; top: 50%; left: 50%;
  width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px;
  border-radius: 50%; background: $color;
  transform: rotate(calc(var(--i) * 45deg)) translateY(0) scale(1);
}
.burst__input:checked ~ .burst__icon .burst__dot {
  animation: particle-fly $duration $easing forwards;
  animation-delay: calc(var(--i) * 20ms);
}
@keyframes particle-fly {
  100% { opacity: 0; transform: rotate(calc(var(--i) * 45deg)) translateY(-34px) scale(.2); }
}

03좋아요 카운트 동기

체크 상태가 바뀔 때마다 옆 숫자가 1씩 오르내리고, 바뀐 숫자는 scale로 짧게 튑니다. CSS만으로는 텍스트를 바꿀 수 없어서 JS 몇 줄이 change 이벤트에서 숫자만 갈아 끼웁니다.

JS 카운트scale pop:checked
function sync() {
  num.textContent = inp.checked ? "13" : "12";
  num.classList.remove("is-pop");
  void num.offsetWidth;
  num.classList.add("is-pop");
}
inp.addEventListener("change", sync);

04별점 채움

라디오 다섯 개를 5·4·3·2·1 역순으로 두고, 체크된 라디오 뒤 형제 선택자로 앞쪽 별까지 함께 채웁니다. 고른 별 하나만 scale로 튀어 선택을 확인해 줍니다.

~ 형제 선택자fieldset/legendscale 바운스
.rating__stars { display: flex; flex-direction: row-reverse; }
.rating__input:checked ~ .rating__star { color: $color; }
.rating__input:checked + .rating__star { animation: star-pick $duration $easing; }
@keyframes star-pick {
  45%  { transform: scale(1.35); }
  100% { transform: scale(1); }
}

05반쪽 별 그라데이션 표시

이건 입력이 아니라 읽기 전용 평균 평점 배지입니다. 별 두 벌을 겹쳐 위쪽 벌을 overflow: hidden으로 폭만큼만 보이게 해서 소수점 평점을 표현하고, 0에서 목표치까지 차오르는 동작이 2초 간격으로 되풀이됩니다.

--rating 커스텀 속성overflow 폭 클리핑루프 리빌
.avg__fill {
  position: absolute; inset: 0;
  width: 0%; overflow: hidden; color: $color;
}
@keyframes reveal-fill {
  55%, 80% { width: var(--pct); }
  100%     { width: var(--pct); }
}
.avg__fill { animation: reveal-fill $dur-loop $easing infinite; }

06박수 카운트

누를 때마다 숫자가 오르는 박수 버튼입니다. 클릭마다 아이콘이 scale과 rotate로 짧게 튀고, "+1" 글자가 위로 올라가며 opacity로 사라집니다.

반복 클릭translateY+opacityJS 카운트
@keyframes clap-bounce {
  40%  { transform: scale(1.3) rotate(-12deg); }
  100% { transform: scale(1) rotate(0deg); }
}
@keyframes clap-plus {
  0%   { opacity: 1; transform: translateY(0); }
  100% { opacity: 0; transform: translateY(-16px); }
}

07북마크 접힘

체크하면 리본 모양 북마크가 위에서부터 채워지고, 오른쪽 위 모서리의 작은 삼각형이 그 모서리를 기준으로 돌며 접힌 종이처럼 펼쳐집니다.

clip-path 리본transform-origin 모서리rotate+scale
.bookmark__fold {
  clip-path: polygon(100% 0, 0 0, 100% 100%);
  transform: scale(0) rotate(-90deg);
  transform-origin: 100% 0;
  opacity: 0;
  transition: opacity $duration $easing, transform $duration $easing;
}
.bookmark__input:checked ~ .bookmark__icon .bookmark__fold {
  opacity: 1; transform: scale(1) rotate(0deg);
}

08엄지 바운스

체크되기 전엔 filter: grayscale로 가라앉아 있다가, 체크되면 색이 살아나며 엄지가 -20도에서 5도를 지나 0도로 돌아오는 동안 scale도 함께 튑니다.

rotate+scale:checked손짓 이징
.thumb__icon {
  filter: grayscale(1) opacity(.55);
  transition: filter $duration $easing;
}
.thumb__input:checked ~ .thumb__icon {
  filter: grayscale(0) opacity(1);
  animation: thumb-bounce $duration $easing;
}
@keyframes thumb-bounce {
  30%  { transform: scale(1.25) rotate(-20deg); }
  60%  { transform: scale(1.1) rotate(5deg); }
}

09이모지 리액션

이모지 네 개 중 하나를 고르는 라디오 그룹입니다. :has()로 그룹 안에 고른 게 있으면 전체를 살짝 가라앉히고, 고른 이모지 하나만 다시 키웁니다.

fieldset/radio:has()scale+opacity
.react__emoji {
  transition: opacity $duration $easing, transform $duration $easing;
}
.react:has(.react__input:checked) .react__emoji {
  opacity: .4; transform: scale(.9);
}
.react__input:checked + .react__emoji {
  opacity: 1; transform: scale(1.3);
}

어디서 깨지는가 — 함정 하나

CSS 커스텀 속성(--rating처럼 --로 시작하는 값)은 그 자체를 키프레임으로 애니메이션하면 기본적으로 보간되지 않습니다. 05 반쪽 별 그라데이션 표시를 처음 만들 때 --fill: 0%에서 --fill: var(--pct)로 넘어가도록 키프레임을 짰더니, 중간 단계 없이 시작 값에서 끝 값으로 한 프레임 만에 뚝 켜졌습니다. 별이 부드럽게 차오르는 그림이 아니었습니다. MDN의 @property 문서에 나오듯 커스텀 속성은 타입을 등록해야 보간이 됩니다. 등록 없이 고치는 방법은 width처럼 원래부터 보간되는 표준 속성에 calc(var(--rating) / 5 * 100%) 값을 넘기는 것이고, 05는 이 방식으로 다시 짜서 렌더 측정에서 스물네 프레임 중 열세 프레임이 움직이는 애니메이션이 됐습니다.

여기서 하나 더 짚을 게 있습니다. 04 별점 채움과 09 이모지 리액션은 :has() 선택자로 "그룹 안에 체크된 게 있는가"를 판단합니다. :has()는 Chrome·Edge 105+, Safari 15.4+, Firefox 121+에서 동작하고(caniuse.com/css-has), 그보다 오래된 브라우저에서는 04의 형제 선택자 채움만 남고 09의 가라앉는 효과는 빠집니다. 기능 전체가 죽지 않도록 :has()는 항상 보강(progressive enhancement)으로만 쓰고, 여기 적어 둔 비밀번호 8ayu345c를 그대로 옮기면 아홉 개 전부의 vanilla·React 파일이 담긴 zip이 열립니다.

접근성

아홉 개 모두 prefers-reduced-motion: reduce에서 움직임은 끄고 상태 색은 남깁니다. 01·02·07·08은 튀거나 터지는 애니메이션을 없애고 체크된 최종 모양만 즉시 보여주며, 05는 리빌 애니메이션 대신 목표 폭을 바로 고정합니다. 04는 fieldsetlegend로 별 다섯 개를 하나의 평점 그룹으로 묶고 각 라디오에 aria-label="N점"을 달아 스크린리더가 숫자를 읽게 했고, 09도 이모지마다 aria-label로 감정 이름을 읽게 했습니다. 06 박수 버튼은 누를 때마다 오르는 숫자를 aria-live="polite"로 알립니다.

@media (prefers-reduced-motion: reduce) {
  .heart__shape--fill { transition: opacity 1ms; }
  .avg__fill { animation: none !important; width: var(--pct); }
  .thumb__icon { transition: filter 1ms; }
}

좋아요·별점 말고 다른 CSS 애니메이션은 CSS 카테고리에 더 있고, 이 블로그가 어떤 곳인지는 소개 페이지에 적어뒀습니다.

FAQ

아홉 개를 한 화면에 다 써도 되나요?

좋아요 하트나 별점처럼 카드마다 반복되는 것은 목록에 여러 개 있어도 괜찮습니다. 다만 06 박수 카운트와 09 이모지 리액션처럼 클릭할 때마다 크게 반응하는 것은 한 화면에 하나만 두는 편이 낫습니다. 여러 개가 동시에 튀면 어디를 봐야 할지 헷갈립니다.

React 버전은 무엇이 다른가요?

zip의 react/ 폴더는 체크·선택 상태를 useState로 관리합니다. 04 별점과 09 리액션은 고른 값을 onChange 콜백으로 부모에 올려주고, 05는 입력이 아니라 rating prop 하나로 값을 받는 읽기 전용 컴포넌트입니다.

체크박스 대신 button을 써도 되나요?

네, 상태가 켜짐·꺼짐 둘뿐이면 <button aria-pressed="true">로 바꿔도 동일하게 동작합니다. 다만 라벨과 자동으로 연결되는 체크박스와 달리, 버튼은 상태가 바뀔 때마다 aria-pressed 값을 직접 갱신해야 스크린리더가 변화를 압니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.