GODRICH
#css-animation#button#loader#transition#scroll#ui#xu-huong-thiet-ke

9 kiểu css typing animation, copy dán là chạy

Css typing animation là hiệu ứng mô phỏng việc gõ chữ bằng CSS thuần, dựng bằng steps() và một con trỏ nhấp nháy thay cho JavaScript gõ tay từng ký tự.

Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip

Thứ tự chín hiệu ứng đi từ một con trỏ đứng yên đến một câu tự đổi chữ giữa chừng. 01 chỉ animate opacity của một thanh con trỏ. 02, 04, 05, 07, 09 đều cho width chạy theo steps() với số bước khác nhau — một dòng, nhiều dòng, gõ rồi xoá, có phản hồi, hoặc ba từ giấu chồng lên nhau. 03 và 06 rời khỏi CSS thuần, dùng JavaScript đo pixel hoặc ngẫu nhiên hoá nhịp gõ rồi giao lại cho Element.animate(). 08 đổi hẳn sang SVG. Trong khung xem phía trên, bạn xem chín ô cùng chạy một vòng lặp ngắn cố định; bản gõ đúng theo độ dài chữ thật nằm trong file zip.

01Con trỏ nhấp nháy

Bạn thấy gì

Ngay sau chữ đã gõ xong là một thanh con trỏ rộng đúng bằng một ký tự, cho opacity nhấp nháy bằng step-end thay vì mờ dần, để trông giống hệt con trỏ gõ chữ thật trên máy tính.

Con trỏ nhấp nháy
Mở trong tab mới

01 · Con trỏ nhấp nháy — Diện tích đo được 0.316% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

steps(2, jump-none)opacity 깜빡임@keyframes caret-blink

Dùng ở đâu

Hợp cho placeholder của form cần nhấn mạnh, trạng thái chờ của ô chat.

.line__caret {
  display: inline-block;
  width: clamp(8px, 2.6vw, 14px);
  height: 1.3em;
  margin-left: clamp(2px, 1vw, 6px);
  background: $color;
  animation: caret-blink $duration $easing infinite;
}

@keyframes caret-blink {
  from { opacity: 1; }
  to   { opacity: 0; }
}

02Gõ chữ bằng steps()

Chuyện gì diễn ra trên màn hình

Width chạy từ 0 đến đúng độ rộng chữ bằng steps(10, end), mỗi bước nhảy đúng một ký tự như máy đánh chữ thật gõ từng nhát.

Hiệu ứng gõ chữ bằng steps()
Mở trong tab mới

02 · Hiệu ứng gõ chữ bằng steps() — Diện tích đo được 3.509% · khung hình chuyển động 12/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

steps(n)ch 단위white-space: nowrap

Chỗ dùng

Hợp cho tiêu đề hero và slogan trang landing, nơi cần một câu gõ ra thật chậm rãi.

.type__text {
  display: inline-block;
  overflow: hidden;
  white-space: nowrap;
  width: 0;
  border-right: .12em solid $color;
  animation: type-loop $duration linear infinite;
}

@keyframes type-loop {
  0%, 6%    { width: 0; animation-timing-function: steps(10, end); }
  58%       { width: var(--full, 10ch); }
  88%       { width: var(--full, 10ch); }
  94%, 100% { width: 0; }
}

03Gõ chữ ghép đa ngôn ngữ

Chuyển động thế nào

steps()+ch giả định font đều nét, nên chữ có dấu tiếng Việt, chữ Hán hay emoji trộn lẫn sẽ bị cắt giữa ký tự. JavaScript đo bề rộng pixel thật của từng ký tự bằng canvas rồi dùng clip-path cắt đúng ranh giới đó.

Gõ chữ ghép đa ngôn ngữ
Mở trong tab mới

03 · Gõ chữ ghép đa ngôn ngữ — Diện tích đo được 2.801% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

clip-path 스텝measureText가변폭 대응

Dùng khi nào

Hợp cho tên sản phẩm trộn Latin, CJK và emoji trong một trang landing quốc tế hoá.

var el = document.getElementById('gtext'), s = el.textContent;
var cv = document.createElement('canvas').getContext('2d');
cv.font = getComputedStyle(el).font;
var w = 0, cum = [0];
for (var i = 0; i < s.length; i++) {
  w += cv.measureText(s[i]).width;
  cum.push(w);
}
var kf = cum.map(function (px) {
  return { clipPath: 'inset(0 ' + (100 - px / w * 70) + '% 0 0)', offset: px / w * .7 };
});
kf.push({ clipPath: 'inset(0 0% 0 0)', offset: .88 }, { clipPath: 'inset(0 100% 0 0)', offset: 1 });
el.animate(kf, { duration: 2000, iterations: Infinity });

04Gõ chữ nhiều dòng

Bạn thấy gì

Mỗi dòng có keyframes steps() riêng, độ trễ đến từ khoảng đầu mỗi keyframes giữ width bằng 0 (0%, 10%, 34%), nên dòng dưới chỉ bắt đầu khi dòng trên gõ hết chữ.

Gõ chữ nhiều dòng
Mở trong tab mới

04 · Gõ chữ nhiều dòng — Diện tích đo được 8.264% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

줄마다 steps()퍼센트 구간 분리overflow: hidden

Dùng ở đâu

Hợp cho phần mở đầu editor code hoặc thẻ giới thiệu bản thân nhiều dòng.

.lines__text {
  overflow: hidden;
  white-space: nowrap;
  width: 0;
  border-right: .1em solid $color;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;

  &--1 { animation-name: l1-type; }
  &--2 { animation-name: l2-type; }
  &--3 { animation-name: l3-type; }
}

@keyframes l2-type {
  0%, 10%   { width: 0; animation-timing-function: steps(16, end); }
  34%       { width: var(--full, 16ch); }
  88%       { width: var(--full, 16ch); }
  96%, 100% { width: 0; }
}

05Gõ rồi xoá rồi gõ lại

Chuyện gì diễn ra trên màn hình

Một khối keyframes duy nhất cho width chạy từ 0 lên 100% rồi về lại 0, gõ ra một câu, dừng lại một nhịp rồi tự xoá, không cần bất kỳ dòng JavaScript nào điều khiển.

Gõ rồi xoá rồi gõ lại
Mở trong tab mới

05 · Gõ rồi xoá rồi gõ lại — Diện tích đo được 3.679% · khung hình chuyển động 18/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

단일 keyframes0→100%→0hold 구간

Chỗ dùng

Hợp cho câu gợi ý trong ô chat trống hoặc placeholder ô tìm kiếm tự đổi.

.search__text {
  overflow: hidden;
  white-space: nowrap;
  width: 0;
  border-right: .1em solid $color;
  animation: search-loop $duration linear infinite;
}

@keyframes search-loop {
  0%, 6%    { width: 0; animation-timing-function: steps(11, end); }
  46%       { width: var(--full, 11ch); }
  62%       { width: var(--full, 11ch); animation-timing-function: steps(11, end); }
  96%, 100% { width: 0; }
}

06Cảm giác AI trả lời theo dòng

Chuyển động thế nào

Thay vì steps() với khoảng cách cố định, một vòng lặp JavaScript nối từng chữ vào với khoảng nghỉ ngẫu nhiên 30-90ms, mô phỏng nhịp lệch của một câu trả lời AI đang trôi ra theo từng token.

Cảm giác AI trả lời theo dòng
Mở trong tab mới

06 · Cảm giác AI trả lời theo dòng — Diện tích đo được 2.514% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

랜덤 간격 JSel.animate오브 커서 pulse

Dùng khi nào

Hợp cho khu vực trả lời của chatbot hoặc hiệu ứng chờ kết quả prompt.

var el = document.getElementById('atext'), s = el.textContent;
var n = s.length, d = [], sum = 0;
for (var i = 0; i < n; i++) {
  var r = 30 + Math.random() * 60;
  d.push(r); sum += r;
}
var acc = 0, kf = [{ clipPath: 'inset(0 100% 0 0)', offset: 0 }];
for (var i = 0; i < n; i++) {
  acc += d[i];
  kf.push({ clipPath: 'inset(0 ' + (100 - acc / sum * 78) + '% 0 0)', offset: acc / sum * .78 });
}
el.animate(kf, { duration: 2000, iterations: Infinity });

07Kiểu dòng lệnh terminal

Bạn thấy gì

Lệnh gõ ra sau dấu $ bằng steps(13, end), rồi dòng phản hồi hiện dần bằng opacity ngay khi animation gõ lệnh vừa kết thúc, ghép thành một cửa sổ terminal giả gồm hai giai đoạn nối tiếp.

Kiểu dòng lệnh terminal
Mở trong tab mới

07 · Kiểu dòng lệnh terminal — Diện tích đo được 1.057% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

명령어→응답 순서monospace 터미널 폰트트래픽라이트 장식

Dùng ở đâu

Hợp cho phần giới thiệu công cụ dev hoặc demo hero của một sản phẩm CLI.

.term__cmd {
  overflow: hidden;
  white-space: nowrap;
  width: 0;
  border-right: .1em solid $color;
  animation: cmd-type $duration linear infinite;
}
.term__resp {
  animation: resp-fade $duration ease-out infinite;
}

@keyframes cmd-type {
  0%, 5%    { width: 0; animation-timing-function: steps(13, end); }
  45%       { width: var(--full, 13ch); }
  85%       { width: var(--full, 13ch); }
  95%, 100% { width: 0; }
}

08Hiệu ứng chữ viết tay SVG

Chuyện gì diễn ra trên màn hình

stroke-dasharray của path SVG được đặt đúng bằng chiều dài đường nét, rồi stroke-dashoffset chuyển dần từ chiều dài đó về 0, khiến chữ viết tay trông như đang được vẽ dần theo từng nét bút.

Hiệu ứng chữ viết tay SVG
Mở trong tab mới

08 · Hiệu ứng chữ viết tay SVG — Diện tích đo được 1.597% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

stroke-dasharraystroke-dashoffsetpath 길이 측정

Chỗ dùng

Hợp cho hiệu ứng chữ ký hoặc thiệp mời sự kiện phong cách viết tay.

.sig__svg path {
  stroke: $color;
  stroke-width: 5;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 520;
  stroke-dashoffset: 520;
  animation: draw $duration $easing infinite;
}

@keyframes draw {
  0%, 6%    { stroke-dashoffset: 520; }
  55%       { stroke-dashoffset: 0; }
  80%       { stroke-dashoffset: 0; }
  96%, 100% { stroke-dashoffset: 520; }
}

09Gõ chữ đổi từ

Chuyển động thế nào

Ba từ xếp chồng đúng một vị trí, giữ width bằng 0 cho tới đúng lượt của mình rồi mới gõ ra và xoá đi bằng steps() riêng, nên chỉ một từ đổi trong khi phần còn lại của câu đứng yên.

Gõ chữ đổi từ
Mở trong tab mới

09 · Gõ chữ đổi từ — Diện tích đo được 4.917% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

구간별 steps()단어 3개 겹쳐두기고정 문장 + 가변 단어

Dùng khi nào

Hợp cho câu hero đổi vai trò hoặc slogan giới thiệu nhiều tính năng.

.swap__word--1 { animation: w1 $duration linear infinite; }
.swap__word--2 { position: absolute; left: 0; top: 0; animation: w2 $duration linear infinite; }
.swap__word--3 { position: absolute; left: 0; top: 0; animation: w3 $duration linear infinite; }

@keyframes w1 {
  0%        { width: 0; animation-timing-function: steps(4, end); }
  8%        { width: var(--full, 4ch); }
  30%       { width: var(--full, 4ch); animation-timing-function: steps(4, end); }
  38%, 100% { width: 0; }
}

Chỗ dễ hỏng ở đâu? — hai cái bẫy

Nội suy width sang max-content

Bẫy đáng kể nhất nằm ở chính cách CSS animate width. Nếu bạn transition width sang max-content thay vì một con số cụ thể, trình duyệt không nội suy được kích thước đó theo từng bước — dù có gắn steps(20, end) hay không, thanh chữ vẫn nhảy thẳng một lần từ 0 sang độ rộng cuối cùng, vì max-content là một từ khoá kích thước nội tại chứ không phải một chiều dài có thể tính giữa hai điểm. Trang interpolate-size của MDN giải thích rõ vì sao những từ khoá kiểu này thường không animate mượt được, và giới thiệu calc-size() như một lối thoát CSS thuần — nhưng tính đến 9/2026 lối thoát đó vẫn chưa được hỗ trợ đầy đủ trên mọi trình duyệt phổ biến (bảng hỗ trợ interpolate-size trên caniuse). Vì lý do đó, chín demo ở trên không transition sang max-content: mỗi file tự đo bề rộng chữ thật bằng canvas.measureText() ngay khi trang vừa tải, rồi ghi kết quả vào một biến --full theo đơn vị px, và keyframes chỉ animate width giữa 0 với chính biến --full đó — một chiều dài cụ thể mà steps() chia đều được.

Giá trị dự phòng 10ch phía sau dấu phẩy

Cùng logic ấy giải thích vì sao mỗi ô vẫn giữ var(--full, 10ch) làm giá trị dự phòng phía sau dấu phẩy: nếu JavaScript bị chặn, con số ch gần đúng vẫn chạy, chỉ là không khớp pixel tuyệt đối như bản đã đo thật. Nếu bạn muốn ngừng đọc lý thuyết và mở file ra xem luôn cho chắc, mật khẩu của zip là erv8c6eb.

Khung xem 480×300 và lớp is-demo

Bẫy thứ hai nằm ngay trong khung xem phía trên: khung iframe ở đó chỉ rộng 480×300 và mỗi câu chữ dài ngắn khác nhau, nên mỗi ô tự chuyển sang lớp .is-demo chạy một vòng lặp cố định hai giây thay cho animation đo pixel thật của nó. Bạn chỉ xem chuyển động lặp lại ở khung này; muốn thấy con trỏ gõ đúng theo độ dài chữ đã đo, hãy mở riêng file HTML gốc trong một tab trình duyệt khác.

Khả năng tiếp cận

Phần còn lại sau khi tắt chuyển động

Chín demo đều phản hồi prefers-reduced-motion, nhưng phần còn lại sau khi tắt chuyển động không giống nhau. Nhóm dùng steps() thuần — 01, 02, 04, 05, 07, 09 — tắt hẳn animation và giữ nguyên trạng thái đã gõ xong: con trỏ đứng yên ở opacity 1, chữ hiện đủ độ rộng đo được, dòng phản hồi terminal hiện luôn. Nhóm dùng JavaScript và Element.animate() — 03 và 06 — tự kiểm tra matchMedia('(prefers-reduced-motion: reduce)') ngay từ đầu script nên animate() không chạy, chữ hiện nguyên vẹn không qua bước clip-path nào. Mục 08 khoá luôn ở stroke-dashoffset: 0 nên nét chữ viết tay hiện trọn ngay lập tức. Trang prefers-reduced-motion của MDN có ghi mức hỗ trợ trình duyệt cho media query này.

@media (prefers-reduced-motion: reduce) {
  .line__caret  { animation: none; opacity: 1; }
  .type__text   { animation: none; width: var(--full, 10ch); border-right-color: transparent; }
  .sig__svg path { animation: none; stroke-dashoffset: 0; }
  .swap__word             { animation: none; }
  .swap__word--1          { width: var(--full, 4ch); }
  .swap__word--2,
  .swap__word--3          { width: 0; opacity: 0; }
}

Chín kiểu gõ chữ này chỉ là một góc trong chuyên mục CSS của site, phần lớn còn lại là các hiệu ứng cuộn và hover cùng kiểu chín-hiệu-ứng-một-file. Trang giới thiệu nói rõ vì sao site này đóng gói mỗi bài thành file zip có khoá.

FAQ

Css typing animation có cần JavaScript không?

Tuỳ mục. 01, 02, 04, 05, 07, 08, 09 chạy hoàn toàn bằng CSS — steps() hoặc stroke-dashoffset điều khiển toàn bộ chuyển động, còn đoạn JavaScript đi kèm chỉ chạy một lần lúc tải trang để đo pixel rồi ghi vào biến --full, không hề đụng vào animation sau đó. Riêng 03 và 06 thì khác: cơ chế clip-path theo từng ký tự nằm trong chính đoạn JavaScript gọi Element.animate(), nên tắt JavaScript là tắt luôn chuyển động của hai mục này.

steps() và JavaScript đo bề rộng khác nhau ở chỗ nào?

steps() là một hàm định thời thuần CSS, cắt một transition hoặc animation thành từng bước rời rạc thay vì trượt liên tục. Đoạn JavaScript đo bề rộng không hề điều khiển bước nhảy đó — nó chỉ chạy đúng một lần khi trang vừa tải, đo ra con số pixel thật của chuỗi chữ rồi ghi vào một custom property, để CSS biết chính xác nó phải chạy width tới đâu.

Có dùng chung với Tailwind hoặc dự án React được không?

Được. Class tiện ích của Tailwind không đụng tới animation-timing-function hay keyframes mà đoạn SCSS này khai báo, nên hai bên không giẫm chân nhau. Thư mục react/ trong zip có sẵn một component cho mỗi hiệu ứng, nhận các biến số qua props, nên bạn ghép thẳng vào một dự án React mà không phải viết lại phần CSS.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.