05絵文字リアクションポップアップ
リアクションボタンを押すと絵文字4つの帯がopacityとtranslateYで浮かび上がり、ボタンのaria-expandedがリアルタイムで更新される。
使いどころメッセージへのリアクション、投稿への反応
opacity+translateYaria-expandedEsc 로 닫힘

<main class="stage">
<div class="rp is-demo">
<div class="rp__pop" role="menu" aria-label="絵文字リアクション">
<button type="button" class="rp__emoji" role="menuitem" aria-label="いいね">👍</button>
<button type="button" class="rp__emoji" role="menuitem" aria-label="笑える">😂</button>
<button type="button" class="rp__emoji" role="menuitem" aria-label="最高">🎉</button>
<button type="button" class="rp__emoji" role="menuitem" aria-label="悲しい">😢</button>
</div>
<button type="button" class="rp__trigger" aria-haspopup="true" aria-expanded="false">リアクション</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.rp { position: relative; display: inline-flex; }
.rp__trigger {
padding: $sp-2 $sp-5;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700;
font-size: 14px;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.rp__pop {
position: absolute;
bottom: calc(100% + #{$sp-2});
left: 50%;
display: flex;
gap: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-raised;
transform: translate(-50%, $sp-2) scale(.85);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.rp.is-open .rp__pop { opacity: 1; transform: translate(-50%, 0) scale(1); pointer-events: auto; }
// 열림 젤리 보강: 세로로 늘어난 뒤 안착 (기존 transition 은 유지, is-open 애니메이션이 이긴다)
.rp.is-open .rp__pop { animation: rp-pop-jelly $duration $easing; transform-origin: bottom center; }
@keyframes rp-pop-jelly {
0% { transform: translate(-50%, 8px) scale(.9, .78); }
55% { transform: translate(-50%, 0) scale(.96, 1.12); }
80% { transform: translate(-50%, 0) scale(1.03, .96); }
100% { transform: translate(-50%, 0) scale(1, 1); }
}
.rp__emoji {
border: 0; background: none; font-size: 22px; line-height: 1; padding: $sp-1;
cursor: pointer; border-radius: $r-sm;
transition: transform $dur-instant $easing;
}
.rp__emoji:hover, .rp__emoji:focus-visible { transform: translateY(-4px); }
@media (prefers-reduced-motion: reduce) {
.rp__pop, .rp__emoji { transition: none; }
.rp.is-open .rp__pop { animation: none !important; }
}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);
}
.rp {
position: relative;
display: inline-flex;
}
.rp__trigger {
padding: 8px 20px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 14px;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.rp__pop {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
display: flex;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(-50%, 8px) scale(0.85);
opacity: 0;
pointer-events: none;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rp.is-open .rp__pop {
opacity: 1;
transform: translate(-50%, 0) scale(1);
pointer-events: auto;
}
.rp.is-open .rp__pop {
animation: rp-pop-jelly 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
transform-origin: bottom center;
}
@keyframes rp-pop-jelly {
0% {
transform: translate(-50%, 8px) scale(0.9, 0.78);
}
55% {
transform: translate(-50%, 0) scale(0.96, 1.12);
}
80% {
transform: translate(-50%, 0) scale(1.03, 0.96);
}
100% {
transform: translate(-50%, 0) scale(1, 1);
}
}
.rp__emoji {
border: 0;
background: none;
font-size: 22px;
line-height: 1;
padding: 4px;
cursor: pointer;
border-radius: 8px;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rp__emoji:hover, .rp__emoji:focus-visible {
transform: translateY(-4px);
}
@media (prefers-reduced-motion: reduce) {
.rp__pop, .rp__emoji {
transition: none;
}
.rp.is-open .rp__pop {
animation: none !important;
}
}// 실제 동작: 버튼을 누르면 팝업이 열리고 aria-expanded 가 바뀐다. Esc 로 닫힘 (11줄)
var rp = document.querySelector('.rp'), trigger = rp.querySelector('.rp__trigger');
function setOpen(v) { rp.classList.toggle('is-open', v); trigger.setAttribute('aria-expanded', String(v)); }
trigger.addEventListener('click', function () { rp.classList.remove('is-demo'); clearInterval(loop); setOpen(!rp.classList.contains('is-open')); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') setOpen(false); });
// .is-demo 자동 순환 — 열림/닫힘을 반복한다 (실제 클릭이 오면 멈춘다)
var open = false;
var loop = setInterval(function () { open = !open; rp.classList.toggle('is-open', open); }, 900);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EmojiReactionPopupPicker.module.scss";
export type EmojiOption = { emoji: string; label: string };
export type EmojiReactionPopupPickerProps = {
triggerLabel?: string;
emojis?: EmojiOption[];
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--accent"?: string };
export default function EmojiReactionPopupPicker({
triggerLabel = "React",
emojis = [
{ emoji: "👍", label: "Like" },
{ emoji: "😂", label: "Haha" },
{ emoji: "🎉", label: "Awesome" },
{ emoji: "😢", label: "Sad" },
],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
accentColor = "#0a4fe8",
}: EmojiReactionPopupPickerProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--accent": accentColor };
return (
<div className={styles.rp} style={vars} onKeyDown={(e: { key: string }) => e.key === "Escape" && setOpen(false)}>
<div className={styles.pop} role="menu" aria-label="Emoji reactions" data-open={open}>
{emojis.map((e) => (
<button key={e.label} type="button" className={styles.emoji} role="menuitem" aria-label={e.label} onClick={() => setOpen(false)}>
{e.emoji}
</button>
))}
</div>
<button type="button" className={styles.trigger} aria-haspopup="true" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{triggerLabel}
</button>
</div>
);
}05 絵文字リアクションポップアップ — 計測 累積面積 6.434% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · セグメントコントロールUI 9選 — コピペOK
このデモが属する記事
セグメントコントロールUI 9選 — コピペOKセグメントコントロールとは、iOSの設定画面でよく見る、いくつかの選択肢から一つを選ぶと背景のピルがその位置までスライドするタブ切り替えボタンです。セグメントコントロールから絵文字リアクション・カラースウォッチまで、名前は知らなくてもアプリ この記事のデモをすべて見る


