Accessibility
CellUI renders real DOM nodes — not a virtual representation. This means standard accessibility practices apply directly. There's no framework-specific accessibility layer to learn. ARIA attributes, semantic HTML, keyboard navigation, and focus management all work as they do in plain HTML.
Semantic HTML
Because view produces real DocumentFragment nodes, you write real HTML:
typescript// Use semantic elements — they work exactly as you'd expect
view`
<nav aria-label="Main navigation">
<ul role="list">
${each(links, 'href', (link) => view`
<li><a href="${link.href}">${link.label}</a></li>
`)}
</ul>
</nav>
`;There's no component abstraction between you and the DOM. <nav>, <main>, <article>, <aside>, <header>, <footer> — use them. Screen readers see them directly.
ARIA Attributes with Signals
Bind ARIA attributes to signals for dynamic state:
typescriptconst isExpanded = signal(false);
const errorMessage = signal('');
view`
<button
aria-expanded="${isExpanded}"
aria-controls="panel"
onclick="${() => isExpanded.value = !isExpanded.value}"
>
Toggle Panel
</button>
${when(isExpanded, {
true: () => view`
<div id="panel" role="region" aria-label="Details panel">
<p>Panel content</p>
</div>
`
})}
`;When isExpanded.value changes, the aria-expanded attribute updates via CellUI's O(1) binding. Screen readers pick up the change.
Focus Management
When when() or each() swaps DOM nodes, focus can be lost. Manage it explicitly:
typescriptfunction Modal(isOpen: Signal<boolean>) {
return when(isOpen, {
true: () => {
const container = document.createElement('div');
container.setAttribute('role', 'dialog');
container.setAttribute('aria-modal', 'true');
container.setAttribute('aria-label', 'Confirmation dialog');
container.tabIndex = -1;
container.appendChild(view`
<p>Are you sure?</p>
<button onclick="${() => isOpen.value = false}">Close</button>
`);
// Focus the dialog when it appears
requestAnimationFrame(() => container.focus());
return container;
}
});
}Focus trap pattern
For modals and dialogs, trap focus inside the container:
typescriptfunction trapFocus(container: HTMLElement) {
const focusable = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
}This is plain DOM — no CellUI abstraction needed. Use portal() to render the modal outside the parent tree so it escapes z-index stacking.
Keyboard Navigation
Event handlers in view work with keyboard events:
typescriptview`
<div
role="button"
tabindex="0"
onclick="${handleAction}"
onkeydown="${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleAction();
}
}}"
>
Custom Button
</div>
`;But prefer native <button> elements — they handle keyboard interaction for free:
typescript// GOOD: keyboard accessible by default
view`<button onclick="${handleAction}">Click me</button>`;
// BAD: requires manual keyboard handling
view`<div onclick="${handleAction}">Click me</div>`;Live Regions
Announce dynamic content changes to screen readers:
typescriptconst status = signal('');
view`
<div aria-live="polite" aria-atomic="true">
${status}
</div>
`;
// When status.value changes, screen readers announce the new text
function saveForm() {
status.value = 'Form saved successfully';
setTimeout(() => status.value = '', 3000);
}Because CellUI updates the exact text node bound to status, the aria-live region works correctly — the browser detects the text change and announces it.
Reduced Motion
Respect prefers-reduced-motion when using transition():
typescriptconst prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
transition(isVisible, {
enter: prefersReduced ? '' : 'fade-in',
leave: prefersReduced ? '' : 'fade-out',
duration: prefersReduced ? 0 : 300,
}, () => view`<div>Content</div>`);Or handle it in CSS:
css@media (prefers-reduced-motion: reduce) {
.fade-in, .fade-out { animation: none !important; transition: none !important; }
}Color Contrast
CellUI doesn't provide a theming system that enforces contrast ratios. When building themes with ambient() or Substrates, verify contrast yourself:
typescriptconst theme = ambient('theme', {
bg: '#09090b',
text: '#fafafa', // 19.2:1 contrast ratio ✓
muted: '#a1a1aa', // 7.1:1 contrast ratio ✓
dim: '#52525b', // 3.4:1 — fails WCAG AA for body text ✗
});Use tools like WebAIM Contrast Checker or browser DevTools accessibility audits.
What CellUI Doesn't Do
- No automatic ARIA — CellUI doesn't add
role,aria-*, or other attributes. You add them. - No focus management — when
when()/each()swap nodes, focus resets. You manage it. - No screen reader testing — CellUI can't tell you if your app is accessible. Test with VoiceOver, NVDA, or JAWS.
This is by design. CellUI renders real DOM. Accessibility is a DOM concern, not a framework concern. The framework stays out of the way.