Hiệu ứng đánh máy là phiên bản kỹ thuật số của việc quan sát ai đó đang suy nghĩ thành lời. Văn bản xuất hiện từng ký tự một, như thể một bàn tay thật đang gõ lên những phím thật. Bạn có thể thấy nó trong các phần hero của các trang web portfolio, trong các trình giả lập terminal trên trình duyệt, và trong cửa sổ chat của các trợ lý AI muốn chứng minh rằng chúng đang "gõ" câu trả lời thay vì chỉ lấy ra một khối văn bản có sẵn. Khi được thực hiện tốt, nó tạo ra sự mong đợi. Khi làm không tốt, nó mang lại cảm giác như một chiếc máy in bị kẹt từ năm 1987.
Tại sao mô hình này vẫn tồn tại
Máy tính truyền tải thông tin ngay lập tức. Con người thì không. Khoảng cách giữa hai tốc độ đó rất hữu ích. Hiệu ứng đánh máy lấp đầy khoảng cách đó bằng cách mô phỏng nhịp độ của con người. Trên một trang landing page, nó có thể thu hút ánh nhìn qua từng từ của tiêu đề để khách truy cập thực sự đọc được giá trị cốt lõi thay vì chỉ đọc lướt qua. Trong một trình giả lập terminal, nó tạo ra ảo giác rằng các câu lệnh đang được thực thi trong thời gian thực. Trong giao diện chatbot, nhịp điệu này báo hiệu rằng một câu trả lời đang được tạo ra ngay lập tức thay vì được truy xuất từ cơ sở dữ liệu.
But hiệu ứng này chỉ hiệu quả nếu các cơ chế kỹ thuật tôn trọng người dùng. Một nhịp điệu đều đặn, máy móc với các khoảng thời gian giống hệt nhau sẽ tạo cảm giác như robot. Tệ hơn, một cách triển khai bỏ qua các công nghệ hỗ trợ (assistive technology) có thể biến một hiệu ứng hình ảnh thú vị thành một rào cản gây khó chịu. Mục tiêu không phải là làm chậm người dùng; mà là thêm vừa đủ "ma sát" để làm cho giao diện cảm thấy sống động.
Xây dựng nó với Recursive setTimeout
Hãy bắt đầu với setTimeout, và đừng chạm vào setInterval. Sự khác biệt quan trọng hơn vẻ ngoài của chúng.
setInterval rất cứng nhắc. Nó kích hoạt sau mỗi n mili giây bất kể điều gì khác đang xảy ra trong script của bạn hoặc luồng chính (main thread) của trình duyệt. Nếu logic của bạn cần tạm dừng 50 mili giây giữa các lần gõ phím thông thường nhưng cần tạm dừng 150 mili giây sau các dấu câu, setInterval không thể thích ứng. Cuối cùng, bạn sẽ phải bao bọc nó trong các logic điều kiện bổ sung, phải chiến đấu với các tình trạng tranh chấp (race conditions), và cuối cùng là xóa và thiết lập lại interval thường xuyên đến mức mã nguồn trở thành một cơn ác mộng về quản lý trạng thái (state-management). Các khoảng thời gian cố định sẽ thất bại ngay khi bạn cần tốc độ thay đổi.
Recursive setTimeout khắc phục điều này bằng cách cho phép mỗi bước tự quyết định quy tắc cho bước tiếp theo. Hãy coi nó như một máy trạng thái (state machine) nhỏ. Bạn duy trì một vài biến: chuỗi hiện tại, chỉ số ký tự hiện tại, một cờ boolean để biết bạn đang gõ hay đang xóa, và một bộ đếm textIndex để bạn có thể lặp qua nhiều chuỗi. Hàm này sẽ thêm một ký tự, kiểm tra vị trí của nó trong câu, sau đó lên lịch cho lần gọi tiếp theo của chính nó với một khoảng trễ phù hợp với ngữ cảnh.
Ví dụ, bạn có thể gõ hầu hết các ký tự ở mức 50 mili giây, chậm lại 150 mili giây sau dấu phẩy, và tạm dừng 800 mili giây ở cuối một câu đầy đủ trước khi chuyển sang chế độ xóa. Bạn không thể làm điều đó một cách gọn gàng với setInterval. Với recursive setTimeout, logic rất rõ ràng:
if typing:
append next character
if at end of string:
switch to pause mode
schedule next call after 1000ms
if deleting:
remove last character
if string empty:
increment textIndex
load next string
switch to typing mode
Cấu trúc này cũng giúp việc dọn dẹp trở nên cực kỳ đơn giản. Hãy lưu lại timeout ID. Khi component unmount hoặc người dùng chuyển trang, chỉ cần gọi clearTimeout một lần. Sẽ không có các interval "mồ côi" chạy ngầm trong nền.
Con trỏ nên tự nhấp nháy
Con trỏ nhấp nháy là một chi tiết hình ảnh, không phải là vấn đề về dữ liệu. Hãy tách nó ra khỏi engine quản lý trạng thái JavaScript của bạn. Hãy sử dụng một animation CSS riêng biệt gắn vào pseudo-element ::after hoặc một thẻ <span> dành riêng nằm ở cuối container chứa văn bản của bạn.
Một @keyframes blink đơn giản để chuyển đổi opacity hoặc border-color với timing step-end sẽ mang lại cho bạn một nhịp nhấp nháy sắc nét, thân thiện với phần cứng và chạy trên compositor. JavaScript không cần phải can thiệp quá sâu vào việc quản lý hiển thị của con trỏ. Nếu bạn chuyển đổi các thuộc tính display từ bên trong vòng lặp đệ quy setTimeout, bạn sẽ buộc trình duyệt phải tính toán lại style không cần thiết sau mỗi ký tự. Hãy để CSS xử lý thẩm mỹ. Hãy để JavaScript xử lý trình tự.
Việc lặp qua nhiều chuỗi rất đơn giản với một bộ đếm textIndex. Hãy lưu các chuỗi của bạn trong một mảng. Khi animation kết thúc giai đoạn xóa và container trống rỗng, hãy tăng textIndex theo phép chia lấy dư (modulo) cho độ dài của mảng, đặt lại con trỏ ký tự về 0 và bắt đầu gõ lại. Đây là cách các trang web portfolio chuyển đổi qua các vai trò—["Developer", "Designer", "Writer"]—mà không cần tải lại trang.
Những sai lầm phá vỡ ảo giác
Có ba lỗi thường xuyên xuất hiện trong các bản triển khai nghiệp dư.
Sử dụng setInterval. Chúng ta đã đề cập đến vấn đề tốc độ không ổn định, nhưng còn một vấn đề tinh vi hơn. Nếu việc cập nhật DOM của bạn bị trễ—chẳng hạn như do trình duyệt đang vẽ lại sự thay đổi bố cục (layout shift)—setInterval vẫn sẽ tiếp tục kích hoạt. Bạn có thể gặp phải tình trạng các lần ghi đè lên nhau, các ký tự bị lặp, hoặc các lần ghi diễn ra nhanh hơn mức trình duyệt có thể hiển thị. setTimeout đệ quy sẽ đợi cho đến khi bước hiện tại hoàn tất trước khi bắt đầu bước tiếp theo.
Quên escape HTML. Nếu các chuỗi nguồn của bạn chứa dấu ngoặc nhọn, và bạn đang chèn nội dung qua innerHTML theo từng ký tự một, bạn sẽ làm chia cắt các thẻ làm đôi. Trình duyệt sẽ thấy <, sau đó là <s, rồi đến <st. Điều này ngăn cản việc phân tích cú pháp thẻ chính xác và có thể khiến bạn gặp phải các nút DOM bị lỗi hoặc các hiệu ứng cascade kiểu dáng không mong muốn. Nếu bạn muốn các ký tự đó hiển thị nguyên bản, hãy escape chúng trước hoặc, tốt hơn hết, hãy ghi vào textContent thay vì innerHTML. Nếu bạn thực sự cần các thẻ <span> có định dạng bên trong kết quả typewriter, hãy tiền xử lý chuỗi để bạn biết chính xác nơi các thẻ bắt đầu và kết thúc trước khi bắt đầu vòng lặp ký tự.
Bỏ qua khả năng truy cập (accessibility). Trình đọc màn hình không hề thích việc phải đọc từng chữ cái một. Khi script của bạn thêm từng ký tự mới vào DOM, một số công nghệ hỗ trợ sẽ thông báo lại toàn bộ nút đó, gây ra một loạt các từ không hoàn chỉnh bị ngắt quãng. Đó là một cơn ác mộng đối với bất kỳ ai đang dựa vào điều hướng bằng âm thanh. Cách khắc phục không hề phức tạp: hãy thêm một aria-label vào container chứa toàn bộ văn bản cuối cùng. Bạn cũng có thể ẩn hoàn toàn phần tử đang hoạt hình khỏi công nghệ hỗ trợ bằng aria-hidden="true" và cung cấp một bản sao tĩnh được ẩn về mặt thị giác cho trình đọc màn hình. Dù bằng cách nào, hãy cung cấp cho người dùng toàn bộ câu ngay từ đầu thay vì bắt họ phải ngồi chờ đợi màn trình diễn của bạn.
Cách để hoàn thiện phiên bản của bạn
Khi vòng lặp cốt lõi đã hoạt động ổn định, bạn có thể thêm các tính năng bổ sung. Nhưng hãy kiềm chế ham muốn thêm chúng cho đến khi các yếu tố cơ bản đã vững chắc.
Hiệu ứng âm thanh gõ phím. Một tiếng click nhẹ ở mỗi ký tự có thể mang lại cảm giác thỏa mãn, nhưng âm thanh trên trang web là một "bãi mìn". Hãy sử dụng Web Audio API hoặc một phần tử Audio nhẹ với một buffer ngắn. Thay đổi tốc độ phát một chút—trong khoảng từ 0.95 đến 1.05—để các tiếng click giống hệt nhau không nghe có vẻ máy móc. Luôn tôn trọng các chính sách tự động phát của trình duyệt và cung cấp nút bật/tắt tiếng. Không có gì khiến người dùng rời đi nhanh hơn một trang portfolio tự động phát tiếng gõ phím vào lúc 9 giờ sáng.
Gõ văn bản nhiều dòng. Các cửa sổ terminal thực tế có tính năng xuống dòng (wrap). Nếu văn bản của bạn vượt quá một dòng, con trỏ dạng border-right đơn giản sẽ nhảy một cách vụng về trừ khi bố cục của bạn có thể dự đoán được. Hãy chia nhỏ các chuỗi bằng ký tự xuống dòng và hiển thị mỗi dòng trong một thẻ <span> riêng biệt, hoặc sử dụng một pseudo-element được định vị để theo dõi điểm kết thúc của nội dung. Hãy cẩn thận với việc xuống dòng văn bản; một con trỏ được triển khai dưới dạng border inline có thể bị tách rời khỏi văn bản nếu chiều rộng của container thay đổi. Hãy cân nhắc sử dụng white-space: pre-wrap và phông chữ monospaced cho các kiểu dáng terminal, vì các ký tự có độ rộng cố định giúp việc tính toán vị trí con trỏ dễ dự đoán hơn nhiều.
Hiển thị Markdown trong thời gian thực. Đây là lúc mọi thứ trở nên phức tạp. Nếu bạn gõ **bold**, bạn có hai lựa chọn: hiển thị các dấu sao nguyên bản như chúng xuất hiện, hoặc chuyển đổi chúng sang kiểu chữ đậm ngay lập tức. Nếu bạn chọn cách sau, việc chuyển từ textContent sang innerHTML giữa chừng đồng nghĩa với việc ranh giới các nút văn bản của bạn sẽ thay đổi. Vị trí con trỏ trở thành một cơn đau đầu về việc quản lý vì các thẻ HTML sẽ làm thay đổi cây DOM bên dưới bạn. Một cách tiếp cận an toàn hơn là gõ chuỗi Markdown thô một cách bình thường, sau đó kích hoạt một lượt render sau khi toàn bộ chuỗi đã hiển thị trên màn hình. Nếu bạn thực sự cần định dạng trực tiếp, hãy duy trì hai lớp: một bộ đệm văn bản được ẩn đi và một lớp phủ hiển thị đã được phân tích cú pháp.
Hiệu ứng đảo ngược. Xóa văn bản không nhất thiết phải là nhấn backspace từng ký tự một. Bạn có thể mô phỏng thao tác "chọn tất cả, sau đó xóa" để xóa sạch trường dữ liệu ngay lập tức trước khi chuỗi tiếp theo được gõ vào. Điều đó tạo cảm giác hơi máy móc. Thay vào đó, việc xóa chậm với tốc độ 30 mili giây mỗi ký tự sẽ tạo ra sự kịch tính. Hãy kết hợp cả hai: xóa nhanh một lỗi đánh máy, tạm dừng, sau đó tiếp tục xóa với tốc độ bình thường. Sự thay đổi này sẽ tạo ra cảm giác chân thực cho hiệu ứng.
Bài học thực sự
Hiệu ứng typewriter là một trong những chi tiết trang trí UI trông có vẻ tầm thường trên bề mặt nhưng lại bộc lộ sự phức tạp của nó chỉ sau khi bạn đã xây dựng xong. Hãy bắt đầu với
