Skip to main content

Framework Usage

Monochrome provides framework wrappers for React and Vue. Both generate the same HTML and ARIA attributes — all behavior comes from the shared core.

Installation

npm install monochrome
The package includes:
  • Core runtime (~2.2KB)
  • React wrappers
  • Vue wrappers
Import what you need:
import "monochrome"  // Core (import once at app entry)
import { Accordion, Menu, Tabs } from "monochrome/react"
Import "monochrome" once at your app’s entry point. The core auto-activates and handles all components on the page via event delegation.

React Usage

Basic Example

import "monochrome"
import { Accordion } from "monochrome/react"

export function FAQ() {
  return (
    <Accordion.Root type="single">
      <Accordion.Item open>
        <Accordion.Header as="h3">
          <Accordion.Trigger>
            What is Monochrome?
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Panel>
          <p>A tiny, accessible UI component library.</p>
        </Accordion.Panel>
      </Accordion.Item>
      <Accordion.Item disabled>
        <Accordion.Header>
          <Accordion.Trigger>
            Disabled Section
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Panel>
          <p>This content is disabled.</p>
        </Accordion.Panel>
      </Accordion.Item>
    </Accordion.Root>
  )
}

Boolean Props

React uses JSX boolean shorthand:
// These are equivalent:
<Accordion.Item open />
<Accordion.Item open={true} />

<Accordion.Item disabled />
<Menu.CheckboxItem checked />

CSS Classes

Use className (standard React):
<Accordion.Trigger className="trigger">
  Click me
</Accordion.Trigger>

All Components

import { Accordion, Collapsible, Menu, Tabs } from "monochrome/react"

// Accordion
<Accordion.Root type="single" | "multiple">
  <Accordion.Item open disabled>
    <Accordion.Header as="h1" | "h2" | "h3" | "h4" | "h5" | "h6">
      <Accordion.Trigger />
    </Accordion.Header>
    <Accordion.Panel />
  </Accordion.Item>
</Accordion.Root>

// Collapsible
<Collapsible.Root open>
  <Collapsible.Trigger />
  <Collapsible.Panel />
</Collapsible.Root>

// Tabs
<Tabs.Root defaultValue="tab1" orientation="horizontal" | "vertical">
  <Tabs.List>
    <Tabs.Tab value="tab1" disabled />
  </Tabs.List>
  <Tabs.Panel value="tab1" focusable />
</Tabs.Root>

// Menu
<Menu.Root menubar>
  <Menu.Trigger />
  <Menu.Popover>
    <Menu.Item disabled href="/link" />
    <Menu.CheckboxItem checked disabled />
    <Menu.RadioItem checked disabled />
    <Menu.Label />
    <Menu.Separator />
    <Menu.Group>
      <Menu.Trigger />  {/* Submenu */}
      <Menu.Popover />
    </Menu.Group>
  </Menu.Popover>
</Menu.Root>

Vue Usage

Basic Example

<script setup lang="ts">
import "monochrome"
import { Accordion } from "monochrome/vue"
</script>

<template>
  <Accordion.Root type="single">
    <Accordion.Item :open="true">
      <Accordion.Header as="h3">
        <Accordion.Trigger>
          What is Monochrome?
        </Accordion.Trigger>
      </Accordion.Header>
      <Accordion.Panel>
        <p>A tiny, accessible UI component library.</p>
      </Accordion.Panel>
    </Accordion.Item>
    <Accordion.Item :disabled="true">
      <Accordion.Header>
        <Accordion.Trigger>
          Disabled Section
        </Accordion.Trigger>
      </Accordion.Header>
      <Accordion.Panel>
        <p>This content is disabled.</p>
      </Accordion.Panel>
    </Accordion.Item>
  </Accordion.Root>
</template>

Boolean Props

Vue requires : binding for boolean props:
<!-- Correct -->
<Accordion.Item :open="true" />
<Accordion.Item :disabled="true" />
<Menu.CheckboxItem :checked="false" />

<!-- Incorrect (will not work) -->
<Accordion.Item open />
<Accordion.Item disabled />
Vue treats boolean attributes without : binding as strings. Always use :open="true", not open.

Kebab-Case Props

Vue auto-converts camelCase to kebab-case in templates:
<!-- These are equivalent -->
<Tabs.Root default-value="tab1" />
<Tabs.Root defaultValue="tab1" />

<!-- But kebab-case is conventional in Vue templates -->
<Tabs.Root default-value="tab1" orientation="horizontal" />

CSS Classes

Use class (standard Vue):
<Accordion.Trigger class="trigger">
  Click me
</Accordion.Trigger>

Dot Notation

Vue 3 supports dot notation natively:
<Accordion.Root>  <!-- Works in Vue 3 -->
  <Accordion.Item>
    <Accordion.Trigger />
  </Accordion.Item>
</Accordion.Root>

All Components

<!-- Accordion -->
<Accordion.Root type="single" | "multiple">
  <Accordion.Item :open="true" :disabled="true">
    <Accordion.Header as="h1" | "h2" | "h3" | "h4" | "h5" | "h6">
      <Accordion.Trigger />
    </Accordion.Header>
    <Accordion.Panel />
  </Accordion.Item>
</Accordion.Root>

<!-- Collapsible -->
<Collapsible.Root :open="true">
  <Collapsible.Trigger />
  <Collapsible.Panel />
</Collapsible.Root>

<!-- Tabs -->
<Tabs.Root default-value="tab1" orientation="horizontal" | "vertical">
  <Tabs.List>
    <Tabs.Tab value="tab1" :disabled="true" />
  </Tabs.List>
  <Tabs.Panel value="tab1" :focusable="true" />
</Tabs.Root>

<!-- Menu -->
<Menu.Root :menubar="true">
  <Menu.Trigger />
  <Menu.Popover>
    <Menu.Item :disabled="true" href="/link" />
    <Menu.CheckboxItem :checked="false" :disabled="true" />
    <Menu.RadioItem :checked="true" :disabled="true" />
    <Menu.Label />
    <Menu.Separator />
    <Menu.Group>
      <Menu.Trigger />  <!-- Submenu -->
      <Menu.Popover />
    </Menu.Group>
  </Menu.Popover>
</Menu.Root>

React vs Vue Differences

FeatureReactVue
Boolean propsopen or open={true}:open="true"
CSS classesclassName="..."class="..."
camelCase propsdefaultValue="tab1"default-value="tab1" (kebab-case in templates)
Dot notationNativeNative (Vue 3)
ContextcreateContext / useContextprovide / inject

Wrapper Implementation

Both React and Vue wrappers are thin. They:
  1. Render the correct HTML structure
  2. Set ARIA attributes based on props
  3. Wire up component context (linking triggers to panels)
  4. Let the core handle all behavior

No JSX, No SFCs

  • React uses createElement() directly (no JSX)
  • Vue uses defineComponent() with h() render functions (no SFCs)
This design:
  • Produces smaller bundles
  • Keeps source framework-agnostic in style
  • Eliminates compiler overhead
  • Improves Preact compatibility (React)

Why Render Functions?

import { createElement as h } from "react"

export const Trigger = (props: TriggerProps) => {
  return h("button", {
    ...props,
    type: "button",
    id: buildId("trigger", context.id),
    "aria-expanded": props.open ? "true" : "false",
    "aria-controls": buildId("content", context.id)
  })
}
Benefits:
  • React: No react/jsx-runtime import, smaller bundle, better Preact compat
  • Vue: No SFC compiler overhead (patch flags, block tracking), 50% smaller bundle

Context Management

React Context

const AccordionContext = createContext<AccordionContextValue | null>(null)

export const Root = ({ type, children }: RootProps) => {
  const id = useId()
  return (
    <AccordionContext.Provider value={{ id, type }}>
      <div data-mode={type} id={buildId("root", id)}>
        {children}
      </div>
    </AccordionContext.Provider>
  )
}

export const Trigger = (props: TriggerProps) => {
  const context = useContext(AccordionContext)
  if (!context) throw new Error("Trigger must be inside Root")
  // ...
}

Vue Context

const AccordionKey = Symbol("accordion")

export const Root = defineComponent({
  setup(props, { slots }) {
    const id = Math.random().toString(36).slice(2)
    provide(AccordionKey, { id, type: props.type })
    return () => h("div", {
      "data-mode": props.type,
      id: buildId("root", id)
    }, slots.default?.())
  }
})

export const Trigger = defineComponent({
  setup(props, { slots }) {
    const context = inject(AccordionKey)
    if (!context) throw new Error("Trigger must be inside Root")
    // ...
  }
})

Event Handling

By default, clicking a menu item closes the menu. Prevent this by calling e.stopPropagation():
<Menu.Item onClick={(e) => {
  console.log("Item clicked")
  e.stopPropagation()  // Keeps menu open
}}>
  Action
</Menu.Item>

Server-Side Rendering (SSR)

Both wrappers support SSR:
  • React: Works with renderToString() (tested in Monochrome’s test suite)
  • Vue: Works with renderToString() from @vue/server-renderer (tested)

hidden="until-found" Issue

React and Vue don’t support hidden="until-found" as a prop value. Both wrappers inject a small inline <script> that upgrades hidden="" to hidden="until-found" during HTML parsing.
<!-- Rendered HTML -->
<script>
  document.querySelectorAll('[hidden]').forEach(el => {
    if (el.getAttribute('aria-hidden') === 'true') {
      el.setAttribute('hidden', 'until-found')
    }
  })
</script>
This script runs before the page is interactive, ensuring find-in-page works immediately.

TypeScript Support

Both wrappers are written in TypeScript with full type definitions:
import type { ComponentProps } from "react"
import { Accordion } from "monochrome/react"

type TriggerProps = ComponentProps<typeof Accordion.Trigger>

const MyTrigger = (props: TriggerProps) => {
  return <Accordion.Trigger {...props} />
}

Best Practices

  1. Import core once — At your app’s entry point (main.tsx, App.vue, etc.)
  2. Let wrappers handle IDs — Don’t set id props manually
  3. Use boolean bindings in Vue — Always :open="true", never open
  4. Don’t manually set ARIA — Wrappers manage all ARIA attributes
  5. Test with both frameworks — If you maintain a component library, test HTML, React, and Vue

Framework Compatibility

React

  • ✅ React 16.8+ (hooks required)
  • ✅ React 18
  • ✅ Next.js (SSR tested)
  • ✅ Remix (SSR tested)
  • ✅ Preact (with preact/compat)

Vue

  • ✅ Vue 3.0+
  • ✅ Nuxt 3 (SSR tested)
  • ❌ Vue 2 (not supported, dot notation requires Vue 3)

Migration Between Frameworks

The API is nearly identical. Migrating from React to Vue (or vice versa) requires only prop syntax changes:
<Accordion.Item open disabled>
  <Accordion.Trigger className="trigger" />
</Accordion.Item>

<Tabs.Root defaultValue="tab1" />

Additional Resources

Build docs developers (and LLMs) love