9 CSS Text Animation Effects — dán 1 dòng là chạy
CSS text animation effects là các chuyển động chữ dựng thuần bằng CSS, không cần JavaScript, giúp tiêu đề hay câu chữ xuất hiện, nhấn mạnh hoặc lặp lại.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Lộ chữ từ mặt nạ dưới lên
- 02 Nảy từng chữ
- 03 Xoay vào khung hình
- 04 Lật chữ 3D
- 05 Mờ rồi nét dần
- 06 Chữ gợn sóng
- 07 Lắc nhẹ nhấn mạnh
- 08 Quét hai tông màu
- 09 Glitch
Chín hiệu ứng xếp theo thứ tự chữ trải qua trên màn hình, không phải theo độ nổi tiếng. Năm kiểu đầu (01–05) là lúc chữ xuất hiện lần đầu, rồi đến một trạng thái chờ lặp mãi sau khi đã hiện ra (06), tiếp theo hai kiểu nhấn mạnh khi câu chữ đã đứng yên trên màn hình (07–08), và khép lại bằng một trạng thái trông bất ổn như màn hình vừa lỗi (09). Mỗi mục dưới đây chỉ giữ lại rule cốt lõi, còn file đầy đủ kèm markup nằm trong zip.
01Lộ chữ từ mặt nạ dưới lên
Tiêu đề nằm trong khung overflow:hidden bị cắt gọn, còn chữ bên trong trượt lên bằng translateY từ ngoài mép dưới về đúng vị trí, lộ ra một lượt như kéo một tấm rèm. Khung không hề di chuyển nên bố cục xung quanh đứng yên tuyệt đối trong lúc chữ tự đi lên.
.mask {
overflow: hidden;
max-width: min(380px, 90vw);
}
.mask__text {
color: #fff7e6;
animation: mask-up 2s cubic-bezier(.2, .8, .2, 1) infinite;
}
@keyframes mask-up {
0%, 4% { transform: translateY(115%); }
30%, 78% { transform: translateY(0); }
96%, 100% { transform: translateY(115%); }
}
02Nảy từng chữ
Mỗi chữ trong tiêu đề nằm trong một thẻ span riêng, được gắn animation-delay lệch nhau 40ms qua biến --i, nên chữ nảy lên từ dưới và rơi đúng vị trí lần lượt chứ không cùng lúc. Vì công thức trễ chỉ dựa vào --i, thêm bớt chữ chỉ cần đánh lại số thứ tự chứ không phải sửa keyframes.
.letters span {
display: inline-block;
animation: letter-bounce 2s cubic-bezier(.2, 1.4, .4, 1) infinite;
animation-delay: calc(var(--i) * 40ms);
}
@keyframes letter-bounce {
0%, 4% { opacity: 0; transform: translateY(22px); }
22%, 70% { opacity: 1; transform: translateY(0); }
88%, 100% { opacity: 0; transform: translateY(-14px); }
}
03Xoay vào khung hình
Mỗi chữ chuyển rotate và opacity cùng lúc từ -25 độ về 0 độ, trông như bay vào lúc còn nghiêng rồi tự đứng thẳng lại. Vì mỗi chữ tự có delay riêng qua --i, cụm chữ ngắn kiểu badge hay tag xoay vào theo đúng thứ tự đọc.
.rot span {
animation: rotate-in 300ms ease-out infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes rotate-in {
0%, 4% { opacity: 0; transform: rotate(-25deg); }
26%, 72% { opacity: 1; transform: rotate(0deg); }
90%, 100% { opacity: 0; transform: rotate(18deg); }
}
04Lật chữ 3D
Bên trong phần tử cha mang perspective, mỗi chữ ẩn mặt sau bằng backface-visibility rồi quay quanh rotateX từ 90 độ về 0 độ, lật ra phía trước như một lá bài nhỏ. Cảm giác lật rõ nhất khi con số hay giá tiền vừa đổi, vì mắt dễ bắt được đúng khoảnh khắc chữ đang xoay ngang.
.flip { perspective: 480px; }
.flip span {
backface-visibility: hidden;
animation: flip-in 600ms cubic-bezier(.2, .8, .2, 1) infinite;
animation-delay: calc(var(--i) * 70ms);
}
@keyframes flip-in {
0%, 4% { opacity: 0; transform: rotateX(90deg); }
30%, 74% { opacity: 1; transform: rotateX(0deg); }
94%, 100% { opacity: 0; transform: rotateX(-90deg); }
}
05Mờ rồi nét dần
filter: blur chuyển từ 14px về 0 cùng lúc opacity đi từ 0 lên 1, nên câu chữ nét dần lên đúng như đang lấy nét ống kính. Không một thuộc tính transform nào được động tới, vị trí chữ đứng yên suốt, chỉ độ sắc nét thay đổi.
.blur {
animation: blur-focus 2s ease-out infinite;
}
@keyframes blur-focus {
0%, 4% { opacity: 0; filter: blur(14px); }
30%, 76% { opacity: 1; filter: blur(0); }
96%, 100% { opacity: 0; filter: blur(14px); }
}
06Chữ gợn sóng
Mọi chữ chạy chung một đoạn keyframes translateY từ 0 lên -16px rồi về 0, chỉ khác delay 90ms mỗi chữ qua --i, nên riêng độ lệch delay đã đủ khiến cả dòng gợn sóng liên tục. Vòng lặp không có điểm vào hay điểm ra, hợp với chữ đứng chờ khi trang đang tải.
.wave span {
animation: wave-up 2s ease-out infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes wave-up {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
07Lắc nhẹ nhấn mạnh
Cả cụm chữ xoay qua lại rất nhẹ giữa rotate 1-2 độ và translateX 2px, yếu hơn hẳn một cú rung báo lỗi, chỉ đủ kéo mắt liếc qua một lần. 60% đầu vòng lặp đứng yên hoàn toàn, chữ chỉ lắc trong khoảng 66-90%, nên dù xung quanh có chuyển động khác, khoảnh khắc lắc đó vẫn nổi bật.
.wiggle {
animation: wiggle 2s ease-out infinite;
}
@keyframes wiggle {
0%, 60%, 100% { transform: rotate(0) translateX(0); }
66% { transform: rotate(-2deg) translateX(-2px); }
72% { transform: rotate(2deg) translateX(2px); }
78% { transform: rotate(-2deg) translateX(-2px); }
84% { transform: rotate(2deg) translateX(2px); }
90% { transform: rotate(0) translateX(0); }
}
08Quét hai tông màu
Cùng một câu chữ được xếp chồng hai lớp màu khác nhau, còn clip-path của lớp trên quét từ trái sang phải để màu lớp dưới lộ dần ra. Ranh giới giữa hai lớp luôn rõ và cứng chứ không mờ dần như gradient, vì clip-path cắt dứt khoát chứ không nội suy màu.
.sweep__top {
position: absolute;
inset: 0;
animation: sweep-wipe 2s cubic-bezier(.2, .8, .2, 1) infinite;
}
@keyframes sweep-wipe {
0%, 4% { clip-path: inset(0 100% 0 0); }
40%, 62% { clip-path: inset(0 0 0 0); }
96%, 100% { clip-path: inset(0 100% 0 0); }
}
09Glitch
Cùng một câu chữ chồng hai lớp khác màu bị clip-path cắt thành từng dải mỏng, mỗi lớp lệch translateX vài pixel trong chốc lát rồi trả lại đúng vị trí cũ. Độ lệch lặp lại hai lượt trong một vòng thay vì chỉ một lần, nên cảm giác màn hình vỡ giống một lỗi thật hơn là một cú rung đơn.
.glitch__r { mix-blend-mode: screen; animation: glitch-r 2s linear infinite; }
@keyframes glitch-r {
0% { transform: translateX(0); clip-path: inset(0 0 0 0); }
8% { transform: translateX(-6px); clip-path: inset(12% 0 58% 0); }
16% { transform: translateX(4px); clip-path: inset(48% 0 14% 0); }
24% { transform: translateX(0); clip-path: inset(0 0 0 0); }
50% { transform: translateX(-4px); clip-path: inset(20% 0 40% 0); }
58% { transform: translateX(5px); clip-path: inset(55% 0 8% 0); }
66% { transform: translateX(0); clip-path: inset(0 0 0 0); }
}
Chỗ dễ hỏng ở đâu? — bẫy stagger và bẫy màu
Bốn mục dùng biến --i cùng calc() để tính độ trễ — 02, 03, 04, 06 — đặt phép tính ngay trên animation-delay của từng chữ, không phải trên phần tử cha. Điều đó cho phép thêm bớt chữ mà không phải sửa SCSS, nhưng đổi lại, nếu bạn gắn thêm một transition opacity trên phần tử cha bọc ngoài, transition đó không hề biết --i là gì nên toàn bộ chữ mờ dần cùng một nhịp thay vì lệch nhau theo đúng thứ tự đã thấy trong demo. Độ trễ luôn phải nằm trên animation của chính từng chữ.
Bẫy thứ hai nằm ở độ sáng. Mục 09 glitch chồng hai lớp đỏ và xanh bằng mix-blend-mode: screen để giả lập màu bị tách ra, nhưng theo trang mix-blend-mode của MDN, screen luôn hoà hai màu về phía trắng, nên nền càng sáng thì hiệu ứng càng bị xoá nhoè. Vì lý do đó mục 09 chỉ đứng trên nền tối, còn muốn dùng trên nền sáng thì phải đổi screen sang difference hoặc một lớp màu đặc có opacity thay vì blend. Đo thật trên khung hình cho thấy mục 06 chuyển động ở 23 trên 24 khung đo được, trong khi mục 07 chỉ chuyển động ở 7 khung, và chín file đã đóng gói cũng được tinh chỉnh theo đúng sự khác biệt đó — mở bằng mật khẩu 97me82wy gõ đúng như nó hiện trên dòng này.
Khả năng tiếp cận
Cả chín hiệu ứng đều tắt animation khi prefers-reduced-motion: reduce được bật, nhưng trạng thái để lại không giống nhau. Mục 01 và 05 giữ nguyên opacity 1 cùng transform hoặc filter rỗng nên hiện thẳng ra, còn nhóm chia chữ theo span (02, 03, 04, 06) cũng khoá opacity 1 và transform none cho từng chữ cùng lúc, không còn thứ tự lệch nhau. Mục 07 và 09 chỉ đóng vai trang trí thuần tuý nên có thể tắt hẳn không để lại dấu vết, riêng mục 08 giữ nguyên clip-path ở trạng thái đã quét hết vì màu chính là thông tin cần đọc được ngay. Mức hỗ trợ trình duyệt của media query này được liệt kê trên trang prefers-reduced-motion của MDN.
@media (prefers-reduced-motion: reduce) {
.mask__text, .blur, .wiggle { animation: none; transform: none; filter: none; opacity: 1; }
.letters span, .rot span, .flip span, .wave span { animation: none; opacity: 1; transform: none; }
.sweep__top { animation: none; clip-path: inset(0 0 0 0); }
.glitch__r, .glitch__b { animation: none; opacity: 0; }
}
Các hiệu ứng chữ khác nằm chung trong chuyên mục CSS, còn trang giới thiệu nói rõ site này đóng gói mỗi bài thành file zip có khoá như thế nào.
FAQ
Chỉ cần một hiệu ứng, không lấy hết chín cái, có được không?
Được. Mỗi hiệu ứng gói gọn trong một class riêng và không chia sẻ biến hay keyframes với mục khác, nên copy đúng phần SCSS cùng đoạn HTML đi kèm là chạy độc lập ngay. Mục 01 và 05 là hai bản đơn giản nhất nếu bạn chỉ cần một chuyển động, vì cả hai không cần chia chữ ra thành nhiều span.
Đổi tốc độ hoặc thời lượng animation ở đâu?
Mỗi file SCSS khai một biến $duration hoặc thời lượng ngay trong khai báo animation ở đầu file, nên chỉ cần sửa một con số đó là toàn bộ nhịp đổi theo. Bốn mục dùng --i còn có thêm một khoảng trễ mỗi chữ (40ms, 70ms, 90ms tuỳ mục) khai riêng ngay cạnh, tách hẳn khỏi thời lượng animation.
Dùng chung với Tailwind hoặc dự án React được không?
Được. Bản demo biên dịch từ SCSS ra CSS thuần nên không kéo theo công cụ build nào, còn một rule @keyframes tuỳ biến như thế này vẫn khai animation ngoài bộ utility mặc định của Tailwind, vì hiệu ứng chữ dạng này hiếm khi có sẵn utility dựng sẵn tương ứng. Thư mục react/ trong zip có sẵn một component riêng cho mỗi hiệu ứng, nhận số chữ và thời lượng qua props.