shadcn/ui and React libraries
shadcn/ui copies its components into your project, so brust compiles them like your own code. Most of the markup becomes static or native; what needs Radix’s behaviour stays a React island, and the build says which.
What compiles
- Rest props and spread.
{ className, ...props }in the signature and{...props}on an element, with handlers that come through the spread. asChildandSlot. Decided where the component is used, as below.- Constant children. Markup between the tags of a React island, when it is constant, travels into the island as data, so the page around it stays static. That includes a constant list (
[1, 2, 3].map(…),Array.from({ length: 4 })), a constant condition and an element passed as a prop. forwardRef. A component wrapped inforwardRefcompiles; a forwardedrefon an HTML element is dropped, and any other use of it keeps the component on React.- Member tags.
<DialogPrimitive.Root>resolves to the package export. - Components exported by name. A file with many components (
Card,CardHeader, …) is fine; any of them may be an island.
// components/ui/button.tsx, as shadcn writes it
function Button({ className, variant, size, asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : 'button'
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
}
// pages/Home.tsx
<Button>Save</Button> // a <button>, static markup
<Button asChild><a href="/login">Login</a></Button> // the <a>, with the button's propsimport { Dialog as DialogPrimitive } from 'radix-ui'
function Dialog({ ...props }) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> // the package export, one island
}What stays an island
The Radix primitives themselves (dialogs, menus, tabs, selects) use React context, refs, portals and focus management, which brust does not lower yet, so each one is a React island of the package’s own export. Components that wrap a React library (a chart, a calendar, a carousel, a command palette) are islands too. A function or a value that changes with state passed to an island makes the component that passes it render with React, and the build reports it as island-prop.
An island is not an error. The component works as it does in React; the page keeps its tier for everything around it.
The numbers
The fixture app builds each of 64 pages alone, one per component of shadcn/ui ([email protected], registry new-york-v4, 61 components, radix-ui 1.8.1, React 19.3.0). 64 build: 61 are static, 1 native and 2 React. Every page builds. Together they hold 53 React islands; 18 pages load no JavaScript at all. The JavaScript a page loads, gzipped, is 0 KB at the least, 87 KB in the middle and 240 KB at the most.
Across the libraries behind them (76 packages, 561 components) the compiler reads 1 as static or native today; the rest are the work still ahead.
What blocks most pages
| Code | Pages | Meaning |
|---|---|---|
external-component | 43 | The component comes from a package. Radix primitives are compiled by React, so they render as an island of the package export. |
spread-props | 41 | Props are spread onto something that is not a plain element: a React island, or a value whose shape is not known when you build. |
island-children | 25 | An island receives children that are not constant (they read state, hold a handler, or call a function). |
hook-unsupported | 6 | A React hook brust does not lower yet, such as `useContext` or `useReducer`. |
member-tag | 4 | A member tag of a local object, such as a context provider, or deeper than one level. |
control-flow | 3 | Control flow in the component body that is not a single return. |
jsx-expression | 3 | A JSX expression the compiler cannot lower, such as a render function. |
slot-dynamic | 3 |
Page by page
| Page | Tier | Islands | JavaScript (gzip) |
|---|---|---|---|
accordion | static | 1 | 94 KB |
alert | static | 0 | 0 |
alert-dialog | static | 1 | 100 KB |
aspect-ratio | static | 1 | 74 KB |
attachment | static | 0 | 0 |
avatar | static | 2 | 87 KB |
badge | static | 0 | 0 |
badge-aslink | static | 0 | 0 |
breadcrumb | static | 2 | 85 KB |
bubble | static | 0 | 0 |
button | static | 0 | 0 |
button-alias | static | 1 | 85 KB |
button-aschild | static | 0 | 0 |
button-group | static | 1 | 86 KB |
calendar | static | 1 | 107 KB |
card | static | 0 | 0 |
carousel | static | 1 | 96 KB |
chart | static | 1 | 240 KB |
checkbox | static | 2 | 92 KB |
collapsible | static | 1 | 89 KB |
combobox | react | 1 | 158 KB |
command | static | 1 | 104 KB |
context-menu | static | 1 | 116 KB |
dialog | static | 1 | 102 KB |
direction | static | 1 | 70 KB |
drawer | static | 1 | 108 KB |
dropdown-menu | static | 1 | 117 KB |
empty | static | 0 | 0 |
field | static | 1 | 86 KB |
form | react | 1 | 101 KB |
hover-card | static | 1 | 103 KB |
input | static | 0 | 0 |
input-group | static | 1 | 87 KB |
input-otp | static | 1 | 87 KB |
item | static | 0 | 0 |
kbd | static | 0 | 0 |
label | static | 1 | 85 KB |
marker | static | 0 | 0 |
menubar | static | 1 | 116 KB |
message | static | 0 | 0 |
message-scroller | static | 1 | 94 KB |
native-select | static | 0 | 0 |
navigation-menu | static | 1 | 99 KB |
pagination | static | 2 | 87 KB |
popover | static | 1 | 109 KB |
progress | static | 1 | 85 KB |
radio-group | static | 1 | 96 KB |
resizable | static | 1 | 103 KB |
scroll-area | static | 1 | 92 KB |
select | static | 1 | 118 KB |
separator | static | 2 | 84 KB |
sheet | static | 1 | 101 KB |
sidebar | static | 1 | 116 KB |
skeleton | static | 0 | 0 |
slider | static | 1 | 93 KB |
sonner | native | 1 | 87 KB |
spinner | static | 1 | 82 KB |
switch | static | 2 | 89 KB |
table | static | 0 | 0 |
tabs | static | 1 | 94 KB |
textarea | static | 0 | 0 |
toggle | static | 2 | 87 KB |
toggle-group | static | 1 | 93 KB |
tooltip | static | 1 | 105 KB |
Read it yourself
The same data, with the full list of blockers and the library analysis, is in docs/shadcn-coverage.md. bun run shadcn in the brust repository regenerates both files; a pull request with a stale copy fails CI. Sizes are gzipped and rounded to 1 KB; a page that loads no JavaScript shows 0. To see where your own app stands, run brust build: it prints a rule and a line for every component that falls back, and dist/manifest.json has each component’s tier.