Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

Radial Menus & Nonlinear UX: Hướng dẫn xây dựng experimental navigation cho web 2026

Ếch Trendy
Ếch Trendy

6 tháng trước · 12 phút đọc

Bạn có nhận ra không? Gần như mọi web đều giống nhau: logo góc trái, hamburger icon góc phải, dropdown trắng phẳng. Safe. Predictable. Nhàm.

Năm 2026, một luồng gió khác đang nổi lên - experimental navigation: radial menus tỏa ra như tia sáng, hidden drawers ẩn mình chờ gesture, và nonlinear journeys biến người dùng từ passive scroller thành người khám phá chủ động. Không phải trend thẩm mỹ thuần túy - đây là sự thay đổi về tư duy UX.

342425 Navigation truyền thống vs experimental - khác nhau ngay ở cách người dùng cảm nhận hành trình

Bài này mình sẽ đi vào 3 phần cụ thể: tại sao trend này xuất hiện, cách build radial menu bằng CSS/JS thuần, và cách tích hợp Locomotive Scroll để tạo nonlinear UX thực sự. Có code chạy được, có case study, và có cả những trade-off mà nhiều tutorial khác bỏ qua.


Tại sao radial menu và nonlinear UX lại nổi lên năm 2026

Có 2 lý do kỹ thuật đẩy trend này lên.

Thứ nhất, trình duyệt đã đủ mạnh. View Transitions API hiện đạt over 85% of browsers (dev.to - Smooth Page Transitions with Zero Libraries: The View Transitions API) độ phủ trình duyệt, CSS Anchor Positioning đã stable trên Chrome, Firefox, và Safari. Những API này làm cho smooth state transitions giữa các "không gian" trong trang trở nên khả thi mà không cần thư viện nặng.

Thứ hai, người dùng đã quen với spatial interaction - từ mobile app (swipe, pinch, long-press radial) đến game UI. Não họ đã được train để explore không gian thay vì follow list.

342426 Các API hiện đại là nền tảng cho experimental nav - đừng build trên nền cát

Nhưng quan trọng hơn là context sử dụng. Radial menu và nonlinear UX không phải cho tất cả mọi web. Nó phù hợp khi:

  • Portfolio/creative agency: Người dùng đến để bị impressed, không phải để checkout nhanh
  • Storytelling/editorial: Nội dung có nhiều luồng tường thuật, không tuyến tính
  • Product discovery: Khám phá catalog sản phẩm như đi chợ, không phải tìm theo SKU

E-commerce checkout? Booking form? Banking app? Giữ linear. Mình nhấn mạnh điều này vì có khá nhiều dev apply trend lên sai context, rồi conversion rate rơi tự do.


Build radial menu từ đầu với CSS và JavaScript thuần

Mình sẽ bỏ qua phần lý thuyết và đi thẳng vào code.

Ý tưởng cốt lõi: Một nút trigger cố định, khi click sẽ fan các menu items ra theo góc tính bằng Math.atan2(). Không cần thư viện - chỉ cần CSS transform và 20 dòng JS.

Phần HTML

<div class="radial-root">
  <button class="trigger" id="menuTrigger" aria-label="Mở menu">
    <span class="trigger-icon">+</span>
  </button>
  <ul class="menu-ring" id="menuRing" role="menu">
    <li class="menu-item" data-label="Trang chủ">🏠</li>
    <li class="menu-item" data-label="Dự án">💼</li>
    <li class="menu-item" data-label="Blog">✏️</li>
    <li class="menu-item" data-label="Liên hệ">📬</li>
  </ul>
</div>

Phần CSS

.radial-root {
  position: fixed;
  bottom: 32px;
  right: 32px;
  z-index: 1000;
}

.trigger {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: #007bff;
  border: none;
  cursor: pointer;
  font-size: 28px;
  color: #fff;
  /* Quan trọng: đặt view-transition-name để API hoạt động */
  view-transition-name: radial-trigger;
  transition: transform 0.3s ease, background 0.2s;
}

.trigger.active {
  transform: rotate(45deg);
  background: #ff4757;
}

.menu-item {
  position: absolute;
  width: 44px;  /* Tối thiểu 44px cho touch target */
  height: 44px;
  border-radius: 50%;
  background: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 20px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
  opacity: 0;
  /* Xuất phát từ tâm trigger */
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  transition: opacity 0.2s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
  /* Tooltip */
  &::after {
    content: attr(data-label);
    position: absolute;
    right: calc(100% + 8px);
    white-space: nowrap;
    font-size: 13px;
    background: #333;
    color: #fff;
    padding: 4px 8px;
    border-radius: 4px;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.15s;
  }
  &:hover::after { opacity: 1; }
}

Phần JavaScript - logic tính góc

const trigger = document.getElementById('menuTrigger');
const ring = document.getElementById('menuRing');
const items = ring.querySelectorAll('.menu-item');

const RADIUS = 90;        // Khoảng cách từ tâm ra item
const START_ANGLE = -90;  // Bắt đầu từ trên (12 giờ)
const ARC = 270;          // Fan ra 270 độ (bỏ phần dưới, tránh chắn nội dung)

function openMenu() {
  // View Transitions API - smooth state change
  const transition = document.startViewTransition?.(() => {
    trigger.classList.add('active');
    items.forEach((item, i) => {
      // Tính góc theo đơn vị radian
      const angleDeg = START_ANGLE + (ARC / (items.length - 1)) * i;
      const angleRad = (angleDeg * Math.PI) / 180;
      const x = RADIUS * Math.cos(angleRad);
      const y = RADIUS * Math.sin(angleRad);
      // Áp dụng transform - item bay ra từ tâm
      item.style.transform = `translate(${x}px, ${y}px)`;
      item.style.opacity = '1';
      item.style.transitionDelay = `${i * 40}ms`; // Stagger
    });
  });
  // Fallback nếu View Transitions chưa support
  if (!document.startViewTransition) {
    trigger.classList.add('active');
    items.forEach((item, i) => {
      const angleDeg = START_ANGLE + (ARC / (items.length - 1)) * i;
      const angleRad = (angleDeg * Math.PI) / 180;
      item.style.transform = `translate(${Math.round(RADIUS * Math.cos(angleRad))}px, ${Math.round(RADIUS * Math.sin(angleRad))}px)`;
      item.style.opacity = '1';
      item.style.transitionDelay = `${i * 40}ms`;
    });
  }
}

function closeMenu() {
  trigger.classList.remove('active');
  items.forEach(item => {
    item.style.transform = 'translate(0, 0)';
    item.style.opacity = '0';
    item.style.transitionDelay = '0ms';
  });
}

let isOpen = false;
trigger.addEventListener('click', () => {
  isOpen = !isOpen;
  isOpen ? openMenu() : closeMenu();
});

// Đóng khi click ngoài
document.addEventListener('click', (e) => {
  if (!e.target.closest('.radial-root') && isOpen) {
    isOpen = false;
    closeMenu();
  }
});

342427 Logic tính góc: Math.cos(angle) cho X, Math.sin(angle) cho Y - đây là trái tim của radial menu

Mình dùng cubic-bezier(0.34, 1.56, 0.64, 1) vì nó tạo bounce nhẹ khi item bay ra - cảm giác "sống động" hơn ease-out thẳng. Nếu cần accessible hơn, thêm @media (prefers-reduced-motion: reduce) để fallback về transition 0s.

Tải bản demo đầy đủ (HTML/CSS/JS trong 1 file) tại đây: Tải demo radial menu (HTML)


Nonlinear UX với Locomotive Scroll: từ prototype đến production

Radial menu chỉ là một mảnh. Phần khó hơn là tạo ra một nonlinear journey thực sự - nơi người dùng cảm giác đang explore không gian, không phải scroll trang.

Locomotive Scroll v4+ là lựa chọn phổ biến nhất cho việc này. Điểm mạnh của nó không phải smooth scroll (bây giờ CSS scroll-behavior: smooth đã đủ tốt), mà là scroll spy - khả năng fire events theo vị trí scroll để trigger các state thay đổi.

Setup cơ bản

<!-- index.html -->
<div data-scroll-container id="main">
  <section data-scroll-section class="section-hero">
    <!-- Khu vực trigger radial menu -->
    <div data-scroll data-scroll-call="showRadialMenu" data-scroll-repeat>
      <h1>Khám phá dự án</h1>
    </div>
  </section>

  <section data-scroll-section class="section-projects">
    <!-- Pin section này khi scroll qua -->
    <div data-scroll data-scroll-sticky data-scroll-target=".section-projects">
      <div class="project-grid">
        <!-- Nội dung project -->
      </div>
    </div>
  </section>
</div>
import LocomotiveScroll from 'locomotive-scroll';
import 'locomotive-scroll/dist/locomotive-scroll.css';

const scroll = new LocomotiveScroll({
  el: document.querySelector('[data-scroll-container]'),
  smooth: true,
  lerp: 0.08,  // Độ trơn - 0 là instant, 1 là không bao giờ đến
  getDirection: true  // Biết user đang scroll up hay down
});

// Hook vào scroll events để trigger radial menu
scroll.on('call', (value, way, obj) => {
  if (value === 'showRadialMenu' && way === 'enter') {
    // Hiện radial menu khi user scroll vào section
    document.querySelector('.radial-root').classList.add('visible');
  }
  if (value === 'showRadialMenu' && way === 'exit') {
    document.querySelector('.radial-root').classList.remove('visible');
  }
});

// Lắng nghe hướng scroll để thay đổi orientation của radial menu
scroll.on('scroll', ({ direction }) => {
  const root = document.querySelector('.radial-root');
  if (direction === 'down') {
    root.dataset.scrollDir = 'down';
  } else {
    root.dataset.scrollDir = 'up';
  }
});

342428 Locomotive Scroll làm trung gian: scroll events kích hoạt radial menu đúng thời điểm người dùng cần

Nonlinear journey pattern cho app Việt

Mình đã prototype một flow này cho một creative agency nhỏ ở Hà Nội. Vấn đề họ gặp: portfolio của họ có 6 loại dự án khác nhau (brand, motion, UI, print...), nhưng nếu list theo dropdown thì mất đi cảm giác sáng tạo.

Solution: Trang chủ là một "map" dạng scroll ngang, mỗi section là một "vùng" dự án. Radial menu hiện khi user scroll vào vùng đó, với các items thay đổi theo context:

const MENU_CONTEXTS = {
  branding: ['Xem case study', 'Quy trình', 'Liên hệ', 'Chia sẻ'],
  motion: ['Xem reel', 'Behind scenes', 'Liên hệ', 'Chia sẻ'],
  ui: ['Xem prototype', 'Code demo', 'Liên hệ', 'Chia sẻ'],
};

scroll.on('call', (value, way) => {
  if (way === 'enter' && MENU_CONTEXTS[value]) {
    // Đổi items của radial menu theo context section
    updateMenuItems(MENU_CONTEXTS[value]);
  }
});

function updateMenuItems(labels) {
  const items = document.querySelectorAll('.menu-item');
  items.forEach((item, i) => {
    item.dataset.label = labels[i] ?? '';
  });
}

Kết quả: người dùng không cần quay lại nav bar. Menu luôn relevant với nội dung đang xem. Đây là điểm mấu chốt của contextual navigation - một phần quan trọng của nonlinear UX.


Trade-offs mà bạn cần biết trước khi ship production

Mình không muốn bài này chỉ là tutorial màu hồng. Đây là những vấn đề thực tế.

Mobile touch precision là thách thức lớn nhất. Radial menu trên desktop hoạt động mượt vì bạn có chuột chính xác đến pixel. Trên mobile, ngón tay có diện tích tiếp xúc lớn hơn và người dùng không biết chính xác họ đang chạm vào đâu trong không gian radial. Minimum touch target 44px là baseline, nhưng khi 6 items tỏa ra trong 270 độ với radius 90px, tổng chu vi là ~424px - chỉ còn ~70px mỗi item. Ổn với 4 items, chật chội với 6+.

Solution thực tế: Giảm số items xuống còn 4 tối đa trên mobile, hoặc switch sang bottom sheet khi detect touch device:

const isTouchDevice = window.matchMedia('(hover: none)').matches;
if (isTouchDevice) {
  // Render bottom sheet thay vì radial menu
  document.querySelector('.radial-root').hidden = true;
  document.querySelector('.bottom-sheet-nav').hidden = false;
}

342429 Desktop vs mobile - đừng force một pattern cho cả hai, adaptive là đúng hướng

Accessibility cũng không đơn giản. Radial menu cần keyboard navigation (Tab/Arrow keys), focus visible rõ ràng, và screen reader labels. Đây là phần tốn thời gian nhất:

// Keyboard navigation cho radial menu
trigger.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();
    isOpen = !isOpen;
    isOpen ? openMenu() : closeMenu();
    // Focus vào item đầu tiên sau khi mở
    if (isOpen) items[0].focus();
  }
});

items.forEach((item, i) => {
  item.setAttribute('tabindex', '0');
  item.setAttribute('role', 'menuitem');
  item.addEventListener('keydown', (e) => {
    // Arrow key để di chuyển giữa items
    if (e.key === 'ArrowRight') items[(i + 1) % items.length].focus();
    if (e.key === 'ArrowLeft') items[(i - 1 + items.length) % items.length].focus();
    if (e.key === 'Escape') { closeMenu(); trigger.focus(); }
  });
});

Performance với Locomotive Scroll: thư viện này dùng requestAnimationFrame loop và clone DOM để tính scroll position. Trên device thấp (phần lớn người dùng VN vẫn dùng thiết bị tầm trung), lerp animation có thể jank. Test kỹ trên Pixel 6a hoặc thiết bị tương đương trước khi ship.

Và cuối cùng - đừng quên fallback graceful. Nếu JS fail, user vẫn cần navigate được. CSS-only fallback hay server-side rendered nav luôn cần có sẵn.


Prototype trong Figma trước khi viết một dòng code

Có một bước mà nhiều dev hay bỏ qua: validate pattern trước khi build. Radial menu trông cool trên Dribbble nhưng user có hiểu cách dùng không?

Figma 2025-2026 có vài tính năng hỗ trợ tốt cho experimental nav prototyping:

Smart Animate + Component States: Tạo component radial menu với 2 states (closed/open), dùng Smart Animate để preview animation fan-out thực tế. Không cần code, khách hàng click được ngay.

FigJam cho journey mapping: Vẽ nonlinear user journey trên FigJam - các vùng nội dung như nodes, user paths như edges. Giúp team thống nhất flow trước khi design chi tiết.

Dev Mode export: Khi đã approved, Dev Mode generate CSS variables và spacing tokens tự động. Với radial layout, bạn có thể export exact border-radius, box-shadow, và timing functions cho transition.

342430 Figma prototype giúp validate trước khi code - tiết kiệm nhiều ngày debug

Một tip nhỏ: Dùng Figma Make (AI prototyping tool) để generate interactive prototype từ mô tả text. Prompt kiểu "Radial menu 4 items, trigger button góc dưới phải, fan-out khi click, smooth animation" sẽ cho ra prototype click-through nhanh hơn tự build.

Sau đó test với ít nhất 5 người dùng thật. Câu hỏi cần trả lời: Họ có discover được menu không? Họ hiểu các items sẽ làm gì không trước khi click? Nếu câu trả lời là "không" cho 3/5 người - reconsider.

Nếu bạn chưa quen với CSS animation và muốn build solid foundation trước khi nhảy vào experimental UI, khóa HTML CSS Pro có phần animation và transform khá chi tiết, là nền tốt để hiểu mechanism đằng sau những gì mình code ở trên.


Từ experimental đến production: checklist trước khi ship

Bạn đã có prototype approved, đã viết code, đã test basic. Trước khi deploy, check những điểm này.

Tải checklist đầy đủ: Tải checklist production radial menu

Performance audit:

  • [ ] Lighthouse Performance score > 85 trên mobile (throttle 4G)
  • [ ] Radial animation dùng transformopacity only - KHÔNG animate top/left/width/height
  • [ ] Locomotive Scroll lerp value ≤ 0.1 trên thiết bị low-end
  • [ ] will-change: transform chỉ set khi element sắp animate, remove sau khi xong

Accessibility audit:

  • [ ] Keyboard navigation hoàn chỉnh (Tab, Arrow keys, Enter, Escape)
  • [ ] role="menu"role="menuitem" đúng chỗ
  • [ ] aria-expanded trên trigger phản ánh đúng state
  • [ ] Test với VoiceOver (Mac) hoặc NVDA (Windows)
  • [ ] @media (prefers-reduced-motion: reduce) disable animation

Mobile/touch audit:

  • [ ] Touch target tối thiểu 44×44px cho mỗi item
  • [ ] Test trên Chrome DevTools với touch emulation
  • [ ] Fallback navigation accessible khi JS fail hoặc trên touch-only device

342431 Checklist không phải formality - mỗi item là một bug đã từng xảy ra ở production

Browser compat:

  • [ ] document.startViewTransition có fallback graceful
  • [ ] CSS Anchor Positioning có fallback position: fixed
  • [ ] Test Safari iOS - vẫn còn quirks với position: fixed trong scroll containers

Experimental navigation không đồng nghĩa với "skip những thứ boring như accessibility". Ngược lại - khi UX pattern đã unconventional, accessibility lại càng phải solid hơn bình thường, bởi vì người dùng không có mental model sẵn để fallback.

Bước tiếp:

  1. Clone demo radial menu ở trên, mở trong browser
  2. Chạy Lighthouse audit ngay trên localhost
  3. Test keyboard navigation - bạn có navigate được mà không dùng chuột không?
  4. Nếu pass cả 3, bạn đã sẵn sàng integrate vào project thật

Gặp vướng mắc với animation timing hay Locomotive config, drop comment - mình sẽ reply.