Migrating from Headless UI
Headless UI and Wire UI are close cousins — both are unstyled, accessible primitives you bring your own styling to. The migration is mostly mechanical, with two real differences to internalize:
data-* state. Where Headless UI gives you {({ open }) => …}, Wire UI exposes data-state / data-open attributes you style with plain CSS — no render functions.as → asChild. Headless UI’s as prop for polymorphism becomes Wire UI’s asChild.Menu.Items → Dropdown.Menu, Dialog.Panel → Modal.Content, etc. — so expect more hand-tuning than a Radix migration.Headless UI ships for React and Vue. Wire UI covers both (plus Solid). The codemod below is for React; Vue is a manual import swap of the same map.
1. Swap the dependency
npm uninstall @headlessui/react # or @headlessui/vue
npm install @wire-ui/react # or @wire-ui/vue / @wire-ui/solid2. Run the codemod (React)
npx jscodeshift \
-t https://wire-ui.com/codemods/headless-ui-to-wire-ui.cjs \
--extensions=tsx,ts,jsx,js --parser=tsx \
src/It repoints @headlessui/react imports to @wire-ui/react, renames the top-level components (Menu → Dropdown, Dialog → Modal, Listbox → Select, Tab → Tabs), and drops Transition (no equivalent). Because Headless UI’s part names differ, the renamed parts (Dropdown.Items, Modal.Panel) will then show as type errors — that’s your fix-it checklist.
Component map
✅ drop-in · ✏️ codemod renames it · 🔧 rename + adjust parts
| Headless UI | Wire UI | Notes | |
|---|---|---|---|
Switch | Switch | 🔧 | Now compound: Switch.Root + Switch.Thumb. checked/onChange carry over. |
Popover | Popover | ✏️ | Popover.Button/Panel → Popover.Trigger/Content. |
Combobox | Combobox | 🔧 | Combobox.Input/Options/Option → Combobox.Input/Content/Item. |
Menu | Dropdown | 🔧 | Menu.Button/Items/Item → Dropdown.Trigger + Dropdown.Menu (items are plain children). |
Listbox | Select | 🔧 | Listbox.Button/Options/Option → Select.Trigger/Content/Item. |
Dialog | Modal | 🔧 | Dialog.Panel → Modal.Content nested inside Modal.Overlay; Dialog.Title → a plain heading. |
Disclosure | Accordion | 🔧 | One-item Accordion type="single" collapsible; Disclosure.Button/Panel → Accordion.Trigger/Content. |
RadioGroup | Radio | 🔧 | RadioGroup.Option → Radio.Item (+ Radio.Indicator/Label). |
Tab group | Tabs | 🔧 | Tab.Group/List/Tab/Panels/Panel → Tabs.Root/List/Trigger/Content. |
Field/Label/Description | Form.* | ✏️ | See the Form Integration guide. |
Button/Input/Textarea/Checkbox | same | ✅ | The v2 form controls map by name. |
Transition | — | 🔧 | No Transition primitive. Drive enter/exit from data-state with CSS or your animation library. |
Worked example: Menu → Dropdown
Note how the render-prop + part structure flattens into compound parts with data-* state.
Before (Headless UI):
import { Menu } from '@headlessui/react'
<Menu>
<Menu.Button>Options</Menu.Button>
<Menu.Items>
<Menu.Item>{({ active }) => <a data-active={active}>Edit</a>}</Menu.Item>
<Menu.Item><a>Delete</a></Menu.Item>
</Menu.Items>
</Menu>After (Wire UI):
import { Dropdown } from '@wire-ui/react'
<Dropdown.Root>
<Dropdown.Trigger>Options</Dropdown.Trigger>
<Dropdown.Menu>
{/* style the active item with [data-highlighted] instead of a render prop */}
<button>Edit</button>
<button>Delete</button>
</Dropdown.Menu>
</Dropdown.Root>After the codemod — checklist
tsc run — every renamed part (Dropdown.Items, Modal.Panel, …) surfaces as the exact line to fix against the map.{({ open, active }) => …}) with [data-state] / [data-highlighted] CSS — see Data Attributes.Transition for a data-state-driven CSS transition or your animation library of choice.Dialog → Modal structure (Content inside Overlay, your own trigger button) — see the overlay walkthrough.