08Sparkle-eyes heart particles
A character with stars glinting inside its eyes; hearts bloom from wherever you press the stage, growing then shrinking away. The press count piles up below.
Best forLike-button reaction character
translateYscaleanimation-fill-mode

<main class="stage">
<div class="sp is-demo" id="sp">
<button class="sp__hit" type="button" aria-label="Send a heart">
<span class="sp__scene" aria-hidden="true">
<span class="sp__char">
<span class="sp__eye sp__eye--l"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="sp__eye sp__eye--r"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="sp__mouth"></span>
<span class="sp__cheek sp__cheek--l"></span>
<span class="sp__cheek sp__cheek--r"></span>
</span>
<span class="sp__auto sp__auto--1"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
<span class="sp__auto sp__auto--2"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
<span class="sp__auto sp__auto--3"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</span>
</button>
<output class="sp__count" id="spCount" aria-hidden="true">0</output>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 반짝 눈 하트 파티클 — 눈 속에 별이 도는 캐릭터 주위로 하트가 피어오른다.
// 하트는 페이드로 지우지 않고 크기로 줄어 소멸한다(scale 로 0 으로) — opacity 중간값이
// poster 에 반투명 유령으로 남는 일을 막는다. 떠오른 자리는 누른 점(JS 가 left/top 지정).
// 지연이 걸린 미리보기 하트 셋은 animation-fill-mode: backwards 로 대기 구간에 미리 보이지 않게 한다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-sky;
@include stage($stage-paper);
.sp { position: relative; width: min(88vw, 368px); display: grid; place-items: center; gap: $sp-2; }
// 뿌리가 vw 폭을 정하고 단추는 그 100% 를 쓴다 — 수축 폭 안에서 % 폭은 0 으로 접힌다
.sp__hit { display: block; width: 100%; border: 0; padding: 0; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.sp__hit:focus-visible { outline: 2px solid $subject-blue; outline-offset: 4px; border-radius: $r-card; }
.sp__scene {
position: relative; display: block; box-sizing: border-box;
width: 100%; aspect-ratio: 16 / 10;
}
// 캐릭터 — 하늘색 방울. 눈은 반짝이(Phosphor sparkle duotone, 인라인)
.sp__char {
position: absolute; bottom: 6%; left: 50%; width: 34%; aspect-ratio: 1 / .92;
background: $color; border: 3px solid $subject-ink;
border-radius: 50% 50% 46% 46% / 44% 44% 56% 56%;
transform: translateX(-50%);
}
.sp__eye { position: absolute; top: 32%; width: 17%; aspect-ratio: 1; color: $subject-ink; }
.sp__eye--l { left: 20%; }
.sp__eye--r { right: 20%; }
.sp__eye svg { display: block; width: 100%; height: 100%; }
.sp__mouth {
position: absolute; top: 56%; left: 50%; width: 18%; height: 9%;
border: 3px solid $subject-ink; border-top: 0; border-radius: 0 0 $r-pill $r-pill;
transform: translateX(-50%);
}
.sp__cheek {
position: absolute; top: 50%; width: 16%; aspect-ratio: 1;
background: $subject-pink; border-radius: 50%; opacity: .55;
}
.sp__cheek--l { left: 2%; }
.sp__cheek--r { right: 2%; }
// 눈의 반짝이는 계속 커졌다 작아지며 살짝 돈다 — 캐릭터가 늘 살아 있는 이유
.sp__eye {
animation-name: spTwinkle;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes spTwinkle {
0%, 100% { transform: scale(.82) rotate(0deg); }
50% { transform: scale(1.12) rotate(12deg); }
}
// 하트 공통 — 붉은 하트가 위로 솟으며 커졌다 접혀 사라진다
.sp__auto, .sp__fly {
position: absolute; width: 12%; aspect-ratio: 1; color: $error; z-index: 2;
pointer-events: none;
}
.sp__auto svg, .sp__fly svg { display: block; width: 100%; height: 100%; }
// 미리보기 하트 셋 — 2초 루프 안에서 3개가 시차를 두고 피어오른다(0.66s 간격)
.sp__auto {
animation-name: spRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sp__auto--1 { left: 30%; bottom: 34%; animation-delay: 0s; }
.sp__auto--2 { left: 56%; bottom: 30%; animation-delay: .66s; }
.sp__auto--3 { left: 42%; bottom: 44%; animation-delay: 1.32s; }
@keyframes spRise {
0% { transform: translateY(10px) scale(.2); }
18% { transform: translateY(-16px) scale(1.05); }
40% { transform: translateY(-34px) scale(.92); }
55% { transform: translateY(-46px) scale(.4); }
60%, 100% { transform: translateY(-52px) scale(0); }
}
// 누른 자리에서 뜨는 하트(일회성) — 같은 키프레임을 한 번만 돈다
.sp__fly {
transform: scale(0);
animation: spRise 900ms cubic-bezier(.2, 1.2, .4, 1) 1 forwards;
}
// 카운터 — 흰 무대 위 잉크 알약
.sp__count {
min-width: $sp-6; padding: 0 $sp-2; box-sizing: border-box;
background: $subject-ink; color: $stage-yellow;
border-radius: $r-pill; text-align: center;
font-weight: 700; font-size: 12px; line-height: 20px;
}
// ── 좁은 화면: 장면(16/10) + 간격 + 카운터가 한 줄로 쌓인다 — 무대 세로(320×200, 패딩 제외 174px) 안에 들게 폭·간격을 줄인다
@media (max-width: 340px) {
.sp { width: min(76vw, 368px); gap: $sp-1; } // 320px 기준 243.2 × 10/16 = 152 + 4 + 16 = 172px
.sp__char { border-width: 2px; }
.sp__count { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
// 하트 솟음과 눈 반짝임을 끊면 하트 없이 서 있는 캐릭터가 남는다 — 카운터만 오른다
.sp__auto,
.sp__fly,
.sp__eye {
animation: none !important;
}
.sp__auto, .sp__fly { transform: scale(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);
}
.sp {
position: relative;
width: min(88vw, 368px);
display: grid;
place-items: center;
gap: 8px;
}
.sp__hit {
display: block;
width: 100%;
border: 0;
padding: 0;
background: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.sp__hit:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
border-radius: 16px;
}
.sp__scene {
position: relative;
display: block;
box-sizing: border-box;
width: 100%;
aspect-ratio: 16/10;
}
.sp__char {
position: absolute;
bottom: 6%;
left: 50%;
width: 34%;
aspect-ratio: 1/0.92;
background: #6ec8ff;
border: 3px solid #17141a;
border-radius: 50% 50% 46% 46%/44% 44% 56% 56%;
transform: translateX(-50%);
}
.sp__eye {
position: absolute;
top: 32%;
width: 17%;
aspect-ratio: 1;
color: #17141a;
}
.sp__eye--l {
left: 20%;
}
.sp__eye--r {
right: 20%;
}
.sp__eye svg {
display: block;
width: 100%;
height: 100%;
}
.sp__mouth {
position: absolute;
top: 56%;
left: 50%;
width: 18%;
height: 9%;
border: 3px solid #17141a;
border-top: 0;
border-radius: 0 0 999px 999px;
transform: translateX(-50%);
}
.sp__cheek {
position: absolute;
top: 50%;
width: 16%;
aspect-ratio: 1;
background: #ff9ebb;
border-radius: 50%;
opacity: 0.55;
}
.sp__cheek--l {
left: 2%;
}
.sp__cheek--r {
right: 2%;
}
.sp__eye {
animation-name: spTwinkle;
animation-duration: 1s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes spTwinkle {
0%, 100% {
transform: scale(0.82) rotate(0deg);
}
50% {
transform: scale(1.12) rotate(12deg);
}
}
.sp__auto, .sp__fly {
position: absolute;
width: 12%;
aspect-ratio: 1;
color: #c2361a;
z-index: 2;
pointer-events: none;
}
.sp__auto svg, .sp__fly svg {
display: block;
width: 100%;
height: 100%;
}
.sp__auto {
animation-name: spRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sp__auto--1 {
left: 30%;
bottom: 34%;
animation-delay: 0s;
}
.sp__auto--2 {
left: 56%;
bottom: 30%;
animation-delay: 0.66s;
}
.sp__auto--3 {
left: 42%;
bottom: 44%;
animation-delay: 1.32s;
}
@keyframes spRise {
0% {
transform: translateY(10px) scale(0.2);
}
18% {
transform: translateY(-16px) scale(1.05);
}
40% {
transform: translateY(-34px) scale(0.92);
}
55% {
transform: translateY(-46px) scale(0.4);
}
60%, 100% {
transform: translateY(-52px) scale(0);
}
}
.sp__fly {
transform: scale(0);
animation: spRise 900ms cubic-bezier(0.2, 1.2, 0.4, 1) 1 forwards;
}
.sp__count {
min-width: 24px;
padding: 0 8px;
box-sizing: border-box;
background: #17141a;
color: #ffd23f;
border-radius: 999px;
text-align: center;
font-weight: 700;
font-size: 12px;
line-height: 20px;
}
@media (max-width: 340px) {
.sp {
width: min(76vw, 368px);
gap: 4px;
}
.sp__char {
border-width: 2px;
}
.sp__count {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp__auto,
.sp__fly,
.sp__eye {
animation: none !important;
}
.sp__auto, .sp__fly {
transform: scale(0);
}
}var sp = document.getElementById('sp');
var scene = document.querySelector('.sp__scene');
var count = document.getElementById('spCount');
var HEART_SVG = scene.querySelector('.sp__auto--1').innerHTML;
var sent = 0;
// 무대를 누른 자리에서 하트가 피어오른다 — span 을 만들어 그 자리에 두고 애니메이션이 끝나면 치운다
document.querySelector('.sp__hit').addEventListener('click', function (e) {
sp.classList.remove('is-demo');
var rect = scene.getBoundingClientRect();
var heart = document.createElement('span');
heart.className = 'sp__fly';
heart.setAttribute('aria-hidden', 'true');
heart.innerHTML = HEART_SVG;
heart.style.left = ((e.clientX || rect.left + rect.width / 2) - rect.left) + 'px';
heart.style.top = ((e.clientY || rect.top + rect.height / 2) - rect.top) + 'px';
scene.appendChild(heart);
heart.addEventListener('animationend', function () { heart.remove(); });
sent = sent + 1;
count.textContent = String(sent);
});
sp.addEventListener('pointerdown', function () { sp.classList.remove('is-demo'); });// CuteSparkleEyesParticles — vanilla 판(run/075/demo/08-cute-sparkle-eyes-particles) 의 React 포트.
// 무대를 누른 자리에서 하트가 피어오른다 — 누른 좌표를 state 로 남겨 그 자리에 하트를 띄운다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CuteSparkleEyesParticles.module.scss";
// 로컬 이벤트 타입 — ambient 선언에 React 마우스 이벤트 제네릭이 없다(237 실측 계열)
type ClickEvt = { clientX: number; clientY: number; currentTarget: HTMLButtonElement };
export type CuteSparkleEyesParticlesProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Fly = { id: number; x: number; y: number };
const SPARKLE_SVG = (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg>
);
const HEART_SVG = (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
);
let flySeq = 0;
export default function CuteSparkleEyesParticles(props: CuteSparkleEyesParticlesProps) {
const duration = props.duration ?? 2000;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#6ec8ff";
const [sent, setSent] = useState<number>(0);
const [flies, setFlies] = useState<Fly[]>([]);
const [preview, setPreview] = useState<boolean>(true);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
function send(e: ClickEvt) {
const rect = (e.currentTarget.querySelector("." + styles.scene) as HTMLElement | null)?.getBoundingClientRect();
if (!rect) { setSent(sent + 1); return; }
const fly: Fly = { id: ++flySeq, x: e.clientX - rect.left, y: e.clientY - rect.top };
setFlies(flies.concat(fly));
setSent(sent + 1);
window.setTimeout(function () {
setFlies(function (cur) { return cur.filter(function (f) { return f.id !== fly.id; }); });
}, 950);
}
return (
<main className={styles.stage}>
<div className={styles.sp + (preview ? " " + styles.isDemo : "")} style={vars}>
<button
className={styles.hit}
type="button"
aria-label="하트 보내기"
onClick={send}
onPointerDown={() => setPreview(false)}
>
<span className={styles.scene} aria-hidden="true">
<span className={styles.char}>
<span className={styles.eye + " " + styles.eyeL}>{SPARKLE_SVG}</span>
<span className={styles.eye + " " + styles.eyeR}>{SPARKLE_SVG}</span>
<span className={styles.mouth}></span>
<span className={styles.cheek + " " + styles.cheekL}></span>
<span className={styles.cheek + " " + styles.cheekR}></span>
</span>
<span className={styles.auto + " " + styles.auto1}>{HEART_SVG}</span>
<span className={styles.auto + " " + styles.auto2}>{HEART_SVG}</span>
<span className={styles.auto + " " + styles.auto3}>{HEART_SVG}</span>
{flies.map(function (f) {
return (
<span className={styles.fly} key={f.id} style={{ left: f.x + "px", top: f.y + "px" }} aria-hidden="true">{HEART_SVG}</span>
);
})}
</span>
</button>
<output className={styles.count} aria-hidden="true">{sent}</output>
</div>
</main>
);
}08 Sparkle-eyes heart particles — Measured area 4.313% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Cute CSS Animations: 9 Kawaii Character Demos
The post this demo lives in
Cute CSS Animations: 9 Kawaii Character DemosCute CSS animations are characters that blink, bounce, and blush from nothing but shapes and keyframes — no image files See all demos in this post


