GODRICH

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

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. Hợp cho placeholder của form cần nhấn mạnh, trạng thái chờ của ô chat.

step-endopacity 깜빡임::after 커서
.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()

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. 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.

steps(n)ch 단위white-space: nowrap
.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ữ

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 đó. Hợp cho tên sản phẩm trộn Latin, CJK và emoji trong một trang landing quốc tế hoá.

clip-path 스텝getBoundingClientRect가변폭 대응
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

Mỗi dòng có animation steps() riêng, animation-delay bằng đúng tổng thời gian các dòng phía trên đã gõ xong, nên dòng dưới chỉ bắt đầu khi dòng trên gõ hết chữ. Hợp cho phần mở đầu editor code hoặc thẻ giới thiệu bản thân nhiều dòng.

줄마다 steps()퍼센트 구간 분리overflow: hidden
.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

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. Hợp cho câu gợi ý trong ô chat trống hoặc placeholder ô tìm kiếm tự đổi.

단일 keyframes0→100%→0hold 구간
.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

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. Hợp cho khu vực trả lời của chatbot hoặc hiệu ứng chờ kết quả prompt.

랜덤 간격 JSsetTimeout 루프오브 커서 pulse
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

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. 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.

명령어→응답 순서monospace 터미널 폰트트래픽라이트 장식
.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

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. Hợp cho hiệu ứng chữ ký hoặc thiệp mời sự kiện phong cách viết tay.

stroke-dasharraystroke-dashoffsetpath 길이 측정
.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ừ

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. Hợp cho câu hero đổi vai trò hoặc slogan giới thiệu nhiều tính năng.

구간별 steps()단어 3개 겹쳐두기고정 문장 + 가변 단어
.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

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.

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.

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

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.