Accessible, headless UI components. Four components — Accordion, Collapsible, Menu, Tabs — powered by a ~2KB core with zero dependencies.
Framework-agnostic core with React and Vue wrappers included. The core uses event delegation and ARIA attributes as the source of truth. Import it once and every component on the page works automatically.
For AI agents helping developers build with monochrome.
npm install monochrome # Core + React + Vue wrappers includedimport "monochrome" // Core (auto-activates)
import { Accordion, Tabs } from "monochrome/react" // React
import { Accordion, Tabs } from "monochrome/vue" // VueCollapsible content panels. type="single" allows one open at a time. type="multiple" allows any combination.
| React | Vue |
|---|---|
import { Accordion } from "monochrome/react"
<Accordion.Root type="single">
<Accordion.Item open>
<Accordion.Header as="h3">
<Accordion.Trigger>
Section Title
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel>
Content here
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item disabled>
<Accordion.Header>
<Accordion.Trigger>
Disabled
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel>
Content here
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root> |
<script setup lang="ts">
import { Accordion } from "monochrome/vue"
</script>
<template>
<Accordion.Root type="single">
<Accordion.Item :open="true">
<Accordion.Header as="h3">
<Accordion.Trigger>
Section Title
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel>
Content here
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item :disabled="true">
<Accordion.Header>
<Accordion.Trigger>
Disabled
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel>
Content here
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
</template> |
Props:
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
Root |
type |
"single" | "multiple" |
"single" |
Whether one or many panels can be open |
Item |
open |
boolean |
false |
Start open |
Item |
disabled |
boolean |
false |
Cannot toggle, skipped by keyboard |
Header |
as |
"h1" – "h6" |
"h3" |
Heading level |
Keyboard: ArrowDown/Up navigate items, Home/End jump to first/last, Enter/Space toggle.
A single trigger that shows/hides content.
| React | Vue |
|---|---|
import { Collapsible } from "monochrome/react"
<Collapsible.Root open>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Panel>Content</Collapsible.Panel>
</Collapsible.Root> |
<script setup lang="ts">
import { Collapsible } from "monochrome/vue"
</script>
<template>
<Collapsible.Root :open="true">
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Panel>Content</Collapsible.Panel>
</Collapsible.Root>
</template> |
Props:
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
Root |
open |
boolean |
false |
Start open |
Keyboard: Enter/Space toggle. No arrow key navigation (WAI-ARIA disclosure pattern).
Tabbed interface with manual activation and roving tabindex.
| React | Vue |
|---|---|
import { Tabs } from "monochrome/react"
<Tabs.Root
defaultValue="tab1"
orientation="horizontal"
>
<Tabs.List>
<Tabs.Tab value="tab1">Tab 1</Tabs.Tab>
<Tabs.Tab value="tab2" disabled>
Tab 2
</Tabs.Tab>
<Tabs.Tab value="tab3">Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="tab1">Content 1</Tabs.Panel>
<Tabs.Panel value="tab2">Content 2</Tabs.Panel>
<Tabs.Panel value="tab3">Content 3</Tabs.Panel>
</Tabs.Root> |
<script setup lang="ts">
import { Tabs } from "monochrome/vue"
</script>
<template>
<Tabs.Root
default-value="tab1"
orientation="horizontal"
>
<Tabs.List>
<Tabs.Tab value="tab1">Tab 1</Tabs.Tab>
<Tabs.Tab value="tab2" :disabled="true">
Tab 2
</Tabs.Tab>
<Tabs.Tab value="tab3">Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="tab1">Content 1</Tabs.Panel>
<Tabs.Panel value="tab2">Content 2</Tabs.Panel>
<Tabs.Panel value="tab3">Content 3</Tabs.Panel>
</Tabs.Root>
</template> |
Props:
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
Root |
defaultValue |
string |
— | Initially selected tab |
Root |
orientation |
"horizontal" | "vertical" |
"horizontal" |
Arrow key direction |
Tab |
value |
string |
— | Unique tab identifier |
Tab |
disabled |
boolean |
false |
Cannot activate, skipped by keyboard |
Panel |
value |
string |
— | Must match a Tab's value |
Panel |
focusable |
boolean |
true |
Set false when panel has its own focusable elements |
Note: In Vue templates, defaultValue is written as default-value (standard Vue kebab-case mapping).
Keyboard: ArrowRight/Left (horizontal) or ArrowDown/Up (vertical), Home/End, Enter/Space to activate.
Dropdown menus, submenus, and menubars. Uses the Popover API.
| React | Vue |
|---|---|
import { Menu } from "monochrome/react"
<Menu.Root>
<Menu.Trigger>Open Menu</Menu.Trigger>
<Menu.Popover>
<Menu.Item>Action</Menu.Item>
<Menu.Item disabled>Disabled</Menu.Item>
<Menu.Item href="/link">Link</Menu.Item>
<Menu.Separator />
<Menu.CheckboxItem checked={false}>
Bold
</Menu.CheckboxItem>
<Menu.RadioItem checked>Small</Menu.RadioItem>
<Menu.RadioItem checked={false}>
Large
</Menu.RadioItem>
<Menu.Separator />
<Menu.Label>Section</Menu.Label>
<Menu.Group>
<Menu.Trigger>Submenu</Menu.Trigger>
<Menu.Popover>
<Menu.Item>Sub Action</Menu.Item>
</Menu.Popover>
</Menu.Group>
</Menu.Popover>
</Menu.Root> |
<script setup lang="ts">
import { Menu } from "monochrome/vue"
</script>
<template>
<Menu.Root>
<Menu.Trigger>Open Menu</Menu.Trigger>
<Menu.Popover>
<Menu.Item>Action</Menu.Item>
<Menu.Item :disabled="true">Disabled</Menu.Item>
<Menu.Item href="/link">Link</Menu.Item>
<Menu.Separator />
<Menu.CheckboxItem :checked="false">
Bold
</Menu.CheckboxItem>
<Menu.RadioItem :checked="true">Small</Menu.RadioItem>
<Menu.RadioItem :checked="false">
Large
</Menu.RadioItem>
<Menu.Separator />
<Menu.Label>Section</Menu.Label>
<Menu.Group>
<Menu.Trigger>Submenu</Menu.Trigger>
<Menu.Popover>
<Menu.Item>Sub Action</Menu.Item>
</Menu.Popover>
</Menu.Group>
</Menu.Popover>
</Menu.Root>
</template> |
Menubar — set menubar (React) or :menubar="true" (Vue) on Menu.Root:
<Menu.Root menubar>
<Menu.Popover> {/* renders as <ul role="menubar"> */}
<Menu.Group>
<Menu.Trigger>File</Menu.Trigger>
<Menu.Popover>
<Menu.Item>New</Menu.Item>
<Menu.Item>Open</Menu.Item>
</Menu.Popover>
</Menu.Group>
<Menu.Group>
<Menu.Trigger>Edit</Menu.Trigger>
<Menu.Popover>
<Menu.Item>Undo</Menu.Item>
</Menu.Popover>
</Menu.Group>
</Menu.Popover>
</Menu.Root>Sub-components:
| Component | Description |
|---|---|
Item |
Renders <button>, <a> (with href), or <span> (with disabled). Click closes the menu. |
CheckboxItem |
Toggle item (role="menuitemcheckbox"). Click toggles aria-checked, menu stays open. |
RadioItem |
Radio item (role="menuitemradio"). Groups are implicit by DOM adjacency — separators break groups. |
Label |
Non-interactive heading (role="presentation"). |
Separator |
Visual divider (role="separator"). Skipped by keyboard. |
Group |
Wraps a submenu trigger + popover pair. |
Popover |
<ul role="menu"> (or role="menubar" in menubar mode). |
Props:
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
Root |
menubar |
boolean |
false |
Menubar mode |
Item |
disabled |
boolean |
false |
Non-interactive, skipped by keyboard |
Item |
href |
string |
— | Renders as <a> instead of <button> |
CheckboxItem |
checked |
boolean |
false |
Checked state |
CheckboxItem |
disabled |
boolean |
false |
Non-interactive, skipped by keyboard |
RadioItem |
checked |
boolean |
false |
Selected state |
RadioItem |
disabled |
boolean |
false |
Non-interactive, skipped by keyboard |
Keyboard: ArrowDown/Up navigate, Home/End, Escape closes, Tab closes all, Enter/Space activates, ArrowRight opens submenu, ArrowLeft closes submenu, letter keys for typeahead.
stopPropagation pattern: Call e.stopPropagation() on an item's click handler to prevent the menu from closing.
Monochrome is headless — no CSS shipped. You provide all styles. Required CSS for menus:
/* Menu popover positioning (core sets --bottom, --left, --top, --right via getBoundingClientRect) */
[role="menu"] {
position: fixed;
inset: auto;
margin: 0;
top: var(--bottom);
left: var(--left);
}
/* Submenu positioning */
[role="menu"] [role="menu"] {
top: var(--top);
left: var(--right);
margin-left: 8px; /* gap so core detects opening direction */
}
/* Popover visibility */
[popover]:popover-open {
display: flex;
}For submenu hover safety triangles, style [data-safe] with a clip-path polygon using the CSS custom properties the core sets (--left, --center, --right, --top, --bottom).
Requires Baseline 2024 features:
- Popover API — menu positioning
hidden="until-found"— preserves Ctrl+F / Cmd+F for hidden contentbeforematchevent — auto-opens components when find-in-page reveals hidden content
The core is framework-agnostic. Include the script and render the correct HTML structure:
<script src="https://unpkg.com/monochrome"></script>
<!-- Accordion -->
<div data-mode="single" id="mcr:accordion:faq">
<div>
<h3>
<button type="button" id="mct:accordion:faq-1"
aria-expanded="false" aria-controls="mcc:accordion:faq-1">
Question?
</button>
</h3>
<div id="mcc:accordion:faq-1" role="region"
aria-labelledby="mct:accordion:faq-1" aria-hidden="true"
hidden="until-found">
Answer.
</div>
</div>
</div>- Boolean props require
:binding —:open="true",:disabled="true",:checked="false" defaultValue→default-valuein templates (Vue auto-maps camelCase to kebab-case)- Dot notation works natively —
<Accordion.Root>,<Menu.Item>, etc. - Vue wrappers use
provide/injectfor component context
- Boolean props use JSX shorthand —
open,disabled,checked classNamefor CSS classes (standard React)
Both React and Vue wrappers use plain createElement/h() render functions — no JSX, no .vue SFCs. This keeps the source framework-agnostic in style and produces minimal bundle output.
- React uses
createElement()directly instead of JSX. This eliminates thereact/jsx-runtimeimport, produces smaller bundles, and improves Preact compatibility (one fewer module alias needed). - Vue uses
defineComponent()withh()render functions instead of SFC templates. The Vue SFC compiler adds significant overhead (patch flags, block tracking,openBlock/createElementBlock). Hand-writtenh()cuts the Vue bundle in half.
Both wrappers are thin: they render the correct DOM structure + ARIA attributes and wire up context (createContext/useContext in React, provide/inject in Vue). All behavior comes from the core.
For AI agents working on the monochrome repository itself.
bun install # Install dependencies
bun build.ts # Build core + React + Vue to dist/, update README badges
bun test # Run all 354 Playwright tests × 3 renderers (HTML + React + Vue)
bun run lint # Biome lint check
bun run format # Biome formatBiome config: 2-space indent, 100-char line width, double quotes, semicolons as needed.
- DOM is the source of truth — The core reads ARIA attributes to determine state. No internal state objects.
- Event delegation — Six global listeners handle everything:
click,pointermove,keydown,scroll,resize,beforematch. Zero per-component listeners. - Zero timers — All behavior is synchronous. No
setTimeout, norequestAnimationFrame, no debounce. - ID conventions drive behavior — Elements are identified by ID prefix (
mct:,mcc:,mcr:), not classes or data attributes. hidden="until-found"— Neverdisplay: none. Preserves browser find-in-page.
| Prefix | Role | Matched by core |
|---|---|---|
mct:a |
Accordion trigger | mct:accordion:* |
mct:c |
Collapsible trigger | mct:collapsible:* |
mct:m |
Menu trigger | mct:menu:* |
mct:t |
Tabs trigger | mct:tabs:* |
mcc: |
Content panel | Linked via aria-controls |
mcr: |
Root container | Component boundary |
Accordion — the core traverses item.firstElementChild.firstElementChild to find triggers. This exact nesting is required:
Root [data-mode] → Item → Header (h1-h6) → Trigger (button)
→ Panel [role=region]
Tabs — all Tab buttons must be direct children of the List (core iterates via parentElement.firstElementChild):
Root [data-orientation] → List [role=tablist] → Tab (button) [role=tab]
→ Panel [role=tabpanel]
Menu — every item is wrapped in <li role="none">:
Root → Trigger (button) [aria-haspopup=menu]
→ Popover (ul) [role=menu, popover=manual]
→ li [role=none] → menuitem (button|a|span)
→ li [role=separator]
→ li [role=none] (Group) → Trigger + Popover (submenu)
The core never handles Enter/Space in keydown. All activation flows through the global click listener. When a user presses Enter/Space on a <button>, the browser fires a native click. The core distinguishes keyboard from mouse via event.detail (0 = keyboard, ≥1 = mouse).
Menu triggers: keyboard opens with Focus.First (first item focused), mouse with Focus.None (focus stays on trigger).
When a submenu is open, the core creates a triangular safe zone so users can move diagonally to it without accidentally closing it.
- The core calculates the submenu's bounding rect and direction
- Sets
data-safeattribute + CSS custom properties (--left,--center,--right,--top,--bottom) on the Group element - Each
pointermoveupdates the triangle point to follow the cursor - Direction is detected via
movementXmatchingsafeDir(-4for right-opening,4for left-opening) - Touch events (
pointerType === "touch") are ignored
hidden="until-found"
React and Vue don't support hidden="until-found" as a prop value. Both wrappers inject an inline <script> (HiddenUntilFound component) that upgrades hidden="" to hidden="until-found" during HTML parsing.
The core's beforematch listener auto-opens the containing component when find-in-page reveals hidden content.
354 tests across three renderers: HTML, React, Vue. Every test runs against all three.
bun test # All 354 tests × 3 renderers
bun test -- --project=vue # Vue only
bun test -- --project=react # React only
bun test -- --project=html # HTML only
bun test -- --grep "Safety" # Specific tests, all renderersTest server (tests/server.tsx):
- HTML fixtures → served as static
.html - React fixtures → SSR via
renderToString(static) or client-side bundle (dynamic) - Vue fixtures → SSR via
createSSRApp+vueRenderToStringfor.vuefiles, client-side bundle for.tsentries - Vue SFC compilation uses
@vue/compiler-sfcregistered as a Bun plugin - Global
data-actionclick handler injected into the HTML template (no per-fixture<script>tags)
Static vs dynamic fixtures: Static fixtures export default a component → SSR-rendered. Dynamic fixtures (with ref(), useState()) don't export → client-side mounted via <script type="module">.
Things that must not be broken:
- Never add timers — no
setTimeout,requestAnimationFrame, or debounce - Never add per-component event listeners — everything goes through 6 global listeners
- Accordion nesting is sacred — Item > Header > Trigger, exactly 3 levels
- Tab buttons must be direct siblings — core iterates via
parentElement.firstElementChild - Menu radio groups are by DOM adjacency — separators or non-radio items break the group
aria-controlsmust match contentid— wrong values break everything- Disabled =
aria-disabled="true"— never the HTMLdisabledattribute - Disabled menu items render as
<span>— not<button>, to prevent click bubbling - Never
display: none— alwayshidden="until-found"for find-in-page - Vue typeahead text — menu item text must be inline (
<Menu.Item>Apple</Menu.Item>) to avoid leading whitespace breakingtextContent.startsWith()
src/
index.ts # Core (~550 lines, single file)
react/
index.ts # Re-exports
shared.ts # BaseProps, buildId, HiddenUntilFound
accordion.ts # createElement() render functions
collapsible.ts
menu.ts
tabs.ts
vue/
index.ts # Re-exports as namespaced objects
shared.ts # buildId, HiddenUntilFound, InjectionKeys
accordion.ts # h() render functions via defineComponent()
collapsible.ts
menu.ts
tabs.ts
tests/
server.tsx # Test server (SSR + bundles)
fixtures.ts # Playwright fixture with `renderer` option
accordion.spec.ts # 66 tests × 3 renderers
collapsible.spec.ts # 42 tests × 3 renderers
menu.spec.ts # 176 tests × 3 renderers
tabs.spec.ts # 70 tests × 3 renderers
fixtures/
test.css # Minimal test styles
html/ # Static HTML fixtures
react/ # React .tsx fixtures
vue/ # Vue .vue SFCs + dynamic .ts entries + App.vue