Core Primitives

Framework-agnostic accessibility utilities from @compa11y/core. Use these to build your own accessible components in any framework.

No framework dependency
The core package is pure JavaScript/TypeScript with zero dependencies. It works in any environment with a DOM.

ARIA Helpers

Utilities for setting and managing ARIA attributes.

aria — chainable attribute setter

A fluent API for setting ARIA attributes on DOM elements imperatively.

import { aria } from "@compa11y/core";

aria.hide(element);                         // aria-hidden="true"
aria.show(element);                         // removes aria-hidden
aria.setExpanded(element, true);            // aria-expanded="true"
aria.setSelected(element, true);            // aria-selected="true"
aria.setChecked(element, "mixed");          // aria-checked="mixed"
aria.setDisabled(element, true);            // aria-disabled="true"
aria.setRequired(element, true);            // aria-required="true"
aria.setInvalid(element, true);             // aria-invalid="true"
aria.setLabel(element, "Close");            // aria-label="Close"
aria.setLabelledBy(element, "title-id");    // aria-labelledby="title-id"
aria.setDescribedBy(element, "hint-id");    // aria-describedby="hint-id"
aria.setControls(element, "menu-id");       // aria-controls="menu-id"
aria.setLive(element, "polite");            // aria-live="polite"
aria.setRole(element, "dialog");            // role="dialog"
aria.setActiveDescendant(element, "opt-3"); // aria-activedescendant="opt-3"
aria.setOrientation(element, "vertical");   // aria-orientation="vertical"
aria.setCurrent(element, "page");           // aria-current="page"
aria.setPressed(element, true);             // aria-pressed="true"
aria.setModal(element, true);               // aria-modal="true"
aria.setHasPopup(element, "menu");          // aria-haspopup="menu"
aria.setBusy(element, true);                // aria-busy="true"

buildAriaProps(props)

Builds an object of ARIA attributes from a descriptive props object. Useful in React components.

import { buildAriaProps } from "@compa11y/core";

const ariaProps = buildAriaProps({
  role: "dialog",
  label: "Settings",
  modal: true,
  expanded: false,
});
// { role: "dialog", "aria-label": "Settings", "aria-modal": "true", "aria-expanded": "false" }

<div {...ariaProps} />

mergeAriaIds(...ids)

Combines multiple element IDs into a space-separated string for use in aria-describedby or aria-labelledby. Null/undefined values are filtered out.

import { mergeAriaIds } from "@compa11y/core";

const describedBy = mergeAriaIds(hintId, hasError ? errorId : null);
// "hint-1 error-1"  or  "hint-1"  depending on hasError

<input aria-describedby={describedBy} />

hasAccessibleName(element)

Checks if an element has a computed accessible name via aria-label, aria-labelledby, or text content.

import { hasAccessibleName } from "@compa11y/core";

if (!hasAccessibleName(button)) {
  console.warn("Button is missing an accessible name");
}

Focus Trap

Constrain Tab/Shift+Tab focus within a container. Used internally by Dialog.

createFocusTrap(container, options?)

Creates a focus trap within a container element. Focus cycles between first and last focusable elements. Supports nested traps via pause/unpause.

import { createFocusTrap, hasFocusTrap, getActiveFocusTrap } from "@compa11y/core";

const trap = createFocusTrap(modalElement, {
  initialFocus: closeButton,         // Where to focus on activation
  returnFocus: true,                 // Return focus to previous element on deactivation
  escapeDeactivates: true,           // Escape key deactivates trap
  clickOutsideDeactivates: false,    // Click outside behavior
  onDeactivate: () => closeModal(),  // Callback on deactivation
});

trap.activate();    // Start trapping
trap.pause();       // Pause (for nested traps)
trap.unpause();     // Resume
trap.deactivate();  // Stop trapping, restore focus
trap.destroy();     // Cleanup without callback

// Stack management
hasFocusTrap();        // Any trap active?
getActiveFocusTrap();  // Get the topmost trap

Focus Visible

Distinguish keyboard focus from mouse/touch focus. Call initFocusVisible() once at app startup.

initFocusVisible()

Initializes global focus-visible tracking by listening to keyboard and pointer events. Returns a cleanup function.

import { initFocusVisible, isFocusVisible, getLastFocusSource, focusWithVisibleRing } from "@compa11y/core";

// Initialize once at app startup
const cleanup = initFocusVisible();

// Check focus type
isFocusVisible();               // true if last focus was keyboard-initiated
getLastFocusSource();           // 'keyboard' | 'mouse' | 'touch' | 'unknown'
hasVisibleFocus(element);       // Does this specific element have visible focus?

// Force visible focus (useful for programmatic focus in modals)
focusWithVisibleRing(firstButton);

// Manual override
setFocusVisible(element, true);

Focus Scope & Roving Tabindex

Navigate and manage focus within a container.

createFocusScope(container, options?)

Creates a focus scope that lets you move focus within a container — first, last, next, previous, or by index.

import { createFocusScope } from "@compa11y/core";

const scope = createFocusScope(container, {
  contain: true,        // Tab wraps within container
  restoreFocus: true,   // Restore focus on destroy
  autoFocus: true,      // Focus first element immediately
});

scope.focusFirst();
scope.focusLast();
scope.focusNext({ wrap: true });
scope.focusPrevious({ wrap: true });
scope.focusAt(2);
scope.getFocused();     // Currently focused element
scope.getElements();    // All focusable elements in scope
scope.destroy();        // Cleanup

createRovingTabindex(container, selector, options?)

Implements the roving tabindex pattern — only one item in a group has tabindex='0' at a time. Handles keyboard navigation automatically.

import { createRovingTabindex } from "@compa11y/core";

const roving = createRovingTabindex(toolbar, '[role="button"]', {
  initialIndex: 0,
  orientation: "horizontal",
  wrap: true,
  onSelectionChange: (index, element) => console.log("Selected:", index),
});

roving.next();
roving.previous();
roving.first();
roving.last();
roving.goto(2);
roving.getIndex();  // Current index
roving.update();    // Refresh when DOM changes
roving.destroy();   // Cleanup

Focus Neighbor & Focus Return

Gracefully handle focus when elements are removed or dialogs are closed.

findFocusNeighbor(element, options?)

Find the nearest focusable sibling when an element is about to be removed or disabled.

import { findFocusNeighbor } from "@compa11y/core";

const neighbor = findFocusNeighbor(deletedItem, {
  scope: listContainer,  // Search within (default: parent element)
  prefer: "previous",    // Try previous first, then next (default)
});

neighbor?.focus();

createFocusReturn(initialElement?)

Save a focus target and restore it later. Automatically falls back to the nearest neighbour if the saved element is no longer focusable.

import { createFocusReturn } from "@compa11y/core";

const focusReturn = createFocusReturn();

// Before opening modal
focusReturn.save();              // Saves document.activeElement
focusReturn.save(triggerButton); // or save a specific element

// After closing modal
focusReturn.return();  // Focuses saved element, or its nearest neighbor

// With options
focusReturn.return({
  prefer: "next",            // Try next neighbor first if saved element is gone
  fallback: someOtherButton, // Last resort fallback
});

focusReturn.element; // Read the saved element (readonly)
focusReturn.clear(); // Clear without focusing

Keyboard Manager

Keyboard event management and pre-built interaction patterns.

createKeyboardManager(handlers, options?)

Creates a centralized keyboard handler. Attaches to a DOM element and dispatches to the correct handler. Supports key combinations like 'Ctrl+A' and 'Shift+Enter'.

import { createKeyboardManager } from "@compa11y/core";

const kb = createKeyboardManager(
  {
    ArrowDown: () => focusNext(),
    ArrowUp: () => focusPrevious(),
    Enter: () => selectItem(),
    Escape: () => close(),
    "Ctrl+A": () => selectAll(),
    Space: () => toggleItem(),
  },
  {
    preventDefault: true,              // Default: true
    stopPropagation: true,             // Default: true
    targetSelector: '[role="option"]', // Only handle when target matches
  }
);

kb.attach(listElement);          // Start listening
kb.on("Delete", () => remove()); // Add handler after creation
kb.off("Delete");                // Remove a handler
kb.detach();                     // Stop listening
kb.destroy();                    // Full cleanup

normalizeKey / getKeyCombo

Normalize keyboard event keys across browsers and build modifier key combos.

import { normalizeKey, getKeyCombo } from "@compa11y/core";

normalizeKey(event); // ' ' → 'Space', 'Esc' → 'Escape', 'Left' → 'ArrowLeft'
getKeyCombo(event);  // 'Ctrl+A', 'Shift+Enter', 'Meta+Space'

KeyboardPatterns

Pre-built keyboard patterns for common ARIA widgets.

import { KeyboardPatterns } from "@compa11y/core";

// Menu / Listbox pattern
KeyboardPatterns.menu({ onUp, onDown, onEnter, onEscape, onHome, onEnd });

// Tabs pattern
KeyboardPatterns.tabs({ onLeft, onRight, onHome, onEnd });

// Dialog pattern
KeyboardPatterns.dialog({ onEscape });

// 2D Grid pattern
KeyboardPatterns.grid({ onUp, onDown, onLeft, onRight, onHome, onEnd, onCtrlHome, onCtrlEnd });

createTypeAhead(items, options?)

Creates a type-ahead search handler. Users type characters to jump to matching items. Resets after a configurable timeout.

import { createTypeAhead } from "@compa11y/core";

const typeAhead = createTypeAhead(["Apple", "Apricot", "Banana", "Cherry"], {
  timeout: 500, // Reset after 500ms of no typing
});

typeAhead.type("a");    // Returns 'Apple'
typeAhead.type("p");    // Returns 'Apricot' (search is now 'ap')
// After 500ms, resets
typeAhead.type("b");    // Returns 'Banana'
typeAhead.reset();      // Manual reset
typeAhead.getSearch();  // Current search string

Live Announcer

Screen reader announcements via ARIA live regions. Call initAnnouncer() once at app startup.

initAnnouncer()

Initializes the live announcer by creating hidden ARIA live region elements in the DOM. Returns a cleanup function.

import { initAnnouncer } from "@compa11y/core";

// Call once at app startup
const cleanup = initAnnouncer();

announce / announcePolite / announceAssertive

Make polite (non-interrupting) or assertive (interrupting) screen reader announcements.

import {
  announce,
  announcePolite,
  announceAssertive,
  announceStatus,
  announceError,
  announceProgress,
  queueAnnouncement,
  clearAnnouncements,
} from "@compa11y/core";

// Basic announcements
announce("Form submitted");                         // Polite by default
announcePolite("3 search results found");           // Non-interrupting
announceAssertive("Error: invalid email address");  // Interrupts current speech

// Convenience functions
announceStatus("Saved");                               // Polite status update
announceError("Connection lost");                      // Assertive error
announceProgress(5, 10);                               // "5 of 10"
announceProgress(5, 10, "Step {current} of {total}"); // "Step 5 of 10"

// Advanced options
announce("Updated", {
  politeness: "polite",
  delay: 100,           // Delay in ms
  clearPrevious: true,  // Clear previous messages first
  timeout: 7000,        // Auto-clear after 7s (default)
});

// Queue rapid announcements (debounced)
queueAnnouncement("Processing...", { debounce: 300 });

// Clear all pending announcements
clearAnnouncements();

createAnnouncer(defaults?)

Create a scoped announcer instance with preset defaults.

import { createAnnouncer } from "@compa11y/core";

const myAnnouncer = createAnnouncer({ politeness: "polite" });
myAnnouncer.announce("Done");
myAnnouncer.assertive("Error!");

ID Generation

Generate unique, stable IDs for ARIA attribute associations.

generateId / generateIds / createIdScope

Generate unique IDs for elements, multiple related IDs, or a scoped set of IDs for a component.

import { generateId, generateIds, createIdScope, resetIdCounter } from "@compa11y/core";

generateId();            // 'compa11y-1'
generateId("button");    // 'compa11y-button-2'

const ids = generateIds(["label", "input", "error"] as const, "search");
// { label: 'compa11y-search-3-label', input: 'compa11y-search-3-input', error: 'compa11y-search-3-error' }

const scope = createIdScope("dropdown");
scope.id;                    // 'compa11y-dropdown-4'
scope.generate("trigger");   // 'compa11y-dropdown-4-trigger'
scope.generate("menu");      // 'compa11y-dropdown-4-menu'

resetIdCounter(); // For testing only

Platform Detection

Detect user preferences and platform capabilities.

Platform utilities

Detect the current platform, device type, and user preferences.

import {
  isBrowser,
  isMac,
  isIOS,
  isAndroid,
  isWindows,
  isTouchDevice,
  prefersReducedMotion,
  prefersHighContrast,
  prefersDarkMode,
  getScreenReaderHints,
  createMediaQueryListener,
} from "@compa11y/core";

isBrowser();       // true if running in browser (not SSR)
isMac();           // true on macOS
isIOS();           // true on iOS
isAndroid();       // true on Android
isWindows();       // true on Windows
isTouchDevice();   // true if device supports touch

// User preferences
prefersReducedMotion(); // true if user prefers reduced motion
prefersHighContrast();  // true if user prefers high contrast
prefersDarkMode();      // true if user prefers dark color scheme

// Screen reader hints (not 100% reliable)
const { possibleScreenReader, forcedColors } = getScreenReaderHints();

// Listen for preference changes
const cleanup = createMediaQueryListener(
  "(prefers-reduced-motion: reduce)",
  (prefersReduced) => setAnimations(!prefersReduced)
);

Dev Warnings

Development-only warnings that help catch accessibility issues early. These are stripped in production builds.

warn / checks / createComponentWarnings

Emit accessibility warnings in development. Use pre-built checks or create a component-specific warning factory.

import { warn, checks, createComponentWarnings, setWarningHandler } from "@compa11y/core";

// Direct warning
warn("Button has no accessible label", "Button");

// Pre-built checks
checks.hasLabel(element, "Button");          // Warns if no accessible name
checks.hasLabelledBy(element, "Combobox");   // Warns if no aria-labelledby

// Component-specific warning factory
const warnings = createComponentWarnings("MyComponent");
warnings.missingProp("aria-label");
warnings.invalidValue("size", "huge", ["sm", "md", "lg"]);

// Custom warning handler (e.g., to throw in tests)
setWarningHandler((message, component) => {
  throw new Error(`[${component}] ${message}`);
});