# Production Deployment Checklist: Experimental Navigation (Radial Menu & Nonlinear UX)
> **Version:** 1.0.0 | **Last Updated:** 2025 | **Target:** Experimental/Avant-garde navigation systems
>
> â ī¸ Experimental UX patterns carry higher risk â this checklist is non-negotiable before production.
---
## Quick Reference
| Section | Items | Priority |
|---|---|---|
| Performance Audit | 12 items | đ´ Critical |
| Accessibility Audit | 14 items | đ´ Critical |
| Mobile/Touch Audit | 10 items | đ High |
| Browser Compatibility | 11 items | đ High |
| User Testing | 9 items | đĄ Required |
**Sign-off required:** Lead Dev + Design + QA before merge to `main`.
---
## Section 1 â Performance Audit
> Radial menus and nonlinear UX rely heavily on transforms, animations, and scroll-based effects. Poor performance = broken experience. Target: **Lighthouse Performance âĨ 90** on mobile.
---
### 1.1 Lighthouse Baseline
- [ ] **Run Lighthouse in incognito, throttled to "Mobile" preset**
*Why:* Extensions and cached assets skew scores. Mobile throttling reflects real-world conditions for experimental UX which is heavier than conventional navigation.
```bash
# CLI audit â no browser extensions, reproducible in CI
npx lighthouse https://your-domain.com \
--preset=perf \
--emulated-form-factor=mobile \
--throttling-method=simulate \
--output=json \
--output-path=./reports/lighthouse-$(date +%Y%m%d).json
# Quick score check
cat ./reports/lighthouse-*.json | \
node -e "const d=require('fs').readFileSync('/dev/stdin','utf8'); \
const r=JSON.parse(d); \
console.log('Performance:', r.categories.performance.score * 100)"
```
- [ ] **Performance score âĨ 90 on mobile, âĨ 95 on desktop**
*Why:* Radial menus with CSS transforms + JS orchestration easily drop to 60-70 without optimization. Set the bar high before launch.
```bash
# Automated assertion in CI (fail build if below threshold)
npx lighthouse-ci autorun \
--collect.url=https://your-domain.com \
--assert.assertions.performance=["error", {"minScore": 0.9}]
```
- [ ] **Verify no render-blocking resources tied to navigation scripts**
*Why:* Navigation JS loading synchronously blocks FCP â users see broken layout before menu initializes.
```html
```
---
### 1.2 CSS Transform Optimization
- [ ] **All animated elements use `transform` and `opacity` ONLY â no layout-triggering properties**
*Why:* Animating `top`, `left`, `width`, `margin` triggers layout recalculation on every frame. Radial menu with 8 items animating `top/left` = 8 Ã 60fps layout thrashes = jank.
```css
/* â WRONG â triggers layout + paint */
.radial-item {
transition: top 0.3s, left 0.3s, opacity 0.3s;
}
/* â
CORRECT â compositor-only, no layout */
.radial-item {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 0.3s ease;
}
/* Radial positioning via transform, not top/left */
.radial-item:nth-child(1) {
--angle: 0deg;
--radius: 120px;
transform: rotate(var(--angle)) translateX(var(--radius)) rotate(calc(-1 * var(--angle)));
}
```
```javascript
// Verify in DevTools console â should show "Composite" not "Layout"
// Open DevTools > Rendering > Paint flashing
// Trigger menu open â NO green flashing on radial items = â
// Programmatic check
const items = document.querySelectorAll('.radial-item');
items.forEach(el => {
const styles = getComputedStyle(el);
const animatedProps = styles.transition;
const hasLayoutProps = /top|left|right|bottom|width|height|margin|padding/.test(animatedProps);
console.assert(!hasLayoutProps, `â Layout property in transition: ${el.className}`);
});
```
- [ ] **Verify GPU layer promotion is intentional â no layer explosion**
*Why:* Excessive `will-change` or `translateZ(0)` creates too many GPU layers, consuming VRAM and causing compositing overhead. Especially bad on low-end mobile.
```javascript
// DevTools > Layers panel â count layers before/after menu open
// Expected: < 20 new layers created when menu opens
// Chrome DevTools command
// Open: chrome://flags/#show-composited-layer-borders
// Then inspect: Layers tab in DevTools
// Check layer count programmatically (rough estimate)
const promoted = [...document.querySelectorAll('*')].filter(el => {
const style = getComputedStyle(el);
return style.willChange !== 'auto' ||
style.transform !== 'none' ||
style.position === 'fixed';
});
console.log(`Promoted elements: ${promoted.length}`);
// Should be < 30 total, alert if > 50
```
---
### 1.3 `will-change` Audit
- [ ] **`will-change` applied only to elements about to animate, removed after animation**
*Why:* Static `will-change: transform` on all menu items wastes GPU memory 24/7 even when menu is closed. On mobile with 2GB RAM, this causes browser to drop frames elsewhere.
```javascript
// â WRONG â permanent will-change
// CSS: .radial-item { will-change: transform; }
// â
CORRECT â dynamic will-change lifecycle
class RadialMenu {
open() {
// Apply BEFORE animation starts
this.items.forEach(item => {
item.style.willChange = 'transform, opacity';
});
// Animate
this.animateIn();
}
onAnimationComplete() {
// Remove AFTER animation ends â critical!
this.items.forEach(item => {
item.style.willChange = 'auto';
});
}
close() {
this.items.forEach(item => {
item.style.willChange = 'transform, opacity';
});
this.animateOut().then(() => {
this.items.forEach(item => {
item.style.willChange = 'auto';
});
});
}
}
```
- [ ] **Audit existing `will-change` declarations in codebase**
*Why:* Accumulated technical debt â developers add `will-change` to fix jank, forget to remove it.
```bash
# Find all will-change in CSS/SCSS/styled-components
grep -r "will-change" ./src --include="*.css" --include="*.scss" \
--include="*.ts" --include="*.tsx" --include="*.js" \
-n --color=always
# Count occurrences
grep -r "will-change" ./src | grep -v "will-change: auto" | wc -l
# Target: 0 static will-change declarations in CSS files
# All should be dynamically applied via JS
```
---
### 1.4 Locomotive Scroll / Lerp Performance
- [ ] **Locomotive Scroll lerp value validated: 0.05â0.15 range**
*Why:* Lerp value controls smoothing lag. Too low (0.02) = sluggish, high input latency perception. Too high (0.5) = no smoothing benefit, defeats purpose. Default 0.1 is baseline but tune per device.
```javascript
// locomotive-scroll config validation
const scroll = new LocomotiveScroll({
el: document.querySelector('[data-scroll-container]'),
smooth: true,
lerp: 0.1, // â Validate this value
multiplier: 1,
smartphone: {
smooth: false, // â CRITICAL: disable on mobile (see mobile section)
lerp: 0.1
},
tablet: {
smooth: true,
lerp: 0.08 // Slightly more responsive on tablet
}
});
// Assertion
console.assert(scroll.options.lerp >= 0.05 && scroll.options.lerp <= 0.15,
`â ī¸ Lerp value ${scroll.options.lerp} outside recommended range`);
```
- [ ] **Verify Locomotive Scroll does not conflict with radial menu fixed positioning**
*Why:* Locomotive uses `transform: translateY()` on scroll container, which creates a new stacking context. Fixed-positioned radial menu gets trapped inside transformed parent â common bug.
```javascript
// Test: open radial menu while page is mid-scroll
// Expected: menu stays in viewport center, doesn't scroll with content
// Diagnostic
const menuEl = document.querySelector('.radial-menu');
const scrollContainer = document.querySelector('[data-scroll-container]');
// Check if menu is child of scroll container (it shouldn't be)
const isTrapped = scrollContainer.contains(menuEl);
console.assert(!isTrapped,
'â Radial menu is child of scroll container â will be transformed by Locomotive');
// Fix: move menu to body level
// document.body.appendChild(menuEl);
// Verify stacking context isn't breaking fixed position
let parent = menuEl.parentElement;
while (parent) {
const style = getComputedStyle(parent);
const hasTransform = style.transform !== 'none';
const hasFilter = style.filter !== 'none';
if (hasTransform || hasFilter) {
console.warn(`â ī¸ Stacking context parent: ${parent.tagName}.${parent.className}`);
}
parent = parent.parentElement;
}
```
- [ ] **RAF loop does not run when Locomotive is idle**
*Why:* Locomotive's requestAnimationFrame loop runs continuously by default, consuming CPU even when nothing is scrolling. On battery-powered devices, this drains battery and causes thermal throttling.
```javascript
// Check if custom RAF loops are properly cancelled
// DevTools > Performance > Record idle state for 5 seconds
// Expected: < 5% CPU usage when page is not being interacted with
// If using custom lerp loop, implement idle detection
class SmoothScroll {
#rafId = null;
#isScrolling = false;
#idleTimeout = null;
startLoop() {
if (this.#rafId) return; // Prevent duplicate loops
this.#rafId = requestAnimationFrame(this.tick.bind(this));
}
stopLoop() {
if (this.#rafId) {
cancelAnimationFrame(this.#rafId);
this.#rafId = null;
}
}
onScroll() {
this.#isScrolling = true;
this.startLoop();
clearTimeout(this.#idleTimeout);
this.#idleTimeout = setTimeout(() => {
this.#isScrolling = false;
// Stop RAF when scroll has settled
if (Math.abs(this.current - this.target) < 0.1) {
this.stopLoop();
}
}, 150);
}
}
```
---
## Section 2 â Accessibility Audit
> Experimental navigation must not exclude users. A radial menu that only works with a mouse is a lawsuit waiting to happen. WCAG 2.1 AA minimum â target AAA where possible.
---
### 2.1 Keyboard Navigation
- [ ] **Radial menu fully operable with keyboard alone â no mouse required**
*Why:* ~7% of users rely on keyboard navigation. Radial menus are inherently spatial â keyboard users can't "point" to items. Must implement logical tab order or arrow key navigation.
```javascript
// Keyboard navigation implementation for radial menu
class RadialMenuKeyboard {
constructor(menu) {
this.menu = menu;
this.items = [...menu.querySelectorAll('[role="menuitem"]')];
this.currentIndex = 0;
menu.addEventListener('keydown', this.handleKeydown.bind(this));
}
handleKeydown(e) {
switch(e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
this.navigate(1);
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
this.navigate(-1);
break;
case 'Enter':
case ' ':
e.preventDefault();
this.activate();
break;
case 'Escape':
e.preventDefault();
this.close();
// Return focus to trigger element â critical!
this.triggerElement.focus();
break;
case 'Tab':
// Trap focus within open menu
e.preventDefault();
this.navigate(e.shiftKey ? -1 : 1);
break;
}
}
navigate(direction) {
this.currentIndex = (this.currentIndex + direction + this.items.length)
% this.items.length;
this.items[this.currentIndex].focus();
}
}
// Test with axe-core
// npm install axe-core
import axe from 'axe-core';
axe.run(document.querySelector('.radial-menu')).then(results => {
if (results.violations.length > 0) {
console.error('Accessibility violations:', results.violations);
}
});
```
- [ ] **Focus is trapped inside open radial menu and restored on close**
*Why:* Without focus trap, Tab key exits the menu while it's visually open, creating a disconnect between visual state and keyboard position. Users lose orientation.
```javascript
// Focus trap utility
function createFocusTrap(container) {
const focusableSelectors = [
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
'select:not([disabled])', 'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])', '[role="menuitem"]'
].join(', ');
const getFocusable = () => [...container.querySelectorAll(focusableSelectors)];
function handleTab(e) {
if (e.key !== 'Tab') return;
const focusable = getFocusable();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
return {
activate: () => {
container.addEventListener('keydown', handleTab);
getFocusable()[0]?.focus();
},
deactivate: () => container.removeEventListener('keydown', handleTab)
};
}
// Verify focus trap is working
// Manual test: Tab through all menu items â should cycle, not escape menu
```
- [ ] **Visible focus indicators on all interactive elements â not just `:focus` but `:focus-visible`**
*Why:* Experimental designs often remove focus rings for aesthetics (`outline: none`). This makes keyboard navigation invisible â users can't tell where they are.
```css
/* â NEVER DO THIS */
* { outline: none; }
*:focus { outline: none; }
/* â
CORRECT â enhance, don't remove */
.radial-item:focus-visible {
outline: 3px solid var(--color-focus, #0066ff);
outline-offset: 4px;
/* Custom focus for radial items */
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.3);
}
/* For radial geometry, consider custom focus indicator */
.radial-item:focus-visible::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 3px solid var(--color-focus);
animation: pulse-focus 1s infinite;
}
```
```bash
# Automated check for outline removal
grep -r "outline.*none\|outline:.*0" ./src --include="*.css" --include="*.scss" \
--include="*.ts" --include="*.tsx" | \
grep -v "focus-visible\|focus-within\|:focus" | \
grep -v "\/\/"
# Should return 0 results
```
---
### 2.2 ARIA Roles & Labels
- [ ] **Radial menu has correct ARIA role structure: `menu`, `menuitem`, `menubar` as appropriate**
*Why:* Screen readers need semantic context to announce navigation. Without roles, a div-based radial menu is announced as generic content â users have no idea it's a navigation menu.
```html
```
- [ ] **Live region announces navigation state changes to screen readers**
*Why:* When nonlinear navigation loads content via AJAX/SPA routing, screen readers don't know page changed. Users think nothing happened.
```html
```
- [ ] **Test with actual screen readers: VoiceOver (macOS/iOS) and NVDA (Windows)**
*Why:* axe-core catches structural issues but not experiential ones. Only a real screen reader reveals whether the radial menu makes sense when heard, not seen.
```bash
# VoiceOver macOS test commands
# Enable: Cmd + F5
# Navigate to menu trigger: Tab
# Expected announcement: "Open navigation menu, button, collapsed"
# Open menu: Space/Enter
# Expected: "Main navigation, menu"
# Navigate items: Arrow keys
# Expected: "About, menu item, 1 of 5"
# NVDA Windows (free)
# Download: https://www.nvaccess.org/download/
# Enable: Ctrl + Alt + N
# Forms mode: F (navigate interactive elements)
# Same test as above
# Automated with jest-axe
# npm install jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('radial menu has no accessibility violations', async () => {
render();
const results = await axe(document.body);
expect(results).toHaveNoViolations();
});
```
---
### 2.3 `prefers-reduced-motion`
- [ ] **ALL animations disabled or replaced with instant transitions when `prefers-reduced-motion: reduce`**
*Why:* Users with vestibular disorders, epilepsy, or motion sensitivity can experience nausea, migraines, or seizures from animated radial menus. This is a medical necessity, not a preference.
```css
/* Base animations for radial menu */
.radial-item {
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 0.3s ease;
}
.radial-menu {
animation: menu-bloom 0.5s ease forwards;
}
/* Locomotive scroll-based parallax */
[data-scroll-speed] {
transition: transform 0.1s linear;
}
/* OVERRIDE â instant transitions for reduced motion */
@media (prefers-reduced-motion: reduce) {
/* Remove ALL transitions */
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Specifically disable Locomotive Scroll smooth scrolling */
[data-scroll-container] {
overflow: auto !important;
transform: none !important;
}
/* Radial menu: appear instantly */
.radial-menu {
animation: none;
}
.radial-item {
transition: none;
/* Items appear at final position immediately */
}
/* Disable parallax */
[data-scroll-speed] {
transform: none !important;
}
}
```
```javascript
// JS-side reduced motion check â for JS-driven animations
const REDUCED_MOTION = window.matchMedia('(prefers-reduced-motion: reduce)');
function getAnimationConfig() {
if (REDUCED_MOTION.matches) {
return {
duration: 0,
delay: 0,
easing: 'linear',
// Still provide visual feedback, just instant
};
}
return {
duration: 400,
delay: (index) => index * 50,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
};
}
// Listen for changes (user can change setting while page is open)
REDUCED_MOTION.addEventListener('change', () => {
// Re-initialize animations with new config
initAnimations(getAnimationConfig());
});
// Verify in browser console
console.log('Reduced motion:', window.matchMedia('(prefers-reduced-motion: reduce)').matches);
```
- [ ] **Locomotive Scroll disabled entirely under `prefers-reduced-motion`**
*Why:* Locomotive's smooth scrolling adds artificial lag between user input and page movement â this exact sensation triggers motion sickness in sensitive users.
```javascript
// Conditionally initialize Locomotive
function initScroll() {
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReduced) {
// Native scroll â no JS overhead
document.documentElement.style.scrollBehavior = 'auto';
console.log('âšī¸ Smooth scroll disabled: prefers-reduced-motion');
return null;
}
const scroll = new LocomotiveScroll({
el: document.querySelector('[data-scroll-container]'),
smooth: true,
lerp: 0.1,
});
return scroll;
}
// Test: Enable reduced motion in macOS System Preferences > Accessibility
// Then verify: No scroll smoothing, no parallax, no menu bloom animation
```
---
### 2.4 Screen Reader Compatibility
- [ ] **Nonlinear page transitions don't break screen reader document order**
*Why:* SPA transitions that reorder DOM elements confuse screen readers â the virtual cursor position becomes invalid. Users get lost or hear repeated content.
```javascript
// After any SPA navigation/transition
function afterPageTransition(newPageElement) {
// 1. Set focus to new page heading
const heading = newPageElement.querySelector('h1, [role="heading"]');
if (heading) {
heading.tabIndex = -1;
heading.focus();
// Remove tabindex after focus (don't pollute tab order)
heading.addEventListener('blur', () => heading.removeAttribute('tabindex'),
{ once: true });
}
// 2. Update document title
document.title = newPageElement.dataset.pageTitle || document.title;
// 3. Announce to live region
announceNavigation(document.title);
// 4. Ensure new content is in natural DOM order
// Check: content shouldn't be after footer in DOM but visually before it
validateDOMOrder(newPageElement);
}
function validateDOMOrder(container) {
const main = container.querySelector('main, [role="main"]');
const footer = document.querySelector('footer, [role="contentinfo"]');
if (main && footer) {
const mainPos = main.compareDocumentPosition(footer);
const isMainBeforeFooter = mainPos & Node.DOCUMENT_POSITION_FOLLOWING;
console.assert(isMainBeforeFooter,
'â ī¸ DOM order issue: main content appears after footer in DOM');
}
}
```
---
## Section 3 â Mobile/Touch Audit
> Radial menus on mobile are high-risk: thumb reach, touch accuracy, and OS-level conflicts (iOS bounce, Android back gesture) can break the experience entirely.
---
### 3.1 Touch Target Sizing
- [ ] **Every radial menu item has minimum 44Ã44px touch target (Apple HIG) / 48Ã48dp (Material Design)**
*Why:* The average adult fingertip is 8-10mm. At 96dpi, 44px â 11.6mm â the minimum for reliable touch accuracy. Smaller targets cause mistaps, frustration, and users abandoning navigation.
```css
/* Radial menu item â visual size may differ from touch target */
.radial-item {
/* Visual presentation */
width: 32px;
height: 32px;
border-radius: 50%;
/* EXTEND touch target without changing layout */
position: relative;
}
.radial-item::before {
content: '';
position: absolute;
/* Extend touch area to 44px minimum on all sides */
inset: calc(-1 * max(0px, (44px - 100%) / 2));
/* Or explicit: */
top: -6px; right: -6px; bottom: -6px; left: -6px;
/* Debug: background: rgba(255,0,0,0.2); */
}
/* Alternative: padding approach */
.radial-item-link {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 6px;
}
```
```javascript
// Automated touch target audit
function auditTouchTargets(selector = '[role="menuitem"], a, button') {
const MIN_SIZE = 44;
const elements = document.querySelectorAll(selector);
const violations = [];
elements.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width < MIN_SIZE || rect.height < MIN_SIZE) {
violations.push({
element: el,
width: Math.round(rect.width),
height: Math.round(rect.height),
text: el.textContent.trim().slice(0, 30)
});
}
});
if (violations.length > 0) {
console.table(violations.map(v => ({
text: v.text,
width: v.width,
height: v.height,
issue: `${v.width < MIN_SIZE ? `width ${v.width}px` : ''} ${v.height < MIN_SIZE ? `height ${v.height}px` : ''}`.trim()
})));
console.error(`â ${violations.length} touch target violations found`);
} else {
console.log(`â
All ${elements.length} touch targets meet 44px minimum`);
}
return violations;
}
// Run after menu opens
document.querySelector('.radial-trigger').addEventListener('click', () => {
setTimeout(() => auditTouchTargets('.radial-item'), 100);
});
```
- [ ] **Radial menu center trigger is reachable in thumb zone on mobile**
*Why:* For a bottom-center radial trigger on 6-inch phone, bottom 40% is the comfortable thumb zone. A top-left hamburger forces uncomfortable grip shifts.
```javascript
// Check trigger position relative to thumb zone
function checkThumbZone() {
const trigger = document.querySelector('.radial-trigger');
const rect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Thumb zone: bottom 50% of screen (rough heuristic)
const thumbZoneTop = viewportHeight * 0.5;
const isInThumbZone = rect.top >= thumbZoneTop;
console.log({
triggerBottom: Math.round(rect.bottom),
viewportHeight: Math.round(viewportHeight),
fromBottom: Math.round(viewportHeight - rect.bottom),
inThumbZone: isInThumbZone
});
if (!isInThumbZone) {
console.warn('â ī¸ Radial trigger is in awkward reach zone on mobile');
}
}
// Run on mobile viewport
if (window.innerWidth < 768) checkThumbZone();
```
---
### 3.2 JavaScript Fallback for Non-Touch Scenarios
- [ ] **Navigation works without JS â core links accessible in `