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

Giữ khung chat luôn cuộn ở đáy mà không cần một dòng code cuộn nào

Ai từng làm giao diện chat, khung log hay terminal trên web đều quen với việc này: mỗi khi có tin nhắn mới, ta phải gọi scrollTop = scrollHeight hoặc scrollIntoView() để kéo khung xuống đáy. Có một cách gọn hơn: để chính trình duyệt giữ khung ở đáy, bằng tính năng scroll anchoring có sẵn.

Toàn bộ mẹo

Tắt scroll anchoring cho mọi thứ bên trong khung log, rồi đặt một cái ghim cao 1px ở cuối, làm anchor duy nhất:

.log * {
  overflow-anchor: none;
}

.log .pin {
  overflow-anchor: auto;
  height: 1px;
}

Trong HTML, cái ghim chỉ là một <div class="pin"></div> rỗng nằm cuối khung .log. Tin nhắn mới luôn được chèn vào ngay trước nó.

Vì sao nó chạy được

Scroll anchoring là cơ chế chống “nhảy trang” của trình duyệt. Khi nội dung thay đổi kích thước, trình duyệt chọn một phần tử đang hiển thị làm mốc rồi tự chỉnh vị trí cuộn để mốc đó đứng yên trên màn hình. Thuộc tính overflow-anchor quyết định phần tử nào được làm mốc: auto là được, none là bỏ qua.

Quy tắc .log * { overflow-anchor: none } loại mọi tin nhắn khỏi danh sách ứng viên. Quy tắc .log .pin cụ thể hơn nên giành lại auto cho đúng một phần tử: cái ghim ở cuối. Khi một tin mới được chèn trước ghim, ghim bị đẩy xuống. Trình duyệt thấy mốc dịch chuyển nên cuộn theo một đoạn bằng đúng chiều cao tin mới, và khung cứ thế bám đáy.

Mỗi lần có tin mới, JavaScript chỉ việc thêm tin vào DOM, không cần đụng tới vị trí cuộn.

Ví dụ 1: Khung chat tối giản

Gõ vài tin rồi bấm Gửi. Tin của bạn trượt lên, bot hiện ba chấm “đang gõ” rồi trả lời. Mọi thứ đều được chèn bằng insertBefore(…, pin), không có lệnh cuộn nào chạy theo tin nhắn. Các hiệu ứng chỉ đổi opacity và transform, nên không làm lệch chiều cao mà cơ chế anchoring dựa vào.

Ví dụ 1. Khung chat với hiệu ứng tin mới và ba chấm “đang gõ”.

Ví dụ 2: Có và không có mẹo

Bấm Bắt đầu để tin nhắn đổ vào cả hai khung. Khung bên trái đứng yên, tin mới tràn xuống khỏi tầm nhìn. Khung bên phải tự bám đáy.

Ví dụ 2. Trái: khung thường. Phải: khung dùng .log và .pin.

Ví dụ 3: Kéo lên đọc lại

Khi bạn cuộn lên giữa chừng, cái ghim nằm ngoài vùng nhìn thấy nên không còn mốc nào được chọn, và khung không giật bạn xuống đáy. Kéo xuống đáy lại thì ghim hiện ra và khung lại bám theo dòng mới. Dòng mới lóe sáng nhẹ để dễ nhận ra.

Ví dụ 3. Log chạy liên tục, dòng mới lóe sáng nhẹ.

Lưu ý khi áp dụng

  • Luôn chèn tin mới trước .pin, ví dụ log.insertBefore(msg, pin). Dùng appendChild thì tin nằm sau ghim và mẹo hết tác dụng.
  • .log phải là khung cuộn: có chiều cao cố định và overflow-y: auto.
  • Đừng đặt overflow-anchor: none lên chính .log. Bộ chọn .log * chỉ áp dụng cho phần tử con.
  • Nếu thêm hiệu ứng, chỉ animate opacity và transform. Animate height hay margin sẽ làm chiều cao thay đổi dần và khung bám đáy giật cục.

Cần “mồi” một lần. Khi thử trong Chrome, scroll anchoring không hoạt động lúc khung đang ở vị trí cuộn 0, ví dụ khung rỗng rồi mới bắt đầu tràn. Vì vậy các ví dụ điền sẵn vài tin và gán scrollTop = scrollHeight đúng một lần lúc khung vừa hiển thị (qua ResizeObserver). Sau đó CSS tự lo.

Hỗ trợ trình duyệt. Mẹo dựa hoàn toàn vào overflow-anchor. Chromium (Chrome, Edge) và Firefox có hỗ trợ. Safari từ lâu chưa hỗ trợ thuộc tính này, nên hãy kiểm tra caniuse.com/css-overflow-anchor trước khi dùng cho sản phẩm thật, và cân nhắc giữ một đoạn JS cuộn dự phòng cho Safari.