GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

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

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

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

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

01하트 팝 등장

무엇이 튀어 오르나

좋아요를 누르면 인라인 하트의 채움 레이어가 가운데를 기준으로 0에서 1.15배까지 튀어 올랐다가 1배로 가라앉습니다.

하트 팝 등장
새 창에서 열기

01 · 하트 팝 등장 — 측정 누적면적 1.06% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

::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로 계산한 각도만큼 흩어지며 사라집니다.

파티클 터짐
새 창에서 열기

02 · 파티클 터짐 — 측정 누적면적 1.614% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

--i 커스텀 속성translate+opacity스태거

이미지 없이 만드는 법

이미지 하나 없이 span 여덟 개와 원 하나로 만든 파티클입니다.

.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로 짧게 튑니다.

좋아요 카운트 동기
새 창에서 열기

03 · 좋아요 카운트 동기 — 측정 누적면적 0.179% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

JS 카운트scale pop:checked

왜 JS 가 필요한가

CSS만으로는 텍스트를 바꿀 수 없어서 JS 몇 줄이 change 이벤트에서 숫자만 갈아 끼웁니다.

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 역순으로 두고, 체크된 라디오 뒤 형제 선택자로 앞쪽 별까지 함께 채웁니다.

별점 채움
새 창에서 열기

04 · 별점 채움 — 측정 누적면적 2.926% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

~ 형제 선택자fieldset/legendscale 바운스

고른 별만 튀는 규칙

고른 별 하나만 scale로 튀어 선택을 확인해 줍니다. 그와 동시에 고른 별 글자 위에서 미니 별 네 개가 흩어지는데, 각 파티클은 저마다 질량(0.6~1.4)을 가져 requestAnimationFrame 안에서 준암시 오일러 적분으로 감쇠(-k/m·v — 무거울수록 덜 감속)와 중력을 받으며 날아가다 멈춥니다.

.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반쪽 별 그라데이션 표시

입력이 아닌 읽기 전용 배지

이건 입력이 아니라 읽기 전용 평균 평점 배지입니다.

반쪽 별 그라데이션 표시
새 창에서 열기

05 · 반쪽 별 그라데이션 표시 — 측정 누적면적 1.199% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

--rating 커스텀 속성overflow 폭 클리핑루프 리빌

소수점 평점을 그리는 법

별 두 벌을 겹쳐 위쪽 벌을 overflow: hidden으로 폭만큼만 보이게 해서 소수점 평점을 표현하고, 0에서 목표치까지 차오르는 동작이 2초 간격으로 되풀이됩니다. 채움이 앞가장자리(--pct 지점)에 다 차는 순간 그 지점에서 미니 별 여섯 개가 터지는데, 이 파티클도 질량별 감쇠(-k/m·v)와 중력(질량 무관)을 requestAnimationFrame 적분으로 받아 흩어집니다.

.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박수 카운트

좋아요 버튼 애니메이션은 몇 번까지 반복되나

누를 때마다 숫자가 오르는 박수 버튼입니다.

박수 카운트
새 창에서 열기

06 · 박수 카운트 — 측정 누적면적 0.886% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

반복 클릭translateY+opacityJS 카운트

아이콘과 +1 이 함께 도는 법

클릭마다 아이콘이 scale과 rotate로 짧게 튀고, "+1" 글자가 위로 올라가며 opacity로 사라집니다. 박수를 칠 때마다 아이콘 중심에서 미니 별 다섯 개가 짧게 터지는 것도 같이 붙어 있는데, 파티클은 가벼운 질량(0.6~1.2)과 빠른 감쇠로 박수 리듬에 맞게 0.6초 안에 떨어져 사라집니다.

@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북마크 접힘

모서리가 접히는 기준점

체크하면 리본 모양 북마크가 위에서부터 채워지고, 오른쪽 위 모서리의 작은 삼각형이 그 모서리를 기준으로 돌며 접힌 종이처럼 펼쳐집니다. 접힘이 완성되는 순간 그 귀 꼭짓점에서 다이아몬드 종잇조각 여섯 개가 흩어지는데, 조각마다 질량(0.7~1.6)을 줘 무거울수록 관성으로 더 멀리 날아가다 감쇠(-k/m·v)와 중력에 눌려 멈춥니다.

북마크 접힘
새 창에서 열기

07 · 북마크 접힘 — 측정 누적면적 0.758% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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도 함께 튑니다. 엄지가 착지하는 오버슈트 순간 아이콘에서 임팩트 스파크(미니 별 넷과 점 둘)가 튀는데, 가벼운 질량과 빠른 감쇠(k=4.0)로 짧게 튀었다 사라져 힘이 실린 느낌을 줍니다.

엄지 바운스
새 창에서 열기

08 · 엄지 바운스 — 측정 누적면적 1.192% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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이모지 리액션

이모지 하나만 골라지는 구조

이모지 네 개 중 하나를 고르는 라디오 그룹입니다.

이모지 리액션
새 창에서 열기

09 · 이모지 리액션 — 측정 누적면적 4.024% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fieldset/radio:has()scale+opacity

전체를 가라앉히는 규칙

:has()로 그룹 안에 고른 게 있으면 전체를 살짝 가라앉히고, 고른 이모지 하나만 다시 키웁니다. 고르는 순간 그 이모지에서 미니 하트 다섯 개가 위로 흩어지는데, 하트마다 질량(0.6~1.4)이 달라 가벼운 것은 일찍 떨어지고 무거운 것은 관성으로 더 오래 떠 있습니다(requestAnimationFrame 적분).

.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는 이 방식으로 다시 짜서 렌더 측정에서 스물네 프레임 중 열세 프레임이 움직이는 애니메이션이 됐습니다.

:has() 는 보강으로만 쓴다

여기서 하나 더 짚을 게 있습니다. 라디오 버튼 구조를 그대로 살린 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는 fieldset과 legend로 별 다섯 개를 하나의 평점 그룹으로 묶고 각 라디오에 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

좋아요 버튼 css 아홉 개를 한 화면에 다 써도 되나요?

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

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

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

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

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

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

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