チャット 吹き出し css アニメーション9選 — 一行でコピペOK
チャット 吹き出し css のアニメーション9種類を、動いた状態のまま並べました。新着メッセージのポップ登場から入力中のドット、既読の切り替え、画像読み込み、返信引用、絵文字リアクション、日付区切り、新着メッセージの自動スクロールまでを実装し、動かす値はtransformとopacityの2つだけに絞っています。
01 chat-bubble-ui-bubble-pop-in
02 chat-bubble-ui-tail-direction
03 chat-bubble-ui-typing-dots
04 chat-bubble-ui-read-receipt-transition
05 chat-bubble-ui-image-bubble-loading
06 chat-bubble-ui-reply-quote-slide
07 chat-bubble-ui-reaction-emoji-pop
08 chat-bubble-ui-date-divider-appear
09 chat-bubble-ui-scroll-pin-new-message
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 吹き出しポップイン
- 02 しっぽの向き切り替え
- 03 入力中ドット
- 04 既読表示の切り替え
- 05 画像吹き出しの読み込み
- 06 返信引用スライド
- 07 リアクション絵文字ポップ
- 08 日付区切り線の登場
- 09 新着メッセージの下部固定
9つは効果の派手さではなく、通知 ui デザイン にも通じる、メッセージ1通が画面上で経る順番に並べています。届いて目に留まり(01)、送信か受信かを見分けさせ(02)、相手が入力中だと知らせ(03)、送った側が読まれたかを確認させ(04)、写真が届く過程を見せ(05)、それまでの会話とつなぎ(06)、反応を残させ(07)、日付が変わったことを示し(08)、最後は新着メッセージが画面の下端に収まります(09)。コードは仕組みが分かる断片だけを載せていて、そのまま貼って動く完成版はzip1つにまとまっています。未読件数のバッジを数字が転がる形で見せたいときはCSS 数字 カウントアップ アニメーション、トーク一覧をカードで並べる画面ならCSS カード ホバー アニメーションに同じ作り方でまとめてあります。
01吹き出しポップイン
チャット風 吹き出し css をどう出すか
新着メッセージの吹き出しがscale(.4→1)で弾むように拡大しながらopacityも上がり、たった今届いたことをはっきり伝えます。
どこで使うか
1対1チャットの新着メッセージや、通知トーストの中のプレビュー吹き出しに使います。

<main class="stage">
<div class="bubble is-demo">たった今届いた!</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────────
$duration: $dur-quick; // 실제 등장(팝)이 끝나는 데 걸리는 시간 — React 판 --duration 기본값
$easing: $ease-pop; // 튀어나오듯 오버슈트 후 안착
$color: $subject-cream; // 말풍선 색 (ink 무대라 cream)
@include stage($stage-ink);
.bubble {
padding: 22px 34px;
border-radius: $r-card;
background: $color;
color: $stage-ink;
font-weight: 700; font-size: clamp(18px, 5.6vw, 24px); line-height: 1.4; font-family: inherit;
transform-origin: center;
&.is-demo {
animation: bubble-pop-loop $dur-loop $easing infinite;
}
}
// 한 주기(2s) 안에 팝 등장 → 유지 → 사라짐을 담아 gif 이음매가 안 보이게 한다
@keyframes bubble-pop-loop {
0% { transform: scale(.4); opacity: 0; }
16% { transform: scale(1.15); opacity: 1; }
24% { transform: scale(1); opacity: 1; }
82% { transform: scale(1); opacity: 1; }
100% { transform: scale(.4); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.bubble.is-demo { animation: none !important; transform: none; opacity: 1; }
}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);
}
.bubble {
padding: 22px 34px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: clamp(18px, 5.6vw, 24px);
line-height: 1.4;
font-family: inherit;
transform-origin: center;
}
.bubble.is-demo {
animation: bubble-pop-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes bubble-pop-loop {
0% {
transform: scale(0.4);
opacity: 0;
}
16% {
transform: scale(1.15);
opacity: 1;
}
24% {
transform: scale(1);
opacity: 1;
}
82% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.4);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.bubble.is-demo {
animation: none !important;
transform: none;
opacity: 1;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./BubblePopIn.module.scss";
export type BubblePopInProps = {
children: ReactNode;
/** 팝 등장이 끝나는 데 걸리는 시간 (기본 220ms) */
duration?: number;
/** easing (기본 오버슈트 포지션) */
easing?: string;
/** 말풍선 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function BubblePopIn({
children,
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#fff7e6",
className = "",
}: BubblePopInProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={`${styles.bubble} ${styles.enter} ${className}`} style={vars}>
{children}
</div>
);
}01 · 吹き出しポップイン — 計測 累積面積 13.073% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.bubble {
transform-origin: center;
&.is-demo {
animation: bubble-pop-loop $dur-loop $easing infinite;
}
}
@keyframes bubble-pop-loop {
0% { transform: scale(.4); opacity: 0; }
16% { transform: scale(1.15); opacity: 1; }
24% { transform: scale(1); opacity: 1; }
82% { transform: scale(1); opacity: 1; }
100% { transform: scale(.4); opacity: 0; }
}
02しっぽの向き切り替え
どう動くか
吹き出しの角にある三角形のしっぽをclip-pathで左右2枚重ねて描き、送信か受信かに応じて吹き出し本体の位置としっぽのopacityを入れ替えます。
どこで使うか
送受信の区別が必要な1対1チャットやグループチャットの吹き出しに使います。

<main class="stage">
<div class="bubble is-demo">
またあとで
<i class="tail tail--left"></i>
<i class="tail tail--right"></i>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────────
$duration: $dur-base; // 왼쪽 ↔ 오른쪽 한 번 전환에 걸리는 시간
$easing: $ease-out;
$color: $subject-ink; // 말풍선 색 (yellow 무대라 ink)
@include stage($stage-yellow);
.bubble {
position: relative;
padding: 14px 20px;
border-radius: $r-card;
background: $color;
color: #fff;
font-weight: 600; font-size: clamp(14px, 4.5vw, 17px); line-height: 1.4; font-family: inherit;
&.is-demo {
animation: tail-shift-loop $dur-loop $easing infinite;
}
}
// clip-path 삼각형 꼬리 — 왼쪽·오른쪽 두 장을 겹쳐 두고 opacity 로만 바꿔친다
.tail {
position: absolute;
bottom: 10px;
width: 14px;
height: 14px;
background: $color;
}
.tail--left { left: -6px; clip-path: polygon(100% 0, 100% 100%, 0 50%); animation: tail-left-loop $dur-loop $easing infinite; }
.tail--right { right: -6px; clip-path: polygon(0 0, 0 100%, 100% 50%); animation: tail-right-loop $dur-loop $easing infinite; }
// 말풍선 전체가 왼쪽(수신)↔오른쪽(발신)으로 옮겨가고, 꼬리는 그 자리에서 opacity 로 바뀐다
@keyframes tail-shift-loop {
0% { transform: translateX(-70px); }
32% { transform: translateX(-70px); }
68% { transform: translateX(70px); }
100% { transform: translateX(70px); }
}
@keyframes tail-left-loop {
0% { opacity: 1; }
32% { opacity: 1; }
68% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes tail-right-loop {
0% { opacity: 0; }
32% { opacity: 0; }
68% { opacity: 1; }
100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.bubble.is-demo, .tail--left, .tail--right { animation: none !important; }
.bubble.is-demo { transform: translateX(-70px); }
.tail--left { opacity: 1; }
.tail--right { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bubble {
position: relative;
padding: 14px 20px;
border-radius: 16px;
background: #17141a;
color: #fff;
font-weight: 600;
font-size: clamp(14px, 4.5vw, 17px);
line-height: 1.4;
font-family: inherit;
}
.bubble.is-demo {
animation: tail-shift-loop 2s ease-out infinite;
}
.tail {
position: absolute;
bottom: 10px;
width: 14px;
height: 14px;
background: #17141a;
}
.tail--left {
left: -6px;
clip-path: polygon(100% 0, 100% 100%, 0 50%);
animation: tail-left-loop 2s ease-out infinite;
}
.tail--right {
right: -6px;
clip-path: polygon(0 0, 0 100%, 100% 50%);
animation: tail-right-loop 2s ease-out infinite;
}
@keyframes tail-shift-loop {
0% {
transform: translateX(-70px);
}
32% {
transform: translateX(-70px);
}
68% {
transform: translateX(70px);
}
100% {
transform: translateX(70px);
}
}
@keyframes tail-left-loop {
0% {
opacity: 1;
}
32% {
opacity: 1;
}
68% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes tail-right-loop {
0% {
opacity: 0;
}
32% {
opacity: 0;
}
68% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.bubble.is-demo, .tail--left, .tail--right {
animation: none !important;
}
.bubble.is-demo {
transform: translateX(-70px);
}
.tail--left {
opacity: 1;
}
.tail--right {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./TailDirection.module.scss";
export type TailDirectionProps = {
children: string;
/** 발신(right)인가 수신(left)인가 */
side?: "left" | "right";
/** 전환에 걸리는 시간 (기본 300ms) */
duration?: number;
easing?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function TailDirection({
children,
side = "right",
duration = 300,
easing = "ease-out",
className = "",
}: TailDirectionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<div className={`${styles.bubble} ${styles[side]} ${className}`} style={vars}>
{children}
<i className={`${styles.tail} ${styles.tailLeft}`} />
<i className={`${styles.tail} ${styles.tailRight}`} />
</div>
);
}02 · しっぽの向き切り替え — 計測 累積面積 8.289% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.tail--left { left: -6px; clip-path: polygon(100% 0, 100% 100%, 0 50%); }
.tail--right { right: -6px; clip-path: polygon(0 0, 0 100%, 100% 50%); }
@keyframes tail-shift-loop {
0% { transform: translateX(-70px); }
32% { transform: translateX(-70px); }
68% { transform: translateX(70px); }
100% { transform: translateX(70px); }
}
03入力中ドット
どう動くか
3つのドットがtranslateYで200msずつずれながら1つずつ跳ね、相手が今まさに入力中であることを知らせます。
どこで使うか
メッセンジャーの相手の入力状態や、チャットボットの応答待ち表示に使います。

<main class="stage">
<div class="wrap">
<p class="label">入力中</p>
<div class="bubble is-demo">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────────
$duration: $dur-base; // 점 하나가 튀는 데 걸리는 시간
$easing: $ease-out;
$color: $subject-ink; // 점 색 (orange 무대라 cream 말풍선 위 ink 점)
@include stage($stage-orange);
.wrap { display: grid; gap: 10px; justify-items: center; }
.label {
margin: 0;
font-weight: 600; font-size: clamp(11px, 3.2vw, 13px); line-height: 1; font-family: inherit;
letter-spacing: .04em;
color: $subject-ink; // 주황 무대 위 글자 — AA 4.5 (blog-design 글자 대비)
}
.bubble {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 22px 28px;
border-radius: $r-pill;
background: $subject-cream;
transform-origin: center;
&.is-demo { animation: bubble-breathe-loop $dur-loop $easing infinite; }
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: $color;
opacity: .4;
.bubble.is-demo & {
animation: dot-bounce-loop $dur-loop $easing infinite;
}
// 세 점이 200ms 씩 어긋나게 시작해 파도처럼 보인다 (음수 delay = 이미 진행 중인 위상에서 시작)
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: -.2s; }
&:nth-child(3) { animation-delay: -.4s; }
}
// 점 세 개가 파도처럼 튀는 동안 말풍선 전체도 아주 살짝 숨쉬듯 커졌다 줄어든다 — 작은 UI 라 캔버스 대비 면적을 함께 확보
@keyframes bubble-breathe-loop {
0% { transform: scale(1); }
10% { transform: scale(1.06); }
20% { transform: scale(1); }
100% { transform: scale(1); }
}
@keyframes dot-bounce-loop {
0% { transform: translateY(0); opacity: .4; }
16% { transform: translateY(-9px); opacity: 1; }
32% { transform: translateY(0); opacity: .4; }
100% { transform: translateY(0); opacity: .4; }
}
@media (prefers-reduced-motion: reduce) {
.dot, .bubble.is-demo { animation: none !important; }
.dot { opacity: .7; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: grid;
gap: 10px;
justify-items: center;
}
.label {
margin: 0;
font-weight: 600;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
color: #17141a;
}
.bubble {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 22px 28px;
border-radius: 999px;
background: #fff7e6;
transform-origin: center;
}
.bubble.is-demo {
animation: bubble-breathe-loop 2s ease-out infinite;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #17141a;
opacity: 0.4;
}
.bubble.is-demo .dot {
animation: dot-bounce-loop 2s ease-out infinite;
}
.dot:nth-child(1) {
animation-delay: 0s;
}
.dot:nth-child(2) {
animation-delay: -0.2s;
}
.dot:nth-child(3) {
animation-delay: -0.4s;
}
@keyframes bubble-breathe-loop {
0% {
transform: scale(1);
}
10% {
transform: scale(1.06);
}
20% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@keyframes dot-bounce-loop {
0% {
transform: translateY(0);
opacity: 0.4;
}
16% {
transform: translateY(-9px);
opacity: 1;
}
32% {
transform: translateY(0);
opacity: 0.4;
}
100% {
transform: translateY(0);
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
.dot, .bubble.is-demo {
animation: none !important;
}
.dot {
opacity: 0.7;
}
}import type { CSSProperties } from "react";
import styles from "./TypingDots.module.scss";
export type TypingDotsProps = {
/** 점 하나가 튀는 데 걸리는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TypingDots({
duration = 300,
easing = "ease-out",
color = "#17141a",
className = "",
}: TypingDotsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.bubble} ${className}`} style={vars}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
);
}03 · 入力中ドット — 計測 累積面積 1.219% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.dot {
opacity: .4;
.bubble.is-demo & {
animation: dot-bounce-loop $dur-loop $easing infinite;
}
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: -.2s; }
&:nth-child(3) { animation-delay: -.4s; }
}
@keyframes dot-bounce-loop {
0% { transform: translateY(0); opacity: .4; }
16% { transform: translateY(-9px); opacity: 1; }
32% { transform: translateY(0); opacity: .4; }
100% { transform: translateY(0); opacity: .4; }
}
04既読表示の切り替え
どう動くか
未読数字の1が読まれた瞬間opacityで消え、チェックアイコンがscale(1.15)まで跳ねてから1に落ち着いて入れ替わります。
どこで使うか
1対1チャットの既読確認や、問い合わせチャットで担当者の返信を確かめる場面に使います。

<main class="stage">
<div class="row is-demo">
<span class="glow" aria-hidden="true"></span>
<div class="bubble">出発するね</div>
<div class="status">
<span class="unread">1</span>
<svg class="check" viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">
<path d="M4 10.5 8 14.5 16 5.5" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────────
$duration: $dur-base; // 숫자 → 체크로 바뀌는 데 걸리는 시간
$easing: $ease-out;
$color: $subject-blue; // 말풍선 색 (paper 무대라 blue)
@include stage($stage-paper);
.row {
position: relative;
display: flex;
align-items: flex-end;
gap: 8px;
}
// 읽힌 순간 뒤에서 한 번 번지는 하이라이트 — 배지 하나만으론 화면 대비 피사체가 너무 작아서(캔버스 8%) 곁들였다
.glow {
position: absolute;
left: 50%;
top: 50%;
width: 260px;
height: 260px;
margin: -130px 0 0 -130px;
border-radius: 50%;
background: radial-gradient(circle, rgba($subject-blue, .4), rgba($subject-blue, 0) 70%);
opacity: 0;
transform: scale(.7);
pointer-events: none;
.row.is-demo & { animation: glow-loop $dur-loop $easing infinite; }
}
.bubble {
position: relative;
padding: 14px 20px;
border-radius: $r-card;
background: $color;
color: #fff;
font-weight: 600; font-size: clamp(14px, 4.5vw, 17px); line-height: 1.4; font-family: inherit;
}
.status {
position: relative;
width: 26px;
height: 26px;
color: $color;
font-weight: 700; font-size: 18px; line-height: 1; font-family: inherit;
}
.unread, .check {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.check { color: $color; }
.row.is-demo .unread { animation: unread-out-loop $dur-loop $easing infinite; }
.row.is-demo .check { animation: check-in-loop $dur-loop $easing infinite; }
// 절반까지는 안읽음 숫자, 절반 지나면 체크 + 하이라이트 — 한 주기 안에서 전환이 반복된다
@keyframes unread-out-loop {
0% { opacity: 1; transform: scale(1); }
48% { opacity: 1; transform: scale(1); }
58% { opacity: 0; transform: scale(.4); }
100% { opacity: 0; transform: scale(.4); }
}
@keyframes check-in-loop {
0% { opacity: 0; transform: scale(.4); }
48% { opacity: 0; transform: scale(.4); }
62% { opacity: 1; transform: scale(1.15); }
72% { opacity: 1; transform: scale(1); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes glow-loop {
0% { opacity: 0; transform: scale(.7); }
44% { opacity: 0; transform: scale(.7); }
60% { opacity: 1; transform: scale(1); }
90% { opacity: 0; transform: scale(1.35); }
100% { opacity: 0; transform: scale(1.35); }
}
@media (prefers-reduced-motion: reduce) {
.row.is-demo .unread, .row.is-demo .check, .glow { animation: none !important; }
.row.is-demo .unread { opacity: 0; }
.row.is-demo .check { opacity: 1; transform: none; }
.glow { opacity: 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);
}
.row {
position: relative;
display: flex;
align-items: flex-end;
gap: 8px;
}
.glow {
position: absolute;
left: 50%;
top: 50%;
width: 260px;
height: 260px;
margin: -130px 0 0 -130px;
border-radius: 50%;
background: radial-gradient(circle, rgba(47, 109, 246, 0.4), rgba(47, 109, 246, 0) 70%);
opacity: 0;
transform: scale(0.7);
pointer-events: none;
}
.row.is-demo .glow {
animation: glow-loop 2s ease-out infinite;
}
.bubble {
position: relative;
padding: 14px 20px;
border-radius: 16px;
background: #2f6df6;
color: #fff;
font-weight: 600;
font-size: clamp(14px, 4.5vw, 17px);
line-height: 1.4;
font-family: inherit;
}
.status {
position: relative;
width: 26px;
height: 26px;
color: #2f6df6;
font-weight: 700;
font-size: 18px;
line-height: 1;
font-family: inherit;
}
.unread, .check {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.check {
color: #2f6df6;
}
.row.is-demo .unread {
animation: unread-out-loop 2s ease-out infinite;
}
.row.is-demo .check {
animation: check-in-loop 2s ease-out infinite;
}
@keyframes unread-out-loop {
0% {
opacity: 1;
transform: scale(1);
}
48% {
opacity: 1;
transform: scale(1);
}
58% {
opacity: 0;
transform: scale(0.4);
}
100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes check-in-loop {
0% {
opacity: 0;
transform: scale(0.4);
}
48% {
opacity: 0;
transform: scale(0.4);
}
62% {
opacity: 1;
transform: scale(1.15);
}
72% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes glow-loop {
0% {
opacity: 0;
transform: scale(0.7);
}
44% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1);
}
90% {
opacity: 0;
transform: scale(1.35);
}
100% {
opacity: 0;
transform: scale(1.35);
}
}
@media (prefers-reduced-motion: reduce) {
.row.is-demo .unread, .row.is-demo .check, .glow {
animation: none !important;
}
.row.is-demo .unread {
opacity: 0;
}
.row.is-demo .check {
opacity: 1;
transform: none;
}
.glow {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./ReadReceiptTransition.module.scss";
export type ReadReceiptTransitionProps = {
/** 상대가 읽었는가 */
read: boolean;
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ReadReceiptTransition({
read,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
className = "",
}: ReadReceiptTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.status} ${className}`} style={vars}>
<span className={`${styles.unread} ${read ? styles.hide : ""}`}>1</span>
<svg className={`${styles.check} ${read ? styles.show : ""}`} viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
<path d="M4 10.5 8 14.5 16 5.5" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
}04 · 既読表示の切り替え — 計測 累積面積 23.517% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.row.is-demo .unread { animation: unread-out-loop $dur-loop $easing infinite; }
.row.is-demo .check { animation: check-in-loop $dur-loop $easing infinite; }
@keyframes unread-out-loop {
0% { opacity: 1; transform: scale(1); }
48% { opacity: 1; transform: scale(1); }
58% { opacity: 0; transform: scale(.4); }
100% { opacity: 0; transform: scale(.4); }
}
@keyframes check-in-loop {
0% { opacity: 0; transform: scale(.4); }
48% { opacity: 0; transform: scale(.4); }
62% { opacity: 1; transform: scale(1.15); }
100% { opacity: 1; transform: scale(1); }
}
05画像吹き出しの読み込み
どう動くか
画像が届く前はグレーのスケルトンの上をハイライトがtranslateXで2回なぞり、届いた瞬間は写真レイヤーがopacity0から1へ切り替わってスケルトンの上に重なります。
どこで使うか
写真やGIFをやり取りするチャット、商品画像が付く問い合わせチャットに使います。

<main class="stage">
<figure class="bubble is-demo">
<span class="skeleton">
<span class="shine"></span>
</span>
<span class="photo"></span>
<figcaption class="caption">写真送信中</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────────
$duration: $dur-enter; // 스켈레톤이 사진으로 바뀌는 데 걸리는 시간
$easing: $ease-out;
$color: $subject-cream; // 말풍선 틀 색 (ink 무대라 cream)
@include stage($stage-ink);
.bubble {
position: relative;
width: min(200px, 60vw);
aspect-ratio: 4 / 3;
margin: 0;
padding: 6px;
border-radius: $r-card;
background: $color;
overflow: hidden;
}
.skeleton, .photo {
position: absolute;
inset: 6px;
border-radius: 10px;
}
.skeleton { background: rgba($stage-ink, .12); overflow: hidden; }
.shine {
position: absolute;
top: 0;
left: -60%;
width: 60%;
height: 100%;
background: linear-gradient(100deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .55), rgba(255, 255, 255, 0));
.bubble.is-demo & { animation: shine-loop $dur-loop $easing infinite; }
}
.photo {
background: linear-gradient(135deg, $subject-blue, $stage-orange);
opacity: 0;
.bubble.is-demo & { animation: photo-in-loop $dur-loop $easing infinite; }
}
.caption {
margin: 4px 2px 0;
font-weight: 600; font-size: clamp(10px, 3vw, 12px); line-height: 1; font-family: inherit;
color: $subject-ink; // 말풍선(크림) 안 스켈레톤 위 글자 — AA 4.5 (blog-design 글자 대비)
opacity: .85;
}
// 셰이머가 두 번 훑고 지나간 뒤 사진이 opacity 로 나타난다
@keyframes shine-loop {
0% { transform: translateX(0); }
60% { transform: translateX(360%); }
100% { transform: translateX(360%); }
}
@keyframes photo-in-loop {
0% { opacity: 0; }
62% { opacity: 0; }
85% { opacity: 1; }
100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.shine, .photo { animation: none !important; }
.photo { opacity: 1; }
.shine { opacity: 0; }
}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);
}
.bubble {
position: relative;
width: min(200px, 60vw);
aspect-ratio: 4/3;
margin: 0;
padding: 6px;
border-radius: 16px;
background: #fff7e6;
overflow: hidden;
}
.skeleton, .photo {
position: absolute;
inset: 6px;
border-radius: 10px;
}
.skeleton {
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.shine {
position: absolute;
top: 0;
left: -60%;
width: 60%;
height: 100%;
background: linear-gradient(100deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0));
}
.bubble.is-demo .shine {
animation: shine-loop 2s ease-out infinite;
}
.photo {
background: linear-gradient(135deg, #2f6df6, #ff4d1f);
opacity: 0;
}
.bubble.is-demo .photo {
animation: photo-in-loop 2s ease-out infinite;
}
.caption {
margin: 4px 2px 0;
font-weight: 600;
font-size: clamp(10px, 3vw, 12px);
line-height: 1;
font-family: inherit;
color: #17141a;
opacity: 0.85;
}
@keyframes shine-loop {
0% {
transform: translateX(0);
}
60% {
transform: translateX(360%);
}
100% {
transform: translateX(360%);
}
}
@keyframes photo-in-loop {
0% {
opacity: 0;
}
62% {
opacity: 0;
}
85% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.shine, .photo {
animation: none !important;
}
.photo {
opacity: 1;
}
.shine {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./ImageBubbleLoading.module.scss";
export type ImageBubbleLoadingProps = {
/** 이미지 로드가 끝났는가 */
loaded: boolean;
duration?: number;
easing?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function ImageBubbleLoading({
loaded,
duration = 600,
easing = "ease-out",
className = "",
}: ImageBubbleLoadingProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<figure className={`${styles.bubble} ${className}`} style={vars}>
<span className={`${styles.skeleton} ${loaded ? styles.fade : ""}`}>
<span className={styles.shine} />
</span>
<span className={`${styles.photo} ${loaded ? styles.show : ""}`} />
</figure>
);
}05 · 画像吹き出しの読み込み — 計測 累積面積 20.786% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.shine {
.bubble.is-demo & { animation: shine-loop $dur-loop $easing infinite; }
}
.photo {
opacity: 0;
.bubble.is-demo & { animation: photo-in-loop $dur-loop $easing infinite; }
}
@keyframes shine-loop {
0% { transform: translateX(0); }
60% { transform: translateX(360%); }
100% { transform: translateX(360%); }
}
@keyframes photo-in-loop {
0% { opacity: 0; }
62% { opacity: 0; }
85% { opacity: 1; }
100% { opacity: 1; }
}
06返信引用スライド
どう動くか
引用元の吹き出しの一部が左からtranslateX(-18px→0)でわずかに滑り込みながらopacityで現れ、新しいメッセージの上に付きます。
どこで使うか
特定のメッセージに返信するスレッド型チャットや、レビュー・コメントの返信に使います。

<main class="stage">
<div class="bubble">
<div class="quote is-demo">夕食どうする?</div>
<p class="reply">チキンは?</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────────
$duration: $dur-quick; // 인용 조각이 밀려 들어오는 데 걸리는 시간
$easing: $ease-out;
$color: $subject-ink; // 말풍선 색 (yellow 무대라 ink)
@include stage($stage-yellow);
.bubble {
width: min(260px, 84vw);
padding: 14px 18px;
border-radius: $r-card;
background: $color;
color: #fff;
}
.quote {
padding: 14px 14px;
margin-bottom: 10px;
border-radius: 8px;
border-left: 3px solid $subject-cream;
background: rgba(255, 255, 255, .1);
font-weight: 500; font-size: clamp(13px, 3.8vw, 14px); line-height: 1.5; font-family: inherit;
color: rgba(255, 255, 255, .75);
&.is-demo { animation: quote-in-loop $dur-loop $easing infinite; }
}
.reply {
margin: 0;
font-weight: 600; font-size: clamp(14px, 4.2vw, 16px); line-height: 1.4; font-family: inherit;
}
// 인용 조각이 왼쪽에서 살짝 밀려 들어와 유지되다 다음 주기를 위해 사라진다
@keyframes quote-in-loop {
0% { transform: translateX(-18px); opacity: 0; }
22% { transform: translateX(0); opacity: 1; }
80% { transform: translateX(0); opacity: 1; }
95% { transform: translateX(-18px); opacity: 0; }
100% { transform: translateX(-18px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.quote.is-demo { animation: none !important; transform: none; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bubble {
width: min(260px, 84vw);
padding: 14px 18px;
border-radius: 16px;
background: #17141a;
color: #fff;
}
.quote {
padding: 14px 14px;
margin-bottom: 10px;
border-radius: 8px;
border-left: 3px solid #fff7e6;
background: rgba(255, 255, 255, 0.1);
font-weight: 500;
font-size: clamp(13px, 3.8vw, 14px);
line-height: 1.5;
font-family: inherit;
color: rgba(255, 255, 255, 0.75);
}
.quote.is-demo {
animation: quote-in-loop 2s ease-out infinite;
}
.reply {
margin: 0;
font-weight: 600;
font-size: clamp(14px, 4.2vw, 16px);
line-height: 1.4;
font-family: inherit;
}
@keyframes quote-in-loop {
0% {
transform: translateX(-18px);
opacity: 0;
}
22% {
transform: translateX(0);
opacity: 1;
}
80% {
transform: translateX(0);
opacity: 1;
}
95% {
transform: translateX(-18px);
opacity: 0;
}
100% {
transform: translateX(-18px);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.quote.is-demo {
animation: none !important;
transform: none;
opacity: 1;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ReplyQuoteSlide.module.scss";
export type ReplyQuoteSlideProps = {
quote: string;
children: ReactNode;
/** 인용 조각이 밀려 들어오는 데 걸리는 시간 (기본 220ms) */
duration?: number;
easing?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function ReplyQuoteSlide({
quote,
children,
duration = 220,
easing = "ease-out",
className = "",
}: ReplyQuoteSlideProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<div className={`${styles.bubble} ${className}`} style={vars}>
<div className={styles.quote}>{quote}</div>
<p className={styles.reply}>{children}</p>
</div>
);
}06 · 返信引用スライド — 計測 累積面積 9.319% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.quote {
border-left: 3px solid $subject-cream;
&.is-demo { animation: quote-in-loop $dur-loop $easing infinite; }
}
@keyframes quote-in-loop {
0% { transform: translateX(-18px); opacity: 0; }
22% { transform: translateX(0); opacity: 1; }
80% { transform: translateX(0); opacity: 1; }
95% { transform: translateX(-18px); opacity: 0; }
100% { transform: translateX(-18px); opacity: 0; }
}
07リアクション絵文字ポップ
どう動くか
吹き出しの角に付く丸いバッジの絵文字がscale(0→1.3→1)とopacityで跳ね上がり、押された瞬間を強調します。
どこで使うか
メッセージに絵文字でリアクションするチャットや、コミュニティ投稿のリアクションボタンに使います。

<main class="stage">
<div class="wrap is-demo">
<div class="bubble">おはよう</div>
<span class="reaction" aria-hidden="true">👍</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────────
$duration: $dur-quick; // 이모지가 튀어 오르는 데 걸리는 시간
$easing: $ease-pop;
$color: $subject-cream; // 말풍선 색 (orange 무대라 cream)
@include stage($stage-orange);
.wrap { position: relative; display: inline-block; }
.bubble {
padding: 16px 24px;
border-radius: $r-card;
background: $color;
color: $stage-ink;
font-weight: 600; font-size: clamp(15px, 4.8vw, 18px); line-height: 1.4; font-family: inherit;
transform-origin: center;
.wrap.is-demo & { animation: bubble-settle-loop $dur-loop $easing infinite; }
}
.reaction {
position: absolute;
right: -16px;
bottom: -16px;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
font-size: 22px;
box-shadow: $shadow-press;
transform: scale(0);
transform-origin: center;
.wrap.is-demo & { animation: reaction-pop-loop $dur-loop $easing infinite; }
}
// 눌린 순간 말풍선도 살짝 눌렸다 돌아오고, 이모지가 오버슈트로 튀어 오른다
@keyframes bubble-settle-loop {
0% { transform: scale(1); }
55% { transform: scale(1); }
66% { transform: scale(.96); }
76% { transform: scale(1); }
100% { transform: scale(1); }
}
@keyframes reaction-pop-loop {
0% { transform: scale(0); opacity: 0; }
55% { transform: scale(0); opacity: 0; }
70% { transform: scale(1.3); opacity: 1; }
80% { transform: scale(1); opacity: 1; }
96% { transform: scale(1); opacity: 1; }
100% { transform: scale(0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.wrap.is-demo .bubble, .wrap.is-demo .reaction { animation: none !important; }
.wrap.is-demo .reaction { transform: scale(1); opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
position: relative;
display: inline-block;
}
.bubble {
padding: 16px 24px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: clamp(15px, 4.8vw, 18px);
line-height: 1.4;
font-family: inherit;
transform-origin: center;
}
.wrap.is-demo .bubble {
animation: bubble-settle-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.reaction {
position: absolute;
right: -16px;
bottom: -16px;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
font-size: 22px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scale(0);
transform-origin: center;
}
.wrap.is-demo .reaction {
animation: reaction-pop-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes bubble-settle-loop {
0% {
transform: scale(1);
}
55% {
transform: scale(1);
}
66% {
transform: scale(0.96);
}
76% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@keyframes reaction-pop-loop {
0% {
transform: scale(0);
opacity: 0;
}
55% {
transform: scale(0);
opacity: 0;
}
70% {
transform: scale(1.3);
opacity: 1;
}
80% {
transform: scale(1);
opacity: 1;
}
96% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.wrap.is-demo .bubble, .wrap.is-demo .reaction {
animation: none !important;
}
.wrap.is-demo .reaction {
transform: scale(1);
opacity: 1;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ReactionEmojiPop.module.scss";
export type ReactionEmojiPopProps = {
children: ReactNode;
emoji?: string;
/** 이모지가 튀어 오르는 데 걸리는 시간 (기본 220ms) */
duration?: number;
easing?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function ReactionEmojiPop({
children,
emoji = "👍",
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
className = "",
}: ReactionEmojiPopProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<div className={`${styles.wrap} ${className}`} style={vars}>
<div className={styles.bubble}>{children}</div>
<span className={`${styles.reaction} ${styles.enter}`} aria-hidden="true">{emoji}</span>
</div>
);
}07 · リアクション絵文字ポップ — 計測 累積面積 3.597% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.reaction {
transform: scale(0);
transform-origin: center;
.wrap.is-demo & { animation: reaction-pop-loop $dur-loop $easing infinite; }
}
@keyframes reaction-pop-loop {
0% { transform: scale(0); opacity: 0; }
55% { transform: scale(0); opacity: 0; }
70% { transform: scale(1.3); opacity: 1; }
80% { transform: scale(1); opacity: 1; }
96% { transform: scale(1); opacity: 1; }
100% { transform: scale(0); opacity: 0; }
}
08日付区切り線の登場
どう動くか
日付が変わる位置のカプセル型区切り線がtranslateY(-14px→0)とopacityで上からわずかに降りてきて会話の間に収まります。
どこで使うか
複数日にまたがるチャットのスクロールや、通知リストの日付グループに使います。

<main class="stage">
<div class="thread">
<span class="divider is-demo">今日</span>
<div class="bubble">おはようございます</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────────
$duration: $dur-base; // 구분선이 내려와 자리잡는 데 걸리는 시간
$easing: $ease-out;
$color: $subject-blue; // 뒤이은 말풍선 색 (paper 무대라 blue)
@include stage($stage-paper);
.thread { display: grid; gap: 14px; justify-items: center; }
.divider {
padding: 6px 16px;
border-radius: $r-pill;
background: rgba($stage-ink, .08);
color: rgba($stage-ink, .6);
font-weight: 700; font-size: clamp(11px, 3.2vw, 12px); line-height: 1; font-family: inherit;
letter-spacing: .04em;
transform-origin: center;
&.is-demo { animation: divider-in-loop $dur-loop $easing infinite; }
}
.bubble {
padding: 16px 26px;
border-radius: $r-card;
background: $color;
color: #fff;
font-weight: 600; font-size: clamp(15px, 4.6vw, 18px); line-height: 1.4; font-family: inherit;
}
// 구분선이 위에서 살짝 내려오며 옅게 나타나고, 다음 주기를 위해 다시 사라진다
@keyframes divider-in-loop {
0% { transform: translateY(-14px) scale(.9); opacity: 0; }
20% { transform: translateY(0) scale(1); opacity: 1; }
82% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-14px) scale(.9); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.divider.is-demo { animation: none !important; transform: none; opacity: 1; }
}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);
}
.thread {
display: grid;
gap: 14px;
justify-items: center;
}
.divider {
padding: 6px 16px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: rgba(23, 20, 26, 0.6);
font-weight: 700;
font-size: clamp(11px, 3.2vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
transform-origin: center;
}
.divider.is-demo {
animation: divider-in-loop 2s ease-out infinite;
}
.bubble {
padding: 16px 26px;
border-radius: 16px;
background: #2f6df6;
color: #fff;
font-weight: 600;
font-size: clamp(15px, 4.6vw, 18px);
line-height: 1.4;
font-family: inherit;
}
@keyframes divider-in-loop {
0% {
transform: translateY(-14px) scale(0.9);
opacity: 0;
}
20% {
transform: translateY(0) scale(1);
opacity: 1;
}
82% {
transform: translateY(0) scale(1);
opacity: 1;
}
100% {
transform: translateY(-14px) scale(0.9);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.divider.is-demo {
animation: none !important;
transform: none;
opacity: 1;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./DateDividerAppear.module.scss";
export type DateDividerAppearProps = {
children: ReactNode;
/** 구분선이 내려와 자리잡는 데 걸리는 시간 (기본 300ms) */
duration?: number;
easing?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function DateDividerAppear({
children,
duration = 300,
easing = "ease-out",
className = "",
}: DateDividerAppearProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<span className={`${styles.divider} ${styles.enter} ${className}`} style={vars}>
{children}
</span>
);
}08 · 日付区切り線の登場 — 計測 累積面積 0.894% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.divider {
transform-origin: center;
&.is-demo { animation: divider-in-loop $dur-loop $easing infinite; }
}
@keyframes divider-in-loop {
0% { transform: translateY(-14px) scale(.9); opacity: 0; }
20% { transform: translateY(0) scale(1); opacity: 1; }
82% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-14px) scale(.9); opacity: 0; }
}
09新着メッセージの下部固定
どう動くか
スクロールがすでに最下部にあるときだけ、新着メッセージがtranslateY(46px→0)でなめらかに押し上がって下端に収まります。zip内のJS版はこの条件をscrollTopの差分で判定し、それ以外は表示位置を動かしません。
どこで使うか
リアルタイムチャットルームの新着受信や、ライブ配信のチャット欄に使います。

<main class="stage">
<div class="thread">
<div class="bubble bubble--in">いいね</div>
<div class="bubble bubble--out is-demo">出発!</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────────
$duration: $dur-base; // 새 메시지가 하단까지 밀려 올라오는 데 걸리는 시간
$easing: $ease-out;
$color: $subject-cream; // 새 메시지 말풍선 색 (ink 무대라 cream)
@include stage($stage-ink);
.thread {
display: grid;
gap: 10px;
justify-items: end;
width: min(220px, 78vw);
}
.bubble {
padding: 12px 18px;
border-radius: $r-card;
font-weight: 600; font-size: clamp(13px, 4vw, 15px); line-height: 1.4; font-family: inherit;
max-width: 80%;
}
.bubble--in {
justify-self: start;
background: rgba(255, 255, 255, .1);
color: #fff;
}
.bubble--out {
background: $color;
color: $stage-ink;
transform-origin: bottom center; // 규칙8: 순수 세로 이동, origin 무의미
&.is-demo { animation: pin-in-loop $dur-loop $easing infinite; }
}
// 스크롤이 바닥에 붙어 있을 때만 새 메시지가 아래에서 밀려 올라와 자리를 잡는다
@keyframes pin-in-loop {
0% { transform: translateY(46px); opacity: 0; }
28% { transform: translateY(0); opacity: 1; }
84% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(46px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.bubble--out.is-demo { animation: none !important; transform: none; opacity: 1; }
}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);
}
.thread {
display: grid;
gap: 10px;
justify-items: end;
width: min(220px, 78vw);
}
.bubble {
padding: 12px 18px;
border-radius: 16px;
font-weight: 600;
font-size: clamp(13px, 4vw, 15px);
line-height: 1.4;
font-family: inherit;
max-width: 80%;
}
.bubble--in {
justify-self: start;
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.bubble--out {
background: #fff7e6;
color: #17141a;
transform-origin: bottom center;
}
.bubble--out.is-demo {
animation: pin-in-loop 2s ease-out infinite;
}
@keyframes pin-in-loop {
0% {
transform: translateY(46px);
opacity: 0;
}
28% {
transform: translateY(0);
opacity: 1;
}
84% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(46px);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.bubble--out.is-demo {
animation: none !important;
transform: none;
opacity: 1;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ScrollPinNewMessage.module.scss";
export type ScrollPinNewMessageProps = {
children: ReactNode;
/** 하단까지 밀려 올라오는 데 걸리는 시간 (기본 300ms) */
duration?: number;
easing?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function ScrollPinNewMessage({
children,
duration = 300,
easing = "ease-out",
className = "",
}: ScrollPinNewMessageProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<div className={`${styles.bubble} ${styles.enter} ${className}`} style={vars}>
{children}
</div>
);
}09 · 新着メッセージの下部固定 — 計測 累積面積 3.932% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.bubble--out {
transform-origin: bottom center;
&.is-demo { animation: pin-in-loop $dur-loop $easing infinite; }
}
@keyframes pin-in-loop {
0% { transform: translateY(46px); opacity: 0; }
28% { transform: translateY(0); opacity: 1; }
84% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(46px); opacity: 0; }
}
どこで崩れるか — 落とし穴
小さすぎて変化が埋もれる
最初に踏んだ落とし穴は04の既読表示です。既読アイコンの.statusは26px四方しかなく、これは480×300のキャンバス全体からすると面積比で1割にも満たない大きさで、数字とチェックをopacityで差し替えるだけでは変化した画素がほとんど埋もれてしまいました。
どう直したか
背後に半透明のradial-gradientでぼかした光の輪を重ね、読まれた瞬間だけscaleとopacityで一度膨らませて消す.glowを足したところ、実測の累積変化面積は23.662%まで伸びました。03の入力中ドットも同じ理由で、ドット3個の上下運動だけでは面積が心もとなく、吹き出し全体をscale(1→1.06→1)でわずかに呼吸させるbubble-breathe-loopを重ねています。ぷっくりした形そのものが主役になるクレイモーフィズムのLPパーツでも、膨らみを担当するのはbox-shadowではなくtransformで、小さな部品ほど面積を稼ぐ工夫が要るという点は同じでした。ここまで直した最終版の9ファイルはe469fwndという文字列がなければ開けない形でzipにまとめてあり、下のカードから取得できます。
clip-path の対応状況
02のしっぽで使ったclip-pathは主要ブラウザで接頭辞なしに動きますが、対応するバージョンの一覧はcaniuseのclip-path対応表で先に確認してください。9つとも動かす値をtransformとopacityだけに絞っているのも同じ計測がきっかけです。この2つは合成の段階だけで完結し、ブラウザ側のレイアウト再計算を伴いません。MDNのCSS・JSアニメーション性能ガイドにもそうまとめられています。
アクセシビリティ
動きを減らす設定では
OSの「視差効果を減らす」設定を有効にした人には、9つともprefers-reduced-motion: reduceの中で結果の状態だけをそのまま見せます。
項目ごとの止まり方
01のポップと06の引用、08の区切り線は浮き上がらず最初から所定の位置で止まり、03のドットは薄いドットのまま静止し、04は数字を消してチェックだけを残し、07のバッジは押された後の大きさで固定されます。
@media (prefers-reduced-motion: reduce) {
.bubble.is-demo, .dot, .divider.is-demo,
.quote.is-demo, .row.is-demo .unread,
.row.is-demo .check, .wrap.is-demo .reaction,
.bubble--out.is-demo { animation: none !important; }
}
他のフィードバック・状態系アニメーションはCSSカテゴリー一覧に、このサイトが何を届けているかはサイト紹介にまとめています。
FAQ
チャットUI以外、トースト通知 css や活動ログにも使えますか?
使えます。01・04・08は「何か新しいものが現れる」画面全般にそのまま流用でき、通知トーストや活動フィードのカードにも自然に馴染みます。03と07はチャット特有の状態を表すため、メッセンジャーや問い合わせチャットのほうが相性が良いです。
Reactではクラスの付け外しの代わりに何を使いますか?
zip内react/フォルダの各コンポーネントは状態をpropsで受け取る形にしてあります。04はread、05はloadedのような真偽値が切り替わるとアニメーションが始まり、モジュールSCSS側はCSS変数だけを参照するのでdurationやeasingもそのままpropsで渡せます。
9つを同じチャット画面に全部置いても大丈夫ですか?
置くこと自体はできますが、1つのメッセージには1つの登場演出で十分です。04の既読、07のリアクション、08の日付区切りのようにそれぞれ別の場面で出るものは、自然に重なりません。トーク一覧を横から引き出す画面まで作るなら、09の下部固定とcss ドロワー メニュー スライドを組み合わせてください。









