9 CSS Toast Notification — không cần JS
CSS toast notification là thẻ thông báo nhỏ tự nổi lên một góc màn hình rồi tự đóng, dựng bằng CSS thuần, không cần JS.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Trượt vào từ bên phải
- 02 Toast Popover API
- 03 Xếp chồng 3 lớp
- 04 Thanh thời gian còn lại
- 05 Toast hoàn tác
- 06 Vuốt để đóng
- 07 Icon thành công
- 08 Toast đáy màn hình di động
- 09 Huy hiệu thông báo
Chín kiểu không xếp theo độ nổi tiếng mà theo đúng vòng đời của một toast trên màn hình: cách xuất hiện gây chú ý (01–03), cách tự dọn mình (04–06), rồi cách dùng màu và hình để nói trạng thái (07–09). Ở lưới phía trên, mỗi ô lặp lại bằng class .is-demo theo chu kỳ hai giây bên trong một iframe cố định không nhận sự kiện chuột thật, nên bạn xem chuyển động lặp chứ chưa bấm nút nào cả — file vanilla riêng trong zip mới phản hồi đúng thao tác thật của bạn.
01Trượt vào từ bên phải
Card trượt vào từ ngoài mép phải màn hình bằng translateX, dừng lại một nhịp rồi thoát ra đúng hướng cũ. Đây là cách cơ bản nhất cho thông báo lưu hay tải lên hoàn tất, đặt ở góc một web app desktop.
.toast {
position: relative;
transform: translateX(140%);
opacity: 0;
}
.toast.is-demo { animation: slide-loop $dur-loop $easing infinite; }
@keyframes slide-loop {
0% { transform: translateX(140%); opacity: 0; }
18% { transform: translateX(0); opacity: 1; }
78% { transform: translateX(0); opacity: 1; }
100% { transform: translateX(140%); opacity: 0; }
}
02Toast Popover API
Chỉ cần thuộc tính popover và nút popovertarget là toast mở đóng được, đứng sẵn ở top-layer và được đóng bằng Esc hay click ra ngoài mà không cần dòng JS nào cho việc đóng. Style mặc định lại canh giữa màn hình, nên muốn đặt ở góc thì phải tự ghi đè.
<div id="ptoast" class="toast" popover role="status" aria-live="polite">
<span class="toast__dot" aria-hidden="true"></span>
<p class="toast__title">Đã kết nối</p>
<button popovertarget="ptoast" popovertargetaction="hide" aria-label="Đóng thông báo">
<svg viewBox="0 0 24 24" width="14" height="14"><line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/></svg>
</button>
</div>
<script>document.getElementById('ptoast').showPopover();</script>
03Xếp chồng 3 lớp
Ba thẻ thông báo nằm chồng khẽ lệch nhau nhờ biến CSS --i gán riêng từng thẻ, hover hoặc focus vào cả cụm thì chúng bung ra theo hình quạt để đọc được từng thẻ. Hợp với hộp thông báo dashboard khi nhiều sự kiện đến gần như cùng lúc.
.stack__item {
position: absolute; inset: 0;
transform: translateY(calc(var(--i) * -6px))
scale(calc(1 - var(--i) * .05));
opacity: calc(1 - var(--i) * .22);
}
.stack:hover .stack__item,
.stack:focus-within .stack__item {
transform: translateY(calc(var(--i) * -22px)) scale(1);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
04Thanh thời gian còn lại
Thanh tiến trình ở đáy co lại đều bằng scaleX theo thời gian tuyến tính, và đúng lúc thanh co hết thì toast biến mất theo cùng nhịp. Dùng cho thông báo phải tự đóng sau một mốc thời gian cố định, kiểu xác nhận tải lên.
.toast__bar {
position: absolute; left: 0; bottom: 0;
width: 100%; height: 4px;
background: $color;
transform-origin: left;
}
.toast.is-demo .toast__bar { animation: bar-shrink $dur-loop linear infinite; }
@keyframes bar-shrink {
0% { transform: scaleX(1); }
82% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
05Toast hoàn tác
Bấm nút hoàn tác cạnh dòng chữ xoá thì JS huỷ bộ đếm giờ và đổi chữ báo đã huỷ; không bấm thì sau 4 giây toast tự cuộn lại từ trên xuống bằng scaleY. Dùng thay cho hộp xác nhận khi xoá một dòng trong danh sách.
document.querySelectorAll('.toast').forEach(function (t) {
var btn = t.querySelector('.toast__undo'), msg = t.querySelector('.toast__text');
var timer = setTimeout(function () { t.classList.add('is-gone'); }, 4000);
btn.addEventListener('click', function () {
clearTimeout(timer);
msg.textContent = 'Đã huỷ';
t.classList.add('is-cancelled');
});
});
06Vuốt để đóng
Giữ và kéo ngang thì thẻ bám theo ngón tay qua biến --dx, buông tay sau khi kéo quá 80px thì thẻ trượt hẳn ra theo hướng đó để đóng. Hợp với hộp thông báo chat, messenger trên di động, nơi thao tác chạm là chính.
el.addEventListener('pointerdown', function (e) {
x0 = e.clientX; dragging = true; el.setPointerCapture(e.pointerId);
});
el.addEventListener('pointermove', function (e) {
if (!dragging) return;
dx = e.clientX - x0; el.style.setProperty('--dx', dx + 'px');
});
el.addEventListener('pointerup', function () {
dragging = false;
if (Math.abs(dx) > 80) el.classList.add(dx > 0 ? 'is-swiped-right' : 'is-swiped-left');
else el.style.setProperty('--dx', '0px');
});
07Icon thành công
Vòng tròn màu trung tính mờ dần biến mất đúng lúc vòng màu trạng thái cùng dấu tick vẽ bằng stroke-dashoffset hiện lên chồng lên nhau, nên chính màu sắc nói lên kết quả. Đổi $color sang màu lỗi là có ngay badge báo thất bại.
.badge__neutral { opacity: .3; }
.badge__colored { opacity: 0; }
.badge__check { stroke-dasharray: 100; stroke-dashoffset: 100; }
.toast.is-demo .badge__neutral { animation: fade-out $dur-loop $easing infinite; }
.toast.is-demo .badge__colored { animation: fade-in $dur-loop $easing infinite; }
.toast.is-demo .badge__check { animation: draw $dur-loop $easing infinite; }
08Toast đáy màn hình di động
Thanh hình viên thuốc cố định ở đáy màn hình né vùng an toàn rồi nhích lên một chút trước khi hạ xuống lại. Khác bottom sheet có tay cầm, nó chỉ hiện một dòng và tự đóng, hợp với tin mới hay mất kết nối trên web di động.
.toast {
position: fixed;
left: 16px; right: 16px;
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
margin: 0 auto;
max-width: 340px;
border-radius: 999px;
transform: translateY(140%);
opacity: 0;
}
.toast.is-demo { animation: bottom-loop $dur-loop $easing infinite; }
09Huy hiệu thông báo
Icon chuông lắc nhẹ quanh đỉnh của nó đúng lúc badge góc trên phải phồng rồi xẹp lại, tạo một khoảnh khắc rõ ràng báo có thông báo mới. Dùng cho icon chuông trên header hiển thị số tin chưa đọc.
.bell__body {
transform-box: fill-box;
transform-origin: 50% 0%;
}
.bell.is-demo .bell__body { animation: shake $dur-loop $easing infinite; }
.bell.is-demo .badge { animation: pulse $dur-loop $easing infinite; }
@keyframes shake {
0%, 55%, 100% { transform: rotate(0deg); }
60% { transform: rotate(-14deg); }
65% { transform: rotate(12deg); }
70% { transform: rotate(-8deg); }
}
Chỗ dễ hỏng ở đâu — cái bẫy
Cả chín toast chỉ động đến transform và opacity, vì animate width hay top bắt trình duyệt tính lại layout ở từng khung hình. Nhưng khi xếp hai lớp chồng nhau lại lộ ra một bẫy khác. 07 icon thành công đặt vòng trung tính và vòng màu trạng thái vào cùng một chỗ rồi đổi opacity qua lại — quên grid-area: 1 / 1 trên cả hai lớp thì chúng xếp dọc lên nhau, khiến badge cao gấp đôi. 03 xếp chồng 3 lớp gặp bẫy cùng gốc: gán --i cố định ngay trong style.scss thì cả ba thẻ nhận đúng một giá trị và nằm đè khít lên nhau, nên bắt buộc phải ghi style="--i:N" riêng cho từng phần tử, mỗi thẻ một giá trị khác nhau (0, 1, 2).
01 trượt vào từ bên phải dùng translateX(140%) chứ không phải 100%, vì thẻ có box-shadow: nếu chỉ đẩy đúng bằng chiều rộng thẻ thì rìa bóng đổ vẫn ló ra mép phải màn hình.
02 toast Popover API cũng từng vấp: style mặc định của thuộc tính popover là cố định giữa màn hình, không ghi đè đầy đủ bằng position: fixed cùng top, right, margin thì toast nổi giữa màn hình chứ không nằm ở góc. Popover API chạy được từ Chrome và Edge 114 trở lên, Safari 17 trở lên, Firefox 125 trở lên theo bảng hỗ trợ trên caniuse; trình duyệt cũ hơn bỏ qua thuộc tính popover và để lại một <div> bình thường nên phải tự thêm JS bật tắt. 08 toast đáy di động dùng env(safe-area-inset-bottom, 0px) cũng theo kiểu đó — từ iOS Safari 11.2 trở lên theo tài liệu MDN về env() nó trả về đúng giá trị notch hay thanh home thật, còn trình duyệt không hỗ trợ thì calc() âm thầm dùng luôn giá trị dự phòng 0px. 06 vuốt để đóng thì nếu không gọi setPointerCapture ngay khi nhấn, ngón tay vừa vượt mép thẻ là sự kiện pointermove đứt mất và cú kéo dừng khựng giữa chừng. Sửa cả ba chỗ grid-area, --i inline, và setPointerCapture rồi đo lại render xong mới đóng vào file nén, mật khẩu mở ra là vp67sjt7, gõ đúng như nó hiện trên dòng này.
Khả năng tiếp cận
Cả chín toast đều gắn role="status" aria-live="polite", đúng mức để báo cho người dùng trình đọc màn hình mà không ngắt ngang việc họ đang làm.
<div class="toast" role="status" aria-live="polite">
<p class="toast__text">Đã lưu</p>
</div>
Với thông báo cần phản hồi ngay, như đăng nhập thất bại, nên nâng lên aria-live="assertive" để nó chen ngang lập tức. 04 thanh thời gian còn lại tự đóng nên phải theo WCAG 2.2.1 Timing Adjustable, vốn yêu cầu luôn có cách kéo dài hoặc tắt giới hạn thời gian — bản thân demo 04 không có nút dừng, nên khi dùng thật nên ghép thêm nút hoàn tác như mục 05 để đáp ứng yêu cầu này, còn ở đây mốc thời gian được đặt tối thiểu 5 giây. prefers-reduced-motion: reduce tắt gần hết animation trượt, xoay, bung ra, riêng thanh tiến trình của 04 vẫn giữ nguyên vì đó là thông tin chức năng báo khi nào toast tự đóng chứ không phải trang trí. Cách media query này được bật và mức hỗ trợ trình duyệt nằm ở tài liệu MDN.
Xem chín kiểu CSS khác đang chờ trong chuyên mục CSS, còn lý do mỗi file nén ở đây đều có khoá nằm ở trang giới thiệu.
FAQ
role="status" hay role="alert" phù hợp hơn cho toast?
Phần lớn toast hợp với role="status" aria-live="polite". role="alert" (hoặc aria-live="assertive") ngắt ngang luôn trình đọc màn hình, nên chỉ dành cho thông báo bắt buộc phải biết ngay, như thanh toán thất bại hay phiên đăng nhập hết hạn.
Toast tự đóng thì nên đặt bao nhiêu giây?
Không có con số chuẩn, nhưng WCAG 2.2.1 buộc phải có cách kéo dài hay tắt giới hạn thời gian. Mục 04 trong bài này để mặc định 5 giây; bản thân nó không có nút hoàn tác, nên khi dùng thật nên ghép thêm nút hoàn tác như mục 05, để không ai bị mất thông báo trước khi đọc xong.
Trình duyệt chưa hỗ trợ Popover API thì xử lý sao?
Trình duyệt không hỗ trợ sẽ để popover lại thành một <div> bình thường, nên chỉ cần kiểm if ('showPopover' in document.createElement('div')) rồi tự viết thêm vài dòng JS toggle class để mở đóng khi không có sẵn hỗ trợ.