Đến nội dung chính
Lập trình

Vẽ HTML thật vào canvas, rồi thả cả trang web xuống nước

Từ trước tới nay, muốn làm hiệu ứng điểm ảnh trên giao diện web, ta phải chọn: hoặc giữ HTML thật và chỉ dùng được CSS, hoặc vẽ lại mọi thứ trong canvas và mất đi ô nhập liệu, khả năng chọn chữ, trình đọc màn hình. HTML-in-Canvas là một API thử nghiệm của Chrome xoá đi lựa chọn đó: canvas vẽ trực tiếp các phần tử con của nó, còn trình duyệt vẫn chuyển click và thao tác gõ phím từ hình vẽ về đúng phần tử HTML.

Trước khi xem ví dụ. API này hiện chỉ có trên Chrome bản máy tính và vẫn đang thử nghiệm. Để xem đầy đủ các ví dụ 1 đến 3, mở chrome://flags/#enable-experimental-web-platform-features, bật Experimental Web Platform features rồi khởi động lại Chrome. Nếu chưa bật, ví dụ 1 hiện biểu mẫu thường, ví dụ 2 và 3 chạy hiệu ứng trên một trang giả vẽ bằng fillText. Ví dụ 4 chạy ở mọi trình duyệt.

Bước 1: Đặt trang vào trong canvas

Đánh dấu canvas bằng thuộc tính layoutsubtree (kèm content="drawable"), đặt HTML vào bên trong, rồi vẽ nó trong sự kiện paint của canvas bằng drawElementImage. Sự kiện này chạy lại mỗi khi nội dung bên trong thay đổi, ví dụ khi bạn gõ phím.

<canvas id="stage" layoutsubtree content="drawable">
  <form id="card" drawable>
    <input placeholder="Tên bạn">
    <button>Chào</button>
  </form>
</canvas>

<script>
  const ctx = stage.getContext("2d");
  stage.onpaint = () => {
    ctx.reset();
    ctx.drawElementImage(card, 0, 0);
  };
</script>

Ví dụ dưới đây vẽ biểu mẫu hai lần: một bản lật ngược, cắt thành những dải mỏng lệch nhau theo sóng để làm bóng dưới nước, và một bản thật. Gõ vào ô nhập, bóng phản chiếu cập nhật theo từng chữ.

Ví dụ 1. Biểu mẫu thật trong canvas, kèm bóng phản chiếu gợn sóng.

Một chi tiết đáng chú ý: trình duyệt dùng lần gọi drawElementImage cuối cùng để quyết định click rơi vào đâu. Vì vậy bản thật phải được vẽ sau cùng. Nếu vẽ bóng sau, click sẽ rơi vào vị trí của cái bóng.

Bước 2: Bẻ cong điểm ảnh

Sao chép canvas 2D vào một texture WebGL sau mỗi lần vẽ, rồi trong shader, đọc mỗi điểm ảnh từ một vị trí hơi lượn sóng. Lớp WebGL nằm phía trên với pointer-events: none, nên click đi xuyên xuống biểu mẫu thật. Kéo thanh “Độ gợn” để chỉnh biên độ.

// sao chép canvas 2D vào texture WebGL sau mỗi lần vẽ
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, stage);

// fragment shader: đọc mỗi điểm ảnh từ một vị trí lượn sóng
vec2 q = uv;
q.x += sin(uv.y * 18. + time) * .012 * wobble;
q.y += sin(uv.x * 13. + time) * .01 * wobble;
gl_FragColor = texture2D(page, q);
Ví dụ 2. Shader làm gợn trang. Biểu mẫu bên dưới vẫn gõ và bấm được.

Bước 3: Cho mặt nước phản ứng

Đặt một lưới độ cao mặt nước bên dưới trang. Con trỏ thả những viên sỏi nhỏ vào lưới. Mỗi khung hình, mỗi ô trở thành trung bình các ô lân cận trừ đi giá trị của chính nó ở bước trước, nhân với một hệ số nhỏ hơn 1: sóng lan ra rồi tắt dần. Trong shader, độ dốc của mặt nước bẻ cong trang. Nút “Thả xu” vẫn hoạt động dưới mặt nước, và mỗi lần bấm sẽ tạo một vòng sóng ngay tại nút.

// mỗi ô = trung bình các ô lân cận trừ đi chính nó ở bước trước
next[i] = (left + right + up + down) / 2 - prev[i];
next[i] *= 0.975;

// trong shader, độ dốc của mặt nước bẻ cong trang
vec2 slope = vec2(right - left, up - down);
gl_FragColor = texture2D(page, uv + slope * .18);
Ví dụ 3. Mặt nước phản ứng theo con trỏ, thỉnh thoảng có giọt mưa.

Bước 4: Để chữ dạt ra

Bước này không cần API canvas nào. Con vật là một chuỗi vòng tròn dọc theo xương sống. Mỗi khung hình, mỗi dòng chữ tính xem các vòng tròn để lại những khoảng trống nào, rồi lấp vào đó nhiều từ nhất có thể. Phần còn lại tràn sang dòng kế tiếp.

for (let y = top; y + lineHeight <= bottom; y += lineHeight) {
  let free = [[left, right]];
  for (const c of body) {               // các vòng tròn dọc xương sống
    const mid = y + lineHeight / 2;
    const dy = Math.max(0, Math.abs(c.y - mid) - lineHeight / 2);
    if (dy >= c.r) continue;
    const hw = Math.sqrt(c.r * c.r - dy * dy);
    free = cut(free, c.x - hw, c.x + hw);
  }
  for (const [a, b] of free) placeWordsThatFit(a, b, y);
}
Ví dụ 4. Chữ tự dạt quanh một con vật làm từ các vòng tròn.

Đi xa hơn: đốt cháy trang

Cùng nguyên lý, có thể đặt dưới trang một lưới thể hiện mức độ cháy của từng điểm. Một cú click châm lửa tại một điểm, và một mép lửa phát sáng bò dần ra xung quanh cho tới khi nguội. Trong shader, giá trị cháy quyết định điểm ảnh thuộc lớp nào: giấy ngả nâu, cháy đen, viền than hồng, rồi thủng hẳn. Sau một lúc trang tự lành lại, và biểu mẫu vẫn dùng được suốt quá trình đó.

Đưa lên sản phẩm thật

  • Để người dùng không phải bật cờ, đăng ký tên miền của bạn vào chương trình Chrome Origin Trials, rồi thêm token dưới dạng thẻ <meta http-equiv="origin-trial"> hoặc header Origin-Trial. Đợt thử nghiệm kết thúc ngày 1/3/2027.
  • Luôn kiểm tra "drawElementImage" in CanvasRenderingContext2D.prototype. Ở nơi không có (Safari, Firefox, điện thoại), hiện trang HTML bình thường.
  • Click và thao tác gõ phím vẫn hoạt động vì trình duyệt ánh xạ chúng từ hình vẽ về HTML thật. Trình đọc màn hình và bàn phím cũng làm việc với HTML thật.
  • Tôn trọng prefers-reduced-motion. Các ví dụ ở đây khi đó sẽ tắt sóng, tắt mưa và để con vật đứng yên.
  • Vì hiện chỉ chạy trên Chrome máy tính, nếu muốn khoe hiệu ứng trên mạng xã hội, hãy quay video.