채팅 말풍선 CSS 애니메이션 9종 — 한 줄 복붙으로
채팅 말풍선 CSS 애니메이션 아홉 개를 한 번에 모았습니다. 팝 등장부터 입력 중 점, 읽음 표시, 이미지 로딩, 답장 인용, 이모지 반응, 날짜 구분선, 새 메시지 하단 고정까지 메시지 한 통이 화면에서 겪는 순간을 담았고, 전부 transform과 opacity만 움직입니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 말풍선 팝 등장
- 02 꼬리 방향 전환
- 03 입력 중 점 세 개
- 04 읽음 표시 전환
- 05 이미지 말풍선 로딩
- 06 답장 인용 슬라이드
- 07 반응 이모지 팝
- 08 날짜 구분선 등장
- 09 새 메시지 하단 고정
아홉 개는 흔한 인기순이 아니라 메시지 한 통이 겪는 순서로 놓았습니다. 도착해서 눈에 띄고(01), 발신·수신을 구분하고(02), 상대가 입력 중임을 알리고(03), 보낸 메시지가 읽혔는지 확인시키고(04), 사진이 도착하는 과정을 보여주고(05), 이전 대화의 맥락을 잇고(06), 반응을 남기고(07), 날짜가 바뀌었음을 표시하고(08), 마지막엔 새 메시지가 화면 하단에 붙습니다(09). 코드는 흐름만 보이는 조각으로 실었고, 붙여서 바로 돌아가는 완성본은 zip 하나에 다 있습니다. 말풍선 안에서 읽지 않은 개수가 오르내리는 장면이 필요하면 CSS 숫자 증가 애니메이션을, 대화 목록을 카드로 늘어놓는 화면이라면 CSS 카드 호버 효과를 같은 방식으로 정리해 두었습니다.
01말풍선 팝 등장
어떻게 움직이나
새 메시지 말풍선이 scale(0.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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꼬리 방향 전환
css 말풍선 꼬리 만들기는 왜 두 개인가
말풍선 모서리의 삼각형 꼬리를 clip-path로 그리고, 보낸 사람이냐 받은 사람이냐에 따라 말풍선 위치와 꼬리 두 장의 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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입력 중 점 세 개
어떻게 움직이나
점 세 개가 translateY로 하나씩 200ms 간격을 두고 튀어 오르며 상대가 지금 입력 중임을 알립니다.
어디에 쓰나
메신저의 상대방 입력 상태나 챗봇의 응답 대기 표시에 씁니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.dot {
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로 사라지고 체크 아이콘이 커졌다 안착하며 그 자리를 대신합니다.
어디에 쓰나
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.unread { animation: unread-out-loop $dur-loop $easing infinite; }
.check { animation: check-in-loop $dur-loop $easing infinite; }
@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); }
}
05이미지 말풍선 로딩
어떻게 움직이나
이미지가 도착하기 전엔 회색 스켈레톤 위로 하이라이트가 translateX로 훑고 지나가고, 도착하면 사진 레이어가 opacity 0에서 1로 나타나며 스켈레톤을 덮습니다.
어디에 쓰나
사진·움짤을 주고받는 채팅, 상품 이미지가 붙는 문의 채팅에 씁니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.shine { animation: shine-loop $dur-loop $easing infinite; }
.photo { opacity: 0; 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로 살짝 밀려 들어오며 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.reaction { 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.divider { 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)로 부드럽게 밀려 올라와 하단에 자리잡습니다.
어디에 쓰나
실시간 채팅방의 새 메시지 수신이나 라이브 방송 채팅창에 씁니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.bubble--out { transform-origin: bottom center; }
.bubble--out.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; }
}
어디서 깨지는가 — 함정 하나
작아서 안 보였다
카카오톡 말풍선 css 처럼 익숙한 화면을 목표로, 03 입력 중 점과 07 반응 이모지 팝을 처음 만들 때는 배지 지름을 34px로 뒀습니다. 눈으로 다시 봐도 뭐가 움직였는지 잘 안 보였는데, 채팅 UI는 원래 작은 요소가 많아서 실제 화면 크기 그대로 캔버스에 옮기면 애니메이션이 있어도 안 보이는 크기로 접히기 때문입니다.
어떻게 키웠나
그래서 배지를 44px로 키우고 말풍선 전체가 함께 살짝 눌렸다 돌아오도록 곁들였더니, 480×300 캔버스에서 실측한 누적면적이 03은 1.2%, 07은 3.5%까지 늘어 이 사이트가 요구하는 최소 기준(0.08%)을 여유 있게 넘겼습니다.
왜 transform 과 opacity 만 쓰나
아홉 개가 전부 transform과 opacity만 움직이는 것도 같은 이유입니다 — width나 top을 바꾸면 브라우저가 매 프레임 레이아웃을 다시 계산하지만 이 둘은 합성 단계에서 끝나기 때문이고, MDN의 애니메이션 성능 가이드가 이유를 정리해 두었습니다. 02 꼬리 방향에서 쓴 clip-path는 이제 데스크톱·모바일 주요 브라우저가 접두사 없이 지원하지만, 오래된 버전까지 확인하고 싶으면 caniuse의 clip-path 지원표를 먼저 보세요. 두껍고 둥근 그림자로 같은 함정을 피해 간 사례는 클레이모피즘 랜딩 UI 키트에 정리해 두었는데, 거기서도 부풀어 오르는 느낌은 box-shadow가 아니라 transform이 맡습니다.
아홉 개 파일 받기
여기 쓴 값 그대로 담아둔 아홉 개 파일은 zip 하나로 묶여 있고, 압축을 푸는 열쇠는 e469fwnd 입니다. 파일은 바로 아래 버튼에서 받습니다.
접근성
동작 줄이기에서
운영체제의 "동작 줄이기" 설정을 켠 사람에게는 아홉 개 모두 prefers-reduced-motion: reduce 안에서 다르게 반응합니다. 움직임만 걷어 내고 결과 상태는 그대로 보여주는 것이 원칙입니다.
항목별로 멈추는 자리
01 팝 등장과 08 날짜 구분선은 뜨지 않고 바로 제자리에서 보이고, 03 입력 중 점은 흐린 점으로만 남고, 04 읽음 표시는 체크가 즉시 켜집니다. 이 설정을 어떤 브라우저가 읽는지는 MDN의 @media 문서에 정리돼 있습니다.
@media (prefers-reduced-motion: reduce) {
.bubble.is-demo, .dot, .divider.is-demo { animation: none !important; }
}
다른 피드백·상태 애니메이션 주제는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
css 말풍선 만들기를 채팅 말고 알림·댓글창에도 쓸 수 있나요?
네. 01·04·08은 알림 토스트나 활동 로그처럼 "새로운 무언가가 나타난다"는 화면 전부에 그대로 옮겨 씁니다. 03과 07은 채팅 특유의 상태라 메신저·상담 채팅에 더 잘 맞습니다.
React에서는 클래스 토글 대신 뭘 쓰나요?
zip 안 react/ 폴더의 컴포넌트는 상태를 props 하나로 받게 만들었습니다. 04는 read, 05는 loaded처럼 불리언 값이 바뀌면 전환이 일어나고, 모듈 SCSS는 CSS 변수만 읽으므로 duration·easing·color도 그대로 props로 넘기면 됩니다.
아홉 개를 한 채팅 화면에 다 넣어도 되나요?
넣을 수는 있지만 한 메시지에는 한 가지 등장 방식이면 충분합니다. 04 읽음 표시, 07 반응, 08 날짜 구분선처럼 서로 다른 상황에서 뜨는 것들은 자연스럽게 겹치지 않습니다. 대화방 목록을 옆에서 밀어 여는 화면까지 함께 만든다면 CSS 사이드바 드로어 슬라이드를 09 새 메시지 하단 고정과 짝지어 쓰세요.









