Bài viết này hệ thống hóa và nâng cấp các mẫu tính năng UI/UX phổ biến trong website tin tức/blog: menu trượt (fly-out), nút trở về đầu trang, bật/tắt khung tìm kiếm, danh sách mạng xã hội di động, khối “xu hướng”, khu vực bình luận, nút “đọc tiếp”, tab nội dung, cử chỉ vuốt chạm (swipe) và carousel (Elastislide). Bạn sẽ có được kiến trúc sự kiện sạch, tối ưu hiệu năng, thân thiện truy cập, đồng thời đáp ứng chuẩn SEO/UX hiện đại.
Khi nào dùng jQuery, khi nào dùng Vanilla JS
- Nên dùng jQuery nếu bạn bảo trì theme cũ, cần hỗ trợ trình duyệt di sản, hoặc muốn triển khai nhanh các thao tác DOM đơn giản.
- Nên dùng JavaScript hiện đại (ES6+, Pointer Events, CSS Scroll Snap, IntersectionObserver) để:
- Cắt giảm phụ thuộc, giảm kích thước tài nguyên.
- Tối ưu Core Web Vitals (LCP, INP, CLS).
- Tăng khả năng truy cập và tương thích thiết bị cảm ứng.
Mẹo: Có thể giữ jQuery cho code cũ, nhưng viết tính năng mới bằng Vanilla JS để giảm dần phụ thuộc.
Menu trượt (Fly-Out Navigation)
Mục tiêu: bật/tắt lớp phủ (overlay) và bảng điều hướng, khóa cuộn nền, cập nhật ARIA để hỗ trợ trình đọc màn hình.
Khuyến nghị UX/SEO:
- Dùng aria-expanded trên nút mở menu.
- Dùng focus trap trong menu để không “rò” focus ra nền.
- Khóa cuộn nền bằng thuộc tính inert trên nội dung ngoài menu (hoặc thêm class overflow-hidden cho body).
- Đảm bảo đóng menu bằng phím Esc và khi click ra ngoài.
Ví dụ JS hiện đại:
const btn = document.querySelector('.js-flyout-toggle');
const panel = document.getElementById('flyout-panel');
const overlay = document.getElementById('flyout-overlay');
const page = document.querySelector('main');
function openMenu() {
panel.classList.add('is-open');
overlay.classList.add('is-visible');
btn.setAttribute('aria-expanded', 'true');
page.setAttribute('inert', '');
document.body.style.overflow = 'hidden';
panel.querySelector('a,button,input,select,textarea')?.focus();
}
function closeMenu() {
panel.classList.remove('is-open');
overlay.classList.remove('is-visible');
btn.setAttribute('aria-expanded', 'false');
page.removeAttribute('inert');
document.body.style.overflow = '';
btn.focus();
}
btn.addEventListener('click', () =>
panel.classList.contains('is-open') ? closeMenu() : openMenu()
);
overlay.addEventListener('click', closeMenu);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
CSS gợi ý:
#flyout-panel { transition: transform .3s ease; transform: translateX(-100%); }
#flyout-panel.is-open { transform: translateX(0); }
#flyout-overlay { opacity: 0; pointer-events: none; transition: opacity .2s; }
#flyout-overlay.is-visible { opacity: .5; pointer-events: auto; }
Nút Trở về đầu trang (Back-to-Top)
Mục tiêu: cuộn mượt, ẩn/hiện theo vị trí cuộn, tôn trọng tùy chọn giảm chuyển động.
- Dùng IntersectionObserver để hiện nút khi người dùng cuộn qua hero/đầu trang.
- Tôn trọng prefers-reduced-motion để tắt animation khi người dùng không muốn.
const backTop = document.querySelector('.js-back-to-top');
backTop.addEventListener('click', e => {
e.preventDefault();
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' });
});
const sentinel = document.querySelector('.js-top-sentinel');
new IntersectionObserver(([e]) => {
backTop.classList.toggle('is-visible', !e.isIntersecting);
}).observe(sentinel);
Bật/tắt khung tìm kiếm (Search Toggle)
- Thêm aria-expanded cho nút, aria-controls để liên kết nút với khung tìm kiếm.
- Focus vào input khi mở, đóng bằng Esc, click ra ngoài để đóng.
const searchBtn = document.querySelector('.js-search-toggle');
const searchWrap = document.getElementById('search-wrap');
const searchInput = searchWrap.querySelector('input[type="search"]');
function toggleSearch(force) {
const willOpen = force ?? !searchWrap.classList.contains('is-open');
searchWrap.classList.toggle('is-open', willOpen);
searchBtn.setAttribute('aria-expanded', willOpen);
if (willOpen) searchInput.focus();
}
searchBtn.addEventListener('click', () => toggleSearch());
document.addEventListener('keydown', e => { if (e.key === 'Escape') toggleSearch(false); });
document.addEventListener('click', e => {
if (!searchWrap.contains(e.target) && !searchBtn.contains(e.target)) toggleSearch(false);
});
Danh sách mạng xã hội di động (Mobile social)
- Chỉ hiển thị khi người dùng cần, để giảm xao nhãng và tiết kiệm không gian màn hình.
- Đảm bảo kích thước mục tiêu cảm ứng tối thiểu 44x44px.
document.querySelector('.js-mob-soc-toggle')
.addEventListener('click', () =>
document.querySelector('.mob-soc-list').classList.toggle('is-open')
);
Khối “Xu hướng” (Trending) bật/tắt
- Lưu trạng thái trong sessionStorage để giữ lựa chọn người dùng theo phiên.
const trendBtn = document.querySelector('.js-trend-toggle');
const trendWrap = document.getElementById('trend-wrap');
const saved = sessionStorage.getItem('trend-open') === '1';
trendWrap.classList.toggle('is-open', saved);
trendBtn.addEventListener('click', () => {
const next = !trendWrap.classList.contains('is-open');
trendWrap.classList.toggle('is-open', next);
sessionStorage.setItem('trend-open', next ? '1' : '0');
});
Bình luận: tối ưu tải chậm (Lazy load Disqus/Comments)
- Trì hoãn tải Disqus đến khi người dùng bấm “Xem bình luận” hoặc khi khối bình luận sắp vào khung nhìn.
- Cải thiện LCP, giảm tài nguyên chặn dựng.
const comBtn = document.querySelector('.js-comments-button');
const comWrap = document.getElementById('comments');
const disqusWrap = document.getElementById('disqus_thread');
function loadDisqus() {
if (disqusWrap.dataset.loaded) return;
disqusWrap.dataset.loaded = '1';
const s = document.createElement('script');
s.src = 'https://YOUR-SHORTNAME.disqus.com/embed.js';
s.setAttribute('data-timestamp', Date.now());
document.body.appendChild(s);
}
comBtn.addEventListener('click', () => {
comWrap.hidden = false;
disqusWrap.hidden = false;
comBtn.hidden = true;
loadDisqus();
});
new IntersectionObserver((entries, obs) => {
if (entries.some(e => e.isIntersecting)) {
loadDisqus();
obs.disconnect();
}
}).observe(comWrap);
Nút “Đọc tiếp”/mở rộng nội dung
- Tránh max-height “none” đột ngột gây nhảy bố cục.
- Dùng kỹ thuật đo chiều cao thật và animate mượt.
const content = document.querySelector('.js-expand-content');
const btnMore = document.querySelector('.js-expand-btn');
btnMore.addEventListener('click', () => {
const full = content.scrollHeight;
content.style.maxHeight = full + 'px';
content.addEventListener('transitionend', () => {
content.style.maxHeight = 'none'; // khóa chiều cao sau khi animate
}, { once: true });
btnMore.hidden = true;
});
CSS:
.js-expand-content { max-height: 480px; overflow: hidden; transition: max-height .3s ease; }
Tab nội dung và tab widget: ARIA + bàn phím + deep-link
- Áp dụng pattern WAI-ARIA cho tablist/tab/tabpanel.
- Điều hướng bằng phím mũi tên, Home/End.
- Hỗ trợ deep-link qua hash để mở tab theo URL.
HTML rút gọn:
<div class="tabs" role="tablist" aria-label="Nội dung nổi bật">
<button role="tab" aria-controls="panel-a" id="tab-a" aria-selected="true">Mới nhất</button>
<button role="tab" aria-controls="panel-b" id="tab-b">Phổ biến</button>
</div>
<section id="panel-a" role="tabpanel" aria-labelledby="tab-a">...</section>
<section id="panel-b" role="tabpanel" aria-labelledby="tab-b" hidden>...</section>
JS:
function initTabs(root) {
const tabs = root.querySelectorAll('[role="tab"]');
const panels = root.parentElement.querySelectorAll('[role="tabpanel"]');
function activateTab(tab) {
tabs.forEach(t => t.setAttribute('aria-selected', t === tab ? 'true' : 'false'));
panels.forEach(p => p.hidden = p.id !== tab.getAttribute('aria-controls'));
tab.focus();
history.replaceState(null, '', '#' + tab.id);
}
tabs.forEach(tab => {
tab.addEventListener('click', () => activateTab(tab));
tab.addEventListener('keydown', e => {
const i = Array.from(tabs).indexOf(tab);
let next = null;
if (e.key === 'ArrowRight') next = tabs[(i + 1) % tabs.length];
if (e.key === 'ArrowLeft') next = tabs[(i - 1 + tabs.length) % tabs.length];
if (e.key === 'Home') next = tabs[0];
if (e.key === 'End') next = tabs[tabs.length - 1];
if (next) { e.preventDefault(); activateTab(next); }
});
});
// Deep link
const hash = location.hash.slice(1);
const target = hash && root.querySelector('#' + hash);
if (target && target.getAttribute('role') === 'tab') activateTab(target);
}
document.querySelectorAll('[role="tablist"]').forEach(initTabs);
Cử chỉ vuốt: thay thế touchwipe bằng Pointer Events
- Gộp sự kiện chuột, cảm ứng, bút bằng Pointer Events.
- Sử dụng passive listeners để tránh chặn cuộn mặc định.
function onSwipe(el, { minX = 20, minY = 20, left, right, up, down } = {}) {
let sx = 0, sy = 0, tracking = false;
el.addEventListener('pointerdown', e => { sx = e.clientX; sy = e.clientY; tracking = true; }, { passive: true });
el.addEventListener('pointermove', e => {
if (!tracking) return;
const dx = e.clientX - sx, dy = e.clientY - sy;
if (Math.abs(dx) >= minX) {
tracking = false; dx < 0 ? left?.() : right?.();
} else if (Math.abs(dy) >= minY) {
tracking = false; dy < 0 ? up?.() : down?.();
}
}, { passive: true });
el.addEventListener('pointerup', () => tracking = false, { passive: true });
el.addEventListener('pointercancel', () => tracking = false, { passive: true });
}
Ứng dụng:
onSwipe(document.querySelector('.js-swipe-area'), {
left: () => console.log('Vuốt trái'),
right: () => console.log('Vuốt phải')
});
Slider/Carousel: từ Elastislide đến CSS Scroll Snap
Elastislide dùng tính toán chiều rộng ảnh, biên, số lượng tối thiểu. Giải pháp hiện đại:
- CSS Scroll Snap: mượt, nhẹ, hỗ trợ truy cập tốt.
- ARIA: role=”region”, aria-roledescription=”carousel”, aria-live=”off”.
- Nút Previous/Next có aria-label, cập nhật aria-disabled khi chạm biên.
- Lazy-load ảnh qua loading=”lazy”.
HTML/CSS tối giản:
<div class="carousel" aria-roledescription="carousel" aria-label="Bài viết nổi bật">
<div class="track">
<article class="slide">...</article>
<article class="slide">...</article>
...
</div>
<button class="prev" aria-label="Trước"></button>
<button class="next" aria-label="Sau"></button>
</div>
.track {
display: grid; grid-auto-flow: column; grid-auto-columns: 80%;
gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth;
}
.slide { scroll-snap-align: start; }
.carousel .prev, .carousel .next { /* style nút */ }
@media (min-width: 1024px) { .track { grid-auto-columns: 33.333%; } }
JS điều hướng:
const car = document.querySelector('.carousel');
const track = car.querySelector('.track');
const prev = car.querySelector('.prev');
const next = car.querySelector('.next');
function scrollBySlide(dir = 1) {
const slide = track.querySelector('.slide');
const width = slide?.getBoundingClientRect().width || 300;
track.scrollBy({ left: dir * (width + 16), behavior: 'smooth' });
}
prev.addEventListener('click', () => scrollBySlide(-1));
next.addEventListener('click', () => scrollBySlide(1));
Hiệu năng: thực hành tốt cho Core Web Vitals
- Lắng nghe sự kiện cuộn/resize với passive listeners; throttle/debounce; dùng requestAnimationFrame cho cập nhật DOM.
- Tránh reflow liên tiếp: đọc trước (getBoundingClientRect), ghi sau (classList).
- Tách JS không quan trọng bằng defer/async; trì hoãn script bên thứ ba (Disqus, widget mạng xã hội).
- Dùng CSS thay vì JS khi có thể (transition, scroll-behavior, Scroll Snap).
- Ảnh: lazy-load, kích thước đúng, định dạng hiện đại (WebP/AVIF), preconnect CDNs.
Khả năng truy cập (A11y) và bàn phím
- Cập nhật aria-expanded, aria-controls khi toggle.
- Tab: role, keyboard nav đầy đủ.
- Menu: đóng Esc, focus trap.
- Đảm bảo tương phản màu, kích thước vùng chạm, thứ tự tab logic.
- Tôn trọng prefers-reduced-motion.
SEO kỹ thuật cho UI động
- Nội dung quan trọng nên hiện diện trong DOM ban đầu (SSR/SSG) để trình thu thập đọc được.
- Tránh che giấu nội dung bằng display:none lâu dài nếu bạn muốn index.
- Đảm bảo link nội bộ là thẻ a chuẩn với href, không chỉ onclick.
- Schema phù hợp: Article, Breadcrumb, ItemList cho slider “bài viết”.
- Kiểm tra với Lighthouse/PSI sau khi thêm tính năng UI.
Kiểm thử và bảo trì
- Kiểm thử trên iOS Safari/Android Chrome/desktop với Touch/Pointer Events.
- Regression test khi thêm/đổi class, tránh rò rỉ sự kiện khi có nhiều nhóm tab/widget.
- Đặt namespace class/js- prefix cho hook JavaScript để không phụ thuộc style class.
Checklist triển khai nhanh
- Menu trượt: aria-expanded, overlay, inert, Esc.
- Back-to-Top: IntersectionObserver, prefers-reduced-motion.
- Tìm kiếm: focus management, click-outside, Esc.
- Mạng xã hội: kích thước chạm, ẩn/hiện nhẹ.
- Xu hướng: lưu trạng thái phiên.
- Bình luận: lazy load Disqus.
- Đọc tiếp: animate max-height có đo kích thước.
- Tab: WAI-ARIA, keyboard, deep-link hash.
- Vuốt: Pointer Events, passive listeners.
- Slider: CSS Scroll Snap, aria, lazy images.
Mẫu kiến trúc sự kiện gọn nhẹ
document.addEventListener('click', e => {
const t = e.target.closest('[data-action]');
if (!t) return;
const action = t.dataset.action;
if (action === 'toggle-search') toggleSearch();
if (action === 'open-menu') openMenu();
if (action === 'close-menu') closeMenu();
// ... mở rộng dễ dàng mà không gắn nhiều listener lẻ tẻ
});
Bằng cách áp dụng các thực hành trên, bạn sẽ có giao diện tương tác mượt mà, dễ truy cập, thân thiện với SEO và bền vững cho việc mở rộng về sau, dù đang duy trì nền jQuery hay chuyển dần sang JavaScript hiện đại.