09Emoji reaction
A radio group of four emoji sits at opacity .7 by default; picking one lets :has() dim the rest further via opacity and scale while the chosen emoji alone scales up to 1.3.
Best forChat message reactions, post emotion reactions
fieldset/radio:has()scale+opacity

<main class="stage">
<fieldset class="react is-demo">
<legend class="react__legend">Pick a reaction</legend>
<div class="react__row">
<input type="radio" name="react" id="e1" class="react__input" style="--i:0"><label for="e1" class="react__emoji" style="--i:0" aria-label="Heart">❤️<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
<input type="radio" name="react" id="e2" class="react__input" style="--i:1"><label for="e2" class="react__emoji" style="--i:1" aria-label="Laugh">😂<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
<input type="radio" name="react" id="e3" class="react__input" style="--i:2"><label for="e3" class="react__emoji" style="--i:2" aria-label="Like">👍<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
<input type="radio" name="react" id="e4" class="react__input" style="--i:3"><label for="e4" class="react__emoji" style="--i:3" aria-label="Wow">😮<span class="react__fx" aria-hidden="true"><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span><span class="react__spark"></span></span></label>
</div>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 선택 반응 시간
$easing: $ease-pop; // 팝 이징
$color: $subject-cream; // 선택 안 된 것들이 가라앉는 톤 기준
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.react {
border: 0; padding: 0; margin: 0;
&__legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
&__row { display: flex; gap: clamp(8px, 2.6vw, 14px); }
&__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
&__emoji {
position: relative; // 한 겹: 스파크 파티클의 좌표 기준 (레이아웃 불변)
font-size: clamp(24px, 6.6vw, 30px);
display: inline-block;
opacity: .7;
transform: scale(1);
cursor: pointer;
transition: opacity $duration $easing, transform $duration $easing;
}
// 한 겹: 물리 미니 하트 파티클 — 고른 이모지 라벨 중심에서 발사. 좌표·페이드는 JS 가 계산한다.
&__fx { position: absolute; top: 50%; left: 50%; pointer-events: none; }
&__spark {
position: absolute; top: 0; left: 0;
width: 12px; height: 12px; margin: -6px 0 0 -6px;
background: $color;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
// 하나가 선택되면 :has() 로 전체가 가라앉고, 그 하나만 다시 커진다
&:has(&__input:checked) &__emoji { opacity: .4; transform: scale(.9); }
&__input:checked + &__emoji { opacity: 1; transform: scale(1.3); }
&.is-demo &__emoji { animation: react-cycle $dur-loop $easing infinite; animation-delay: calc(var(--i) * .4s); }
}
@keyframes react-cycle {
0%, 87.5%, 100% { opacity: .55; transform: scale(1); }
12.5% { opacity: 1; transform: scale(1.3); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.react__emoji { transition: opacity 1ms; }
.react.is-demo .react__emoji { animation: none !important; }
.react__spark { display: none; } // 파티클 버스트는 꺼짐 — 기본 상태 전환만
}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);
}
.react {
border: 0;
padding: 0;
margin: 0;
}
.react__legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.react__row {
display: flex;
gap: clamp(8px, 2.6vw, 14px);
}
.react__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.react__emoji {
position: relative;
font-size: clamp(24px, 6.6vw, 30px);
display: inline-block;
opacity: 0.7;
transform: scale(1);
cursor: pointer;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.react__fx {
position: absolute;
top: 50%;
left: 50%;
pointer-events: none;
}
.react__spark {
position: absolute;
top: 0;
left: 0;
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
background: #fff7e6;
clip-path: path("M6 11 1 6.5C-.5 5 .0 2.5 2 2 3.5 1.5 5 2.5 6 4 7 2.5 8.5 1.5 10 2 12 2.5 12.5 5 11 6.5Z");
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.react:has(.react__input:checked) .react__emoji {
opacity: 0.4;
transform: scale(0.9);
}
.react__input:checked + .react__emoji {
opacity: 1;
transform: scale(1.3);
}
.react.is-demo .react__emoji {
animation: react-cycle 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
animation-delay: calc(var(--i) * 0.4s);
}
@keyframes react-cycle {
0%, 87.5%, 100% {
opacity: 0.55;
transform: scale(1);
}
12.5% {
opacity: 1;
transform: scale(1.3);
}
}
@media (prefers-reduced-motion: reduce) {
.react__emoji {
transition: opacity 1ms;
}
.react.is-demo .react__emoji {
animation: none !important;
}
.react__spark {
display: none;
}
}// 한 겹: 미니 Heart 물리 파티클 — 이모지를 고르는 순간 그 이모지 라벨에서 하트가 흩어진다.
// 각 하트는 질량·감쇠(-k/m·v)·중력을 매 프레임 준암시 오일러 적분으로 받아 퍼졌다 멈춘다.
// transform 은 JS 가 계산해 기입한다(키이프레임 아님).
(function () {
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var alive = false, raf = 0, last = 0;
function fire(fx) {
if (reduce || !fx) return;
var ps = [].slice.call(fx.querySelectorAll('.react__spark'));
if (!ps.length) return;
var now = performance.now();
for (var i = 0; i < ps.length; i++) {
var m = .6 + Math.random() * .8; // 개별 질량 0.6~1.4
var ang = -Math.PI / 2 + (Math.random() - .5) * 3.2; // 위쪽 부채꼴 ±92°
var v0 = (110 + Math.random() * 70) * (1.15 - m * .2); // 무거울수록 느게 출발
ps[i]._p = { x: 0, y: 0, vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m: m, k: 3.5, g: 300, rot: (Math.random() - .5) * 40, spin: (Math.random() - .5) * 800,
s: .4 + m * .3, born: now + i * 22, life: 650 + Math.random() * 350 };
}
if (!alive) { alive = true; last = now; raf = requestAnimationFrame(step); }
}
function step(t) {
var dt = Math.min((t - last) / 1000, .05); last = t; // 탭 복귀 시 폭주 방지 클램프
var any = false;
var all = document.querySelectorAll('.react__spark');
for (var i = 0; i < all.length; i++) {
var el = all[i], p = el._p; if (!p) continue;
var age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연(순차 분사)
if (age >= p.life) { el.style.opacity = '0'; el._p = null; continue; } // 수명 끝 → 그 자리에 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 가속도 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 가속도 = g (질량 무관)
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
var o = age / p.life; // 수명 후반 45%에서 페이드아웃
el.style.opacity = o < .55 ? '1' : (1 - (o - .55) / .45).toFixed(3);
el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(1) + 'deg) scale(' + p.s.toFixed(3) + ')';
any = true;
}
if (any) raf = requestAnimationFrame(step); else alive = false; // 전부 정지하면 루프 종료
}
// 트리거 1: 실제 클릭 — 고른 라벨의 그룹만 발사
document.querySelectorAll('.react').forEach(function (r) {
r.querySelectorAll('.react__input').forEach(function (inp) {
inp.addEventListener('change', function () {
r.classList.remove('is-demo');
var lb = document.querySelector('label[for="' + inp.id + '"]');
if (lb && inp.checked) fire(lb.querySelector('.react__fx'));
});
inp.addEventListener('pointerdown', function () { r.classList.remove('is-demo'); });
});
});
// 트리거 2: 데모 루프 — 이모지 i 가 자기 사이클(2s, delay i*.4s)의 12.5%(≈250ms)에 튄다
function demoShot() {
document.querySelectorAll('.react').forEach(function (r) {
if (!r.classList.contains('is-demo')) return;
var lbs = r.querySelectorAll('.react__emoji');
for (var i = 0; i < lbs.length; i++) {
(function (lb) { setTimeout(function () { fire(lb.querySelector('.react__fx')); }, 250 + 400 * i); })(lbs[i]);
}
});
}
setInterval(demoShot, 2000);
demoShot();
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EmojiReact.module.scss";
export type EmojiReactProps = {
duration?: number;
easing?: string;
defaultValue?: string | null;
onChange?: (value: string) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
/** 미니 하트 1개의 물성 — 질량·감쇠·중력으로 퍼졌다 멈춘다 */
type Particle = {
x: number; y: number; vx: number; vy: number;
m: number; k: number; g: number;
rot: number; spin: number; s: number;
born: number; life: number;
};
const SPARK_COUNT = 5;
const OPTIONS = [
{ id: "heart", emoji: "❤️", label: "하트" },
{ id: "laugh", emoji: "😂", label: "웃음" },
{ id: "like", emoji: "👍", label: "좋아요" },
{ id: "wow", emoji: "😮", label: "놀람" },
];
/** 질량-감쇠 물리 초기 상태. 감쇠 = -k/m·v, 중력 = g. 고른 이모지 라벨 중심에서 위쪽 부채꼴로 흩어진다. */
function spawn(now: number, i: number): Particle {
const m = 0.6 + Math.random() * 0.8; // 개별 질량 0.6~1.4
const ang = -Math.PI / 2 + (Math.random() - 0.5) * 3.2; // 위쪽 부채꼴 ±92°
const v0 = (110 + Math.random() * 70) * (1.15 - m * 0.2); // 무거울수록 느게 출발
return {
x: 0, y: 0,
vx: Math.cos(ang) * v0, vy: Math.sin(ang) * v0,
m, k: 3.5, g: 300,
rot: (Math.random() - 0.5) * 40, spin: (Math.random() - 0.5) * 800,
s: 0.4 + m * 0.3,
born: now + i * 22, life: 650 + Math.random() * 350, // 순차 분사 + 수명 랜덤
};
}
export default function EmojiReact({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", defaultValue = null, onChange }: EmojiReactProps) {
const [picked, setPicked] = useState(defaultValue);
const [burstKey, setBurstKey] = useState(0);
const wrapRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const fire = useCallback(() => setBurstKey((k) => k + 1), []);
// 준암시 오일러 적분 루프 — rAF 안에서 좌표를 계산해 transform 에 쓴다 (keyframes 하드코딩 아님)
useEffect(() => {
if (!burstKey) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const wrap = wrapRef.current;
if (!wrap) return;
const els = Array.from(wrap.querySelectorAll<HTMLElement>(`.${styles.spark}`)); // 렌더된 그룹(고른 라벨 하나)만
if (!els.length) return;
const now = performance.now();
const ps: (Particle | null)[] = els.map((_, i) => spawn(now, i));
let raf = 0, last = now;
const step = (t: number) => {
const dt = Math.min((t - last) / 1000, 0.05); // 탭 복귀 시 폭주 방지 클램프
last = t;
let any = false;
for (let i = 0; i < els.length; i++) {
const el = els[i], p = ps[i]; if (!p) continue;
const age = t - p.born;
if (age < 0) { any = true; continue; } // 방출 전 지연
if (age >= p.life) { el.style.opacity = "0"; ps[i] = null; continue; } // 수명 끝 → 정지
p.vx += (-p.k / p.m * p.vx) * dt; // 감쇠 = -k/m·v
p.vy += (p.g - p.k / p.m * p.vy) * dt; // 중력 + 감쇠
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.spin * dt;
const o = age / p.life;
el.style.opacity = o < 0.55 ? "1" : (1 - (o - 0.55) / 0.45).toFixed(3);
el.style.transform =
`translate(${p.x.toFixed(1)}px,${p.y.toFixed(1)}px) rotate(${p.rot.toFixed(1)}deg) scale(${p.s.toFixed(3)})`;
any = true;
}
if (any) raf = requestAnimationFrame(step); // 전부 정지하면 루프 종료
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [burstKey]);
return (
<fieldset className={styles.react} style={vars}>
<legend className={styles.legend}>리액션 고르기</legend>
<div className={styles.row} data-active={picked ? "" : undefined} ref={wrapRef}>
{OPTIONS.map((o) => (
<label key={o.id} className={styles.emoji} data-picked={picked === o.id || undefined}>
<input
type="radio"
name="react"
className={styles.input}
checked={picked === o.id}
onChange={() => { setPicked(o.id); fire(); onChange?.(o.id); }}
aria-label={o.label}
/>
<span aria-hidden="true">{o.emoji}</span>
{picked === o.id && (
<span className={styles.fx} aria-hidden="true">
{Array.from({ length: SPARK_COUNT }, (_, i) => (
<span key={`${burstKey}-${i}`} className={styles.spark} aria-hidden="true" />
))}
</span>
)}
</label>
))}
</div>
</fieldset>
);
}09 Emoji reaction — Measured area 4.024% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Heart Animations — One Line, Copy-Paste
The post this demo lives in
9 CSS Heart Animations — One Line, Copy-PasteThese nine css heart animations turn a checkbox or a radio input into a like button, a star rating, or a reaction picker See all demos in this post


