04Chuyển trạng thái đã xem
Số chưa đọc mờ đi rồi dấu tick phóng to thế chỗ đúng lúc tin nhắn được xem.
Dùng choXác nhận đã xem trong chat 1:1, xác nhận nhân viên hỗ trợ đã trả lời
opacityscale0.2초

<main class="stage">
<div class="row is-demo">
<span class="glow" aria-hidden="true"></span>
<div class="bubble">Xuất phát đây</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 Chuyển trạng thái đã xem — Diện tích đo được 23.517% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 CSS Chat Bubble Animation — dán 1 dòng là chạy
Bài viết chứa demo này
9 CSS Chat Bubble Animation — dán 1 dòng là chạyCSS chat bubble animation là hiệu ứng bong bóng chat chạy bằng CSS thuần khi tin nhắn xuất hiện hoặc được xem. Chín kiểu Xem tất cả demo trong bài này


