ChromVoid Blog
Accessible UI Should Not Depend on Good Intentions
UIKit owns the surface. Headless models own focus, keyboard, selection, and ARIA contracts. Here is why ChromVoid keeps those boundaries separate.

Accessible UI Should Not Depend on Good Intentions
A password manager can encrypt every byte correctly and still lose trust at the interface.
Close a dialog and let focus disappear. Move through a listbox while the visual highlight and
aria-selected disagree. Ship a command palette and a select menu with two different keyboard
models.
None of these failures reveals a vault key. But each one makes the product harder to operate, harder to verify, and easier to misuse. In a tool that stores passwords, passkeys, notes, wallets, and private files, predictable interaction is part of the trust boundary.
ChromVoid treats that as an architectural problem.
Our UI stack follows three rules:
- Use native HTML when the platform already provides the right semantics and behavior.
- Use Web Components as the stable product surface.
- Put complex focus, keyboard, selection, and ARIA rules in APG-aligned headless models.
This does not make ChromVoid automatically accessible. No component library can make that promise for an entire product. It does something more concrete: it gives interaction behavior a clear owner and makes that behavior possible to inspect, reuse, and test.
Private by architecture, not by vibes, applies to UI as well.
The Short Version
ChromVoid splits its component architecture into two packages.
@chromvoid/uikit owns the visible product surface: custom elements, native DOM, Shadow DOM,
slots, CSS parts, design tokens, form participation, events, themes, and visual states.
@chromvoid/headless-ui owns the interaction model: state transitions, keyboard intent, focus and
selection policy, typeahead, ARIA roles and properties, ids, and DOM-facing contracts.
The split is not based on the idea that every control needs custom ARIA. The opposite is closer to our goal:
Native HTML first. APG-aligned behavior when native behavior is not enough.
A button should still be a button. A link should still be a link. A table should remain a native HTML table unless the product genuinely needs an interactive grid. Custom behavior starts where the platform stops providing the complete interaction model we need.
Native HTML Comes First
APG-aligned does not mean ARIA everywhere.
ARIA can communicate semantics to assistive technology, but adding a role does not give an element native keyboard behavior, focus behavior, form behavior, or browser conventions. Those still have to be implemented correctly. That is why WAI-ARIA guidance repeatedly recommends using native HTML when an appropriate element already exists.
ChromVoid follows that hierarchy inside its Web Components.
For example, cv-button renders a real HTML <button> inside the component. The native element
provides the browser's button semantics and baseline behavior. The headless button model adds a
consistent contract for disabled, loading, pressed, and activation states, while UIKit owns the
rendering, slots, visual variants, form actions, and custom events.
That boundary is more robust than recreating a button from a generic element:
<cv-button>
└── <button>
├── native semantics
├── browser focus behavior
├── UIKit styling and slots
└── headless state and interaction contract
The same principle applies across the system. We prefer platform semantics where they are sufficient and use APG patterns for composite widgets where the browser does not provide the full product behavior: listboxes, command palettes, trees, grids, toolbars, and similar controls.
Why Web Components Are the Product Boundary
Web Components are useful to ChromVoid because they are part of the web platform rather than the lifecycle of one application framework.
A component such as cv-button, cv-dialog, cv-listbox, or cv-theme-provider becomes a real
custom element that can be registered once and used from product code, documentation, prototypes,
WebViews, or framework adapters.
<cv-theme-provider mode="dark">
<cv-button variant="primary">Unlock vault</cv-button>
<cv-listbox aria-label="Vault sections"></cv-listbox>
</cv-theme-provider>
This does not make frameworks irrelevant. React, Vue, or another renderer can still consume the elements. The point is that the long-lived component contract is not owned by one of them.
The public surface is visible in the DOM:
- attributes and properties configure the element;
- custom events report product-level changes;
- slots define composition points;
- CSS parts expose deliberate styling hooks;
--cv-*custom properties carry design tokens and theme values.
Shadow DOM provides encapsulation where it helps, while slots, parts, and tokens prevent that
encapsulation from becoming a styling dead end. Form-associated custom elements can participate in
native form flows through ElementInternals where that is the right platform primitive.
These features have costs. Shadow DOM styling has to be designed. Typed custom events need explicit contracts. Server rendering and framework interoperability need care. But the costs are concentrated at a documented boundary instead of being rediscovered in every product screen.
Two Layers, One Interaction Contract
Web Components provide a durable surface. They do not decide how a composite widget should behave.
A listbox still needs answers to questions such as:
- Which option is active?
- Which options are selected?
- Does focus use
aria-activedescendantor rovingtabindex? - What happens on
ArrowDown,ArrowUp,Home, andEnd? - Are disabled options skipped?
- Does selection follow focus?
- Is typeahead enabled?
- What do
aria-selected,aria-setsize, andaria-posinsetexpose?
If every styled component answers those questions locally, a product slowly accumulates different interaction models for controls that look related.
ChromVoid moves those decisions into @chromvoid/headless-ui.
The public shape is intentionally repetitive:
const model = createListbox(options)
model.state
model.actions
model.contracts.getRootProps()
model.contracts.getOptionProps(id)
The flow is straightforward:
user intent
↓
model action
↓
reactive state
↓
DOM-facing accessibility contract
↓
Web Component render
The headless layer owns behavior:
- supported state transitions;
- keyboard intent mapping;
- focus and selection policy;
- typeahead and composite navigation;
- roles, ARIA states, ids, and relationships;
- contracts that a renderer applies to the DOM.
UIKit owns the surface:
- the native elements and custom-element registration;
- Shadow DOM, slots, CSS parts, and tokens;
- visual variants, sizing, loading states, and motion;
- product events such as
cv-inputandcv-change; - form participation and controller helpers;
- the final composition used by the application.
A visual regression belongs to UIKit. A broken ArrowDown transition belongs to the model. The
ownership is visible before the bug is fixed.
A Listbox From Intent to DOM
Consider a listbox for vault sections.
const listbox = createListbox({
idBase: 'vault-sections',
ariaLabel: 'Vault sections',
options: [
{id: 'passwords', label: 'Passwords'},
{id: 'passkeys', label: 'Passkeys'},
{id: 'files', label: 'Files'},
],
selectionMode: 'single',
})
The model exposes reactive state, supported actions, and DOM-facing contracts:
const root = listbox.contracts.getRootProps()
const passkeys = listbox.contracts.getOptionProps('passkeys')
root.role // "listbox"
root['aria-activedescendant'] // active option DOM id, when applicable
passkeys.role // "option"
passkeys['aria-selected'] // "true" or "false"
passkeys['aria-posinset'] // position in the set
passkeys['aria-setsize'] // total option count
When the user presses ArrowDown, the UI layer forwards the keyboard intent to the model:
listbox.actions.handleKeyDown(event)
The model updates the active option according to its focus strategy and listbox rules. The contracts
then expose the corresponding aria-activedescendant, tabindex, data-active, and selection
values. UIKit renders those values and styles the active or selected option.
ArrowDown
↓
handleKeyDown(event)
↓
activeId changes
↓
getRootProps() / getOptionProps(id)
↓
ARIA, tabindex, and data state update
↓
<cv-listbox> renders the new state
The important part is not the number of layers. It is that the visual highlight and the accessible state are derived from the same model instead of being synchronized by convention.
This also changes how the behavior can be tested. Navigation, range selection, disabled options, orientation, focus strategy, and ARIA output can be exercised as model transitions without loading the styled component. Browser and assistive-technology testing remain necessary, but less behavior is left for manual QA to discover by accident.
What This Split Buys Us
Users do not experience a package boundary. They experience whether the interface keeps its promises.
The desired outcomes are observable:
- closing an overlay restores focus to a meaningful place;
- arrow keys move through a composite widget predictably;
- disabled options are not activated;
- the selected state exposed to assistive technology matches the selected state on screen;
- desktop and mobile surfaces do not invent different rules for the same action.
For developers, the same architecture reduces one-off repairs. A keyboard bug can be fixed in the
behavior model and reused by every adapter that consumes it. A visual change can be made in UIKit
without rewriting selection logic. Documentation can show the same cv-* elements used by the
product instead of a simplified framework-specific imitation.
It also makes disagreements easier to investigate. If a command palette and a listbox implement typeahead differently, we can decide whether that difference is intentional or whether they should share a lower-level primitive. If mobile and desktop diverge, we can ask whether the divergence is visual, structural, or behavioral.
The architecture does not eliminate mistakes. It makes their location easier to explain.
How This Differs From Existing Libraries
ChromVoid is not the first project to treat accessibility and component behavior as infrastructure. The useful comparison is not which library is universally better. It is where each library places the public boundary.
Web Awesome provides a broad, general-purpose Web Components design system. It continues the direction established by Shoelace, which is now sunset and points users toward Web Awesome. Its custom elements combine the reusable browser-facing surface with the component implementation.
Radix Primitives provides low-level, unstyled, accessibility-focused primitives for React. Its public component boundary is React, and it handles many focus, keyboard, and ARIA details inside that ecosystem.
React Aria provides React components and lower-level hooks with extensive behavior, internationalization, device support, and assistive-technology testing. It is a strong fit when React is the intended long-term application boundary.
Headless UI provides unstyled accessible components for React and Vue, with a composition model that fits naturally into those framework ecosystems.
ChromVoid makes a different tradeoff:
The product surface is a Web Component, while the interaction engine is a separate, renderer-independent package.
That is not automatically faster or more mature than adopting an established library. It is a bet on a boundary that matches a long-lived local-first product: stable browser-facing elements above, explicit behavior contracts below.
What This Architecture Does Not Prove
A clean component boundary is not an accessibility certificate.
It does not guarantee good labels, clear writing, sufficient contrast, sensible reading order, usable touch targets, reduced-motion support, or an understandable screen composition. Those are product decisions, not properties a headless model can infer.
APG alignment is also not the end of testing. APG describes patterns and expected behavior, but real support varies across browsers, operating systems, screen readers, input methods, and mobile combinations. Automated model tests cannot replace testing the rendered component with the assistive technologies used by the target audience.
The architecture itself is still early. The packages are pre-1.0, have fewer external integrations, and carry less production evidence than Radix, React Aria, Headless UI, or Web Awesome. Reatom is a real dependency choice in the public model. Consumers need to accept that reactive shape rather than receiving framework-native state.
Web Components also require deliberate integration work. Shadow DOM styling, server rendering, React interoperability, typed custom events, and form-associated controls all have edge cases. Framework-agnostic does not mean integration-free.
The honest claim is narrower:
ChromVoid gives accessibility behavior an explicit owner and a boundary that can be tested.
That is useful, but it still has to be validated in the final product.
A Boundary We Can Test
ChromVoid is not trying to win a component-count contest.
We are trying to make interface behavior explainable.
Native HTML gives us platform semantics and browser behavior where they already exist. Web Components give us a durable product surface with real elements, controlled styling hooks, scoped theming, and less framework ownership. APG-aligned headless models give us explicit keyboard rules, focus policy, selection logic, ARIA contracts, and testable state transitions for the widgets the platform does not fully provide.
Together, those layers let us treat UI behavior the way a privacy product should treat any important boundary: explicitly, conservatively, and without pretending the abstraction is magic.
Not automatic accessibility. Not a promise based on intent.
A boundary we can inspect, test, and improve.