Architecture

Monochrome behaves differently from other component libraries because it is built differently. This page explains what the core does when you click a button, and why eight components can share one small runtime with nothing to initialize. Nothing here is required reading to use the library. It exists because the "how" is the most common question.

The DOM Is the State

Most UI libraries keep component state in JavaScript: an object holds isOpen, the DOM is a rendering of that object, and the two are kept in sync by the framework. Monochrome has no such object. The ARIA attributes that accessibility already requires (aria-expanded, aria-selected, aria-checked, aria-hidden) are the state.

When you click a collapsible trigger, the core reads aria-expanded off the button, flips it, and flips aria-hidden on the panel. That is the entire transaction. Asking "is this menu open?" means reading an attribute, not consulting a store.

This has consequences that look like features but are really properties of the design:

  • Nothing to initialize. There is no instance to create, so components work the moment their HTML exists, whether it was in the initial payload, rendered by a framework, or inserted five minutes later.
  • Nothing to clean up. Removing the HTML removes the state. There are no listeners or subscriptions attached to it.
  • Nothing to desynchronize. A cached isOpen can drift from the DOM the moment anything else touches it: another script, a framework re-render, a DevTools edit. With one source of truth there is nothing to reconcile.
  • Debugging is inspection. The elements panel shows the complete state of every component. If the attribute is right, the component is right.

The same principle guarantees the accessibility story: the library cannot forget to update a screen reader, because the thing it updates to function at all is the thing screen readers read.

Seven Listeners, Zero Components

The core registers exactly seven global event listeners when imported: click, pointermove, keydown, scroll, resize, focusin, and focusout. That is the complete runtime, for any number of components.

Per-component listeners scale with component count and need teardown when components unmount. Seven delegated listeners are constant cost: a page with a thousand interactive elements has the same listener footprint as a page with one. Dynamically inserted components are covered automatically, because the listener was never attached to the component in the first place. It was on window all along, waiting for events to bubble up.

ID Prefix Dispatch

Delegation raises a routing question: when a click bubbles up, which component logic should handle it? Most libraries answer with classes, data attributes, or a registry populated at mount time. Monochrome routes on the element's id:

Prefix Meaning
mct: Trigger elements (almost always a <button>)
mcc: Content elements (panels, popovers, dialogs)
mcr: Root containers (component boundaries)

The component name follows the prefix (mct:accordion:faq), and internally the core matches the shortest unambiguous string: mct:c is a collapsible trigger, mct:ta is a tab, mct:to is a tooltip (tabs and tooltip both start with "t", so they extend one letter). IDs double as the wiring for aria-controls and aria-labelledby, which accessible markup needs anyway, so the dispatch mechanism costs zero extra attributes.

The click handler is one walk up the tree: from the event target toward the root, checking each ancestor's id for a known prefix. The first match dispatches. The walk that finds no match is not wasted. Reaching the root without matching is the outside-click detection that closes open menus and popovers. Component routing and light dismiss are the same traversal.

Zero Timers

The core contains no setTimeout, no requestAnimationFrame, no debounce, no throttle. Every action completes synchronously inside the event that caused it.

Timers in UI code are usually there to paper over sequencing: wait for a transition, delay a tooltip, debounce a hover. Each one introduces a window where the UI is in-between states and a cleanup obligation when the component disappears mid-wait. Synchronous code has neither. Where a delay is genuinely wanted (a tooltip appearing after a pause, a popover animating in), CSS handles it: transition-delay and @starting-style run off the main thread and cost zero bytes of JavaScript.

One example of how far this goes: monochrome never handles Enter or Space in keydown. Browsers synthesize a click event for those keys on a <button>, so the click listener receives keyboard activation for free. The two paths are distinguished where it matters by event.detail, which is 0 for synthetic keyboard clicks and a positive count for real mouse clicks. That is how a menu knows to focus its first item on keyboard open but stay on the trigger for mouse open.

Positioning Lives in CSS

Floating elements (menus, popovers, tooltips) use the Popover API, so they render in the browser's top layer: no portals, no z-index wars, no stacking-context surprises.

Positioning them involves no layout math in JavaScript. On open, the core measures the trigger once and publishes the rect as CSS custom properties on the content element: --top, --right, --bottom, --left, plus the panel's own --pw and --ph. Your stylesheet decides what to do with them:

css
[id^="mcc:menu"] {
  position: fixed;
  top: var(--bottom);
  left: var(--left);
}

Flip a menu to open upward, align a tooltip to the right edge, offset a popover by a gap: all CSS edits, no library configuration. The library gives you measurements; you own the geometry.

The Safety Triangle

Moving the cursor from a submenu trigger to the submenu itself usually means cutting diagonally across other menu items. Naive hover logic closes the submenu the moment you touch a sibling. The classic fix is a "safety triangle" between cursor and submenu, traditionally implemented with mousemove math and grace-period timers.

Monochrome's version contains no geometry code and no timers. The core publishes the submenu's rect and the cursor position as CSS custom properties, and a clip-path polygon in CSS paints the triangle itself:

  • clamp(var(--left), var(--x), var(--right)) picks the triangle's apex, which makes the same polygon work whether the submenu opens left or right.
  • "Is the cursor moving toward the submenu?" is one signed multiplication: (submenuLeft - triggerRight) * movementX < 0. The sign of the first factor encodes which side the submenu is on, so a single expression covers both directions without a branch.
  • The submenu's rect is measured lazily on the first pointer move over the trigger, not at open time, because entry animations (@starting-style transforms) make the open-time rect wrong, and if the cursor never enters the triangle the measurement never happens.

The polygon clips an invisible, viewport-sized pseudo-element with pointer-events: auto. While the cursor is inside the triangle, that overlay catches the pointer, sibling menu items never see hover, and the submenu stays open. Hit-testing entirely in CSS.

One Walker, Three Behaviours

Arrow-key navigation in menus, tabs, and accordions is "roving tabindex": focus moves between siblings, wrapping at the ends, skipping disabled items. The core implements this once, as a generic sibling walker plus a per-component focus rule.

The interesting part is what else that walker does. Menu typeahead (type a letter, focus jumps to the next matching item) is the same walker with a letter filter active. Radio groups (activating a menuitemradio clears aria-checked on every adjacent radio) are the same walker in a sweep mode that clears attributes instead of moving focus. Three behaviours, one engine, selected by which module-level flag is set when the walk starts.

Edge cases that usually need counters fall out of one pointer: the walker remembers the first candidate it rejected, and if the wrap-around brings it back to that element, it gives up. That is the entire defense against infinite loops on an all-disabled list or a typeahead letter with no match.

The Router in Two Ideas

The optional Router follows the same philosophy: minimal state, platform features, synchronous where possible. Two mechanisms carry it:

  1. Declarative regions. Pages mark swappable areas with data-area attributes; the router fetches the next page, parses it, and swaps areas whose data-key differs. Matching keys are preserved untouched, DOM state and all.
  2. A monotonic token. Every navigation increments a counter and remembers its own value. When a fetch resolves, it commits only if its token is still the latest. Rapid-fire clicking can never apply a stale page, without locks or cancellation machinery.

What It Costs

Every architecture trades something. Monochrome's trades are explicit:

  • Convention over configuration. The HTML contract (IDs, roles, ARIA attributes) must be exactly right; there is no runtime that fixes up loose markup. The framework wrappers exist precisely to generate this contract for you.
  • No behavior options. There is no closeOnBlur: false. Behavior follows the WAI-ARIA Authoring Practices, and customization happens in CSS and HTML structure, not a props API.
  • Modern browsers only. The Popover API and <dialog> are hard requirements. See Browser Support for exact versions.

If your project needs configurable behavior or legacy browser support, a framework-based library is the better tool. If it needs correct, fast, accessible components with the smallest possible footprint, this architecture is hard to beat.

Next Steps