zubra convert, measured#
The one objection every developer we asked shared was "every library is React-first", and zubra convert is the answer to it. This page says how far it gets on a real React component library.
The corpus#
shadcn/ui, apps/v4/registry/new-york-v4/ui at 42f3bb6 (2026-11-06): 61 files, 325 exported function components, in TypeScript. It's the most-copied React component code there is, and a hard case: most of it wraps a third-party React primitive, and almost every component spreads the rest of its props onto an element.
The run: zubra convert <file> --prefix ui, with --component <Name> for each component of a file that exports several.
The result#
| Files built on a React primitive (Radix, cmdk, vaul, Recharts, …) | 46 files refused up front, with one message naming the library and pointing at the platform, the registry or Zag |
| Components in the other 15 files | 68 |
| …that convert and compile with nothing left to finish | 50 (74%) |
How it gets there:
- Spread onto an element compiles (
{...expr}is a Zubra feature), so it isn't reported. ...propsspread onto the returned element is dropped, with a note: in Zubra the host is the element the consumer sees, and it carries theirid,aria-*,data-*and listeners itself.classNamebecomes the host's ownclass: the parameter goes and its uses becomeundefined, socn("base", className)keeps the component's own classes, and the consumer's style the host.- The props' React type (
React.ComponentProps<"div">) goes. - A component built on a React primitive is refused up front. Reported line by line it would be a dozen unrelated problems; it's one problem: the behaviour is the primitive's, and there's no HTML to convert to.
What's left in the 18#
All are reported with advice, none silently wrong:
- Spreading onto another component (7):
<Button {...props}>. Zubra checks every prop of a child component; a spread can't be checked. The fix is to pass each prop. - Custom hooks and a context provider (6, all in
message-scroller.tsx): the component's state lives in a React context and a custom hook. - The rest of the props spread onto an inner element (2):
<div><table {...props} /></div>. The consumer's attributes would land on the host, not the table. - One early return, one
childrenused as a value, one icon component that isn't imported as a component.
What the numbers say#
The plain-markup end of a React library converts: cards, alerts, inputs, tables, kbd, skeletons, empty states. The behaviour end doesn't, and shouldn't pretend to. A Radix dialog has no markup of its own to convert; its Zubra equivalent is the platform's <dialog>, a registry component, or a Zag machine through @zubra/runtime/zag.
Per file#
Converted or reported:
| File | Components | Converted | What's left |
|---|---|---|---|
alert.tsx |
3 | 3 | — |
card.tsx |
7 | 7 | — |
empty.tsx |
6 | 6 | — |
field.tsx |
10 | 7 | spreading onto a component isn't converted (1), X can only be rendered: put X in the JSX (1), a component function returns once, at the end (1), use conditionals in the returned JSX (1) |
input-group.tsx |
6 | 3 | spreading onto a component isn't converted (3) |
input.tsx |
1 | 1 | — |
kbd.tsx |
2 | 2 | — |
message-scroller.tsx |
6 | 0 | member expressions as tags (X) aren't supported (5), import the component and use its name (5), X isn't converted (1), props as one object (X) aren't converted (1) |
message.tsx |
6 | 6 | — |
native-select.tsx |
3 | 2 | the rest of the props, used other than spread onto the returned element, aren't converted (1) |
pagination.tsx |
7 | 4 | spreading onto a component isn't converted (2), |
skeleton.tsx |
1 | 1 | — |
spinner.tsx |
1 | 0 | spreading onto a component isn't converted (1) |
table.tsx |
8 | 7 | the rest of the props, used other than spread onto the returned element, aren't converted (1) |
textarea.tsx |
1 | 1 | — |
Refused up front:
| File | Built on |
|---|---|
accordion.tsx |
Radix |
alert-dialog.tsx |
Radix |
aspect-ratio.tsx |
Radix |
attachment.tsx |
Radix |
avatar.tsx |
Radix |
badge.tsx |
Radix |
breadcrumb.tsx |
Radix |
bubble.tsx |
Radix |
button-group.tsx |
Radix |
button.tsx |
Radix |
calendar.tsx |
react-day-picker |
carousel.tsx |
Embla's React carousel |
chart.tsx |
Recharts |
checkbox.tsx |
Radix |
collapsible.tsx |
Radix |
combobox.tsx |
Base UI |
command.tsx |
cmdk |
context-menu.tsx |
Radix |
dialog.tsx |
Radix |
direction.tsx |
Radix |
drawer.tsx |
vaul |
dropdown-menu.tsx |
Radix |
form.tsx |
Radix |
hover-card.tsx |
Radix |
input-otp.tsx |
input-otp |
item.tsx |
Radix |
label.tsx |
Radix |
marker.tsx |
Radix |
menubar.tsx |
Radix |
navigation-menu.tsx |
Radix |
popover.tsx |
Radix |
progress.tsx |
Radix |
radio-group.tsx |
Radix |
resizable.tsx |
react-resizable-panels |
scroll-area.tsx |
Radix |
select.tsx |
Radix |
separator.tsx |
Radix |
sheet.tsx |
Radix |
sidebar.tsx |
Radix |
slider.tsx |
Radix |
sonner.tsx |
sonner |
switch.tsx |
Radix |
tabs.tsx |
Radix |
toggle-group.tsx |
Radix |
toggle.tsx |
Radix |
tooltip.tsx |
Radix |