9 CSS Chat Bubble Animations, One Line, Copy-Paste
A css chat bubble animation is the transform-and-opacity motion a bubble makes as it pops in, waits, gets read, or reacts — nine of them are collected here.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Bubble pop-in
- 02 Tail direction switch
- 03 Typing indicator dots
- 04 Read receipt transition
- 05 Image bubble loading
- 06 Reply quote slide
- 07 Reaction emoji pop
- 08 Date divider appear
- 09 New message auto-scroll pin
The nine below follow one message's trip across the screen rather than any popularity ranking. 01 and 02 cover how a bubble first appears and which way its tail points. 03 through 05 cover waiting states — someone typing, a message getting read, a photo still loading. 06 and 07 layer context and feedback onto a bubble that's already on screen: a quoted reply and a tapped reaction. 08 and 09 sit outside any single bubble, marking a new day and keeping the freshest message in view. Watch the grid above run through all nine without any input: each thumbnail lives inside a fixed, click-proof iframe. Every state plays on a timer instead of waiting for a cursor no iframe can receive. If the unread badge beside a thread has to roll up rather than jump, the CSS number counter animations set covers that digit motion, and a thread list laid out as tiles is closer to the CSS card hover effects set.
01Bubble pop-in
How it works
A new message bubble scales up from 0.4, overshoots past its full size, then settles back to a scale of 1 while its opacity rises from 0 to 1 across the same span.
Where it fits
It fits a fresh bubble in a one-on-one thread or a preview bubble inside a notification toast — anywhere a message needs to read as having just landed.

<main class="stage">
<div class="bubble is-demo">Just landed!</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 · Bubble pop-in — Measured area 13.073% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
.bubble--new {
transform-origin: center;
animation: bubble-pop $duration $easing;
}
@keyframes bubble-pop {
0% { transform: scale(.4); opacity: 0; }
65% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
02Tail direction switch
Why the css chat bubble tail needs two shapes
A triangular tail carved with clip-path: polygon() sits at the bubble's bottom corner, and the whole bubble slides left or right depending on whether the message is received or sent. A matching left or right tail fades in at the same time to line up with it.
Where it fits
It's the piece that lets a one-on-one or group thread tell sender from recipient without reading a single word.

<main class="stage">
<div class="bubble is-demo">
See you soon
<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 · Tail direction switch — Measured area 8.289% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
.bubble__tail {
position: absolute;
bottom: 10px;
width: 14px;
height: 14px;
background: currentColor;
}
.bubble--in .bubble__tail {
left: -6px;
clip-path: polygon(100% 0, 100% 100%, 0 50%);
}
.bubble--out .bubble__tail {
right: -6px;
clip-path: polygon(0 0, 0 100%, 100% 50%);
}
03Typing indicator dots
How it works
Three dots bounce up and down one after another, each one starting its translateY cycle a fraction of a second behind the last. The motion reads as a single wave instead of three dots moving in sync.
Where it fits
It signals that the other person in a messenger thread — or a chatbot mid-response — is typing right now.

<main class="stage">
<div class="wrap">
<p class="label">Typing</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 · Typing indicator dots — Measured area 1.219% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
.dot {
animation: dot-bounce $duration $easing infinite;
}
.dot:nth-child(2) { animation-delay: .2s; }
.dot:nth-child(3) { animation-delay: .4s; }
@keyframes dot-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: .4; }
30% { transform: translateY(-9px); opacity: 1; }
}
04Read receipt transition
How it works
The unread count fades and shrinks away via opacity and scale the instant a message is read, and a check icon overshoots past full size before settling into the spot the count left behind.
Where it fits
It confirms a read receipt in a one-on-one chat, or lets a support agent see that their reply actually reached the customer.

<main class="stage">
<div class="row is-demo">
<span class="glow" aria-hidden="true"></span>
<div class="bubble">Heading out</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 · Read receipt transition — Measured area 23.517% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
.status {
position: relative;
width: 20px;
height: 20px;
}
.unread, .check {
position: absolute;
inset: 0;
}
.status--read .unread {
animation: fade-out $duration $easing forwards;
}
.status--read .check {
animation: fade-in $duration $easing forwards;
}
@keyframes fade-out {
to { opacity: 0; transform: scale(.4); }
}
@keyframes fade-in {
from { opacity: 0; transform: scale(.4); }
to { opacity: 1; transform: scale(1); }
}
05Image bubble loading
How it works
A gray skeleton panel holds a diagonal highlight that sweeps across it on transform: translateX() while the photo underneath waits at opacity: 0, then the photo fades in once it's ready.
Where it fits
It covers any chat that exchanges photos or GIFs, including a support thread where a customer attaches a product image.

<main class="stage">
<figure class="bubble is-demo">
<span class="skeleton">
<span class="shine"></span>
</span>
<span class="photo"></span>
<figcaption class="caption">Sending photo</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 · Image bubble loading — Measured area 20.786% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
.skeleton {
position: absolute;
inset: 0;
overflow: hidden;
background: rgba(0, 0, 0, .08);
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .5), transparent);
animation: shine 1.4s linear infinite;
}
.photo {
opacity: 0;
transition: opacity $duration $easing;
}
.bubble--loaded .photo { opacity: 1; }
@keyframes shine {
to { transform: translateX(100%); }
}
06Reply quote slide
How it works
A quoted snippet of the original message slides in a few pixels from the left while fading in from opacity: 0, sitting just above the new reply so it's clear which line prompted the answer.
Where it fits
It suits a thread-style chat that replies to one specific message, or a review or comment section built the same way.

<main class="stage">
<div class="bubble">
<div class="quote is-demo">Dinner plans?</div>
<p class="reply">Chicken?</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 · Reply quote slide — Measured area 9.319% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
.quote {
border-left: 3px solid var(--accent);
opacity: 0;
transform: translateX(-18px);
animation: quote-in $duration $easing forwards;
}
@keyframes quote-in {
to { opacity: 1; transform: translateX(0); }
}
07Reaction emoji pop
How it works
An emoji badge pinned to the bubble's corner scales from 0 past 1.3 before settling at 1, and the bubble itself dips slightly and springs back at the same moment. The whole thing reads as one tap landing.
Where it fits
It's built for a chat where people react to messages with emoji, or a reaction button on a community post.

<main class="stage">
<div class="wrap is-demo">
<div class="bubble">Morning!</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 · Reaction emoji pop — Measured area 3.597% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
.reaction {
transform: scale(0);
transform-origin: center;
animation: reaction-pop $duration $easing forwards;
}
@keyframes reaction-pop {
0% { transform: scale(0); opacity: 0; }
65% { transform: scale(1.3); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
08Date divider appear
How it works
A pill-shaped date divider eases down a few pixels while fading in via opacity, then settles into place between two days' worth of messages.
Where it fits
It marks where one day's conversation ends and the next begins when scrolling back through a chat, or where a notification list breaks into date groups.

<main class="stage">
<div class="thread">
<span class="divider is-demo">Today</span>
<div class="bubble">Good morning</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 · Date divider appear — Measured area 0.894% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
.divider {
opacity: 0;
transform: translateY(-14px) scale(.9);
animation: divider-in $duration $easing forwards;
}
@keyframes divider-in {
to { opacity: 1; transform: translateY(0) scale(1); }
}
09New message auto-scroll pin
How it works
A new outgoing bubble slides up from just below the visible thread and fades in as it arrives, but only when the thread was already scrolled to the bottom. Scroll up to read older messages and the newest bubble waits quietly off-screen instead of yanking the view down.
Where it fits
That condition takes a few lines of JavaScript comparing scrollTop against scrollHeight; the CSS below only covers the motion itself, once the script decides to add the class.

<main class="stage">
<div class="thread">
<div class="bubble bubble--in">Sounds good</div>
<div class="bubble bubble--out is-demo">On my way!</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 · New message auto-scroll pin — Measured area 3.932% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
.bubble--out {
transform-origin: bottom center;
animation: pin-in $duration $easing forwards;
}
@keyframes pin-in {
0% { transform: translateY(46px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
Where the tail trick breaks
Every one of these nine only ever moves transform and opacity. Nothing here touches width, top, or left while it's animating, because those properties force the browser to recompute layout on every frame instead of handing the frame straight to compositing. MDN's guide to animation performance walks through why that split matters.
The mirrored-tail shortcut
Item 02's tail, the css speech bubble arrow every thread needs, looks like the kind of thing a single flipped shape should handle: draw one triangle, then mirror it with transform: scaleX(-1) when a message switches from received to sent. That shortcut breaks the moment the bubble carries any text or an asymmetric border-radius, because scaleX(-1) mirrors the whole element it's applied to, not just the point of a shape. A label sitting inside reads backward, and a corner rounded unevenly on purpose flips to the wrong side.
Two triangles instead
The demo instead keeps two separate triangles, .bubble--in and .bubble--out, each cut with its own mirrored clip-path: polygon() coordinates, and only crossfades which one is visible. clip-path itself needs no prefix in any current desktop or mobile browser, and caniuse's clip-path table covers exactly how far back that support reaches for anyone shipping to older devices. The claymorphism UI kit sections lean on the same discipline from the opposite direction: their puffed-up corners are painted once and then animated with transform alone, never by re-cutting the shape mid-frame the way a mirrored tail would.
What's in the archive
All nine files built this same way sit in one archive, and the key that opens it is e469fwnd, typed in on its own with nothing extra added around it.
Accessibility
Reduced motion swaps the approach
prefers-reduced-motion: reduce doesn't just mute these nine — it swaps the animated approach for the same end state landing without a run-up, item by item. 01's pop, 06's quote slide, 07's reaction, and 08's divider each skip straight to their finished position: full size, full opacity, in place. 02's tail transition and 04's read-receipt swap behave the same way, jumping straight to whichever side or icon is currently correct instead of crossfading into it. 03's dots stop bouncing and sit as three flat, low-opacity marks instead. Motion is what signals typing in the first place, so reduced motion trades that signal for a static placeholder rather than dropping it outright.
The auto-scroll exception
09's auto-scroll pin keeps its conditional logic intact — a message still only jumps into view when the thread is already at the bottom — but drops the sliding entrance itself. Chrome, Firefox, and Safari all read this setting from the operating system's own motion toggle; MDN's @media reference lays out where each platform exposes it.
@media (prefers-reduced-motion: reduce) {
.bubble--new, .dot, .divider { animation-duration: .01ms !important; }
}
The rest of this site's feedback and state animations live on the CSS animation hub; what this project does and doesn't sell is on the about page.
FAQ
Can I use a single css chat bubble animation instead of pulling in all nine?
Yes — each one is scoped to its own class or two, .bubble--new, .status--read, .bubble--loaded, and so on, with no shared state or parent selector holding the set together. Copying one item's SCSS and its matching markup is enough on its own; nothing here depends on the other eight being present in the same file.
How is a css chat bubble animation different from a plain hover effect?
None of these nine wait for a pointer at all — every one fires when a state changes: a message arrives, gets read, or a thread scrolls to the bottom. So touch devices and keyboard-only sessions see the exact same motion a mouse would trigger. A hover effect needs a cursor sitting over something by definition, which rules it out for a phone screen or a screen-reader user tabbing through a thread.
What if I want a css message bubble to pop in just once instead of looping?
The grid demo above loops every animation continuously so it's visible without waiting for a real message, but most of the SCSS shown fires once per class toggle by default. Two exceptions already ship with infinite built into their @keyframes calls — 03's typing dots and 05's loading shimmer both need to keep moving for as long as their state holds. Everything else stays a single-run transition, triggered by a script adding a class when the matching event happens. If the thread list beside these bubbles also has to slide in from the edge, pair item 09's bottom pin with the CSS sidebar drawer demos, which run on the same one-class-toggle rule.









