shadcn 4.x generates components on top of Base UI instead of Radix. asChild is gone, and Accordion no longer accepts type or collapsible — both are TS2322, so tsc hands them to you in the first run. The one that survives a green build is Select: Base UI's <Select.Value> renders the value, not the selected item's children, unless you pass an items map to <Select.Root>. No error, no warning, and the open popup still looks correct — only the closed trigger reads buy where it should read Buy a template.
The symptom
Contact form on my studio site. A subject field with three options, wired through react-hook-form's Controller. Open the select, and the popup is exactly right: "Buy a template", "Custom project", "Other", localized, the check mark on the active one. Pick the first. Popup closes. The trigger now says:
buyThe raw option value. Not the label sitting two pixels above it a moment ago.
That asymmetry is what cost me the time. Every instinct says a select that renders its own list correctly must be able to render the selected one, so I went looking at my SelectItem wrapper, at the Controller, at whether field.value was carrying the label or the value. All fine. The list and the trigger are simply two different code paths in Base UI, and only one of them reads the children I wrote.
The loud half of the migration
Context: components.json in that project says "style": "base-nova", shadcn is declared ^4.6.0 (resolved 4.13.0), and not one file under src/components/ui/ imports @radix-ui/* any more — the primitives all come from @base-ui/react, declared ^1.4.1 and resolved to 1.6.0. Two of the three breakages announced themselves immediately.
asChild no longer exists. Base UI composes through a render prop instead, typed in node_modules/@base-ui/react/internals/types.d.ts as:
render?: React.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | undefined;Inside the wrappers that reads fine — you pass the element you want the primitive to become, and the primitive merges its props into it. From src/components/ui/select.tsx:
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>Where it reads badly is the classic <Button asChild><Link/></Button>. render={<Link href="/x" />} puts the href in one place and the children in another, and with next-intl's locale-aware Link it gets worse. I stopped translating that idiom and wrote a component instead — src/components/ui/button-link.tsx:
/**
* Internal next-intl-aware link styled as a button. Use this anywhere
* you'd reach for `<Button asChild><Link/></Button>` in classic shadcn —
* shadcn 4.x dropped `asChild` in favor of Base UI's `render` API.
*/
export function ButtonLink({ className, variant, size, children, ...props }: Props) {
return (
<Link {...props} className={cn(buttonVariants({ variant, size }), className)}>
{children}
</Link>
);
}buttonVariants is still the same cva export from button.tsx, so styling stays in one place. That file is now imported in 13 files across the app, and the sibling ExternalButtonLink covers target="_blank" anchors. It is less clever than render and it never needs explaining.
The second loud one: Accordion lost type and collapsible. AccordionRootProps in node_modules/@base-ui/react/accordion/root/AccordionRoot.d.ts has value, defaultValue, disabled, hiddenUntilFound, keepMounted, loopFocus, onValueChange, multiple, orientation. That is the whole list. type="single" / type="multiple" collapsed into a single boolean:
/**
* Whether multiple items can be open at the same time.
* @default false
*/
multiple?: boolean | undefined;And collapsible is gone outright, because there is nothing left to opt into: in AccordionRoot.js the single-open branch is value[0] === newValue ? [] : [newValue], so pressing the open trigger always closes it. My FAQ sections now render <Accordion className="mx-auto max-w-3xl"> — styling only, no accordion props at all.
The half that compiles
I wanted to know exactly which of these the compiler would hand me, so I dropped a probe file into src/ with all three mistakes in it and ran npx tsc --noEmit:
// src/__probe.tsx
import { Button } from "@/components/ui/button";
import { Accordion } from "@/components/ui/accordion";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
export function Probe() {
return (
<>
<Button asChild><a href="/x">go</a></Button>
<Accordion type="single" collapsible />
<Select value="buy" onValueChange={() => {}}>
<SelectTrigger><SelectValue placeholder="Pick a topic" /></SelectTrigger>
<SelectContent>
<SelectItem value="buy">Buy a template</SelectItem>
</SelectContent>
</Select>
</>
);
}Output, keeping the Property … does not exist line of each error and eliding the cva variant blob:
src/__probe.tsx(14,15): error TS2322: …
Property 'asChild' does not exist on type
'IntrinsicAttributes & ButtonProps & VariantProps<…>'.
src/__probe.tsx(15,18): error TS2322: …
Property 'type' does not exist on type 'IntrinsicAttributes & Props<any>'.Two errors. The Select block — the one that actually ships a wrong label to a user — produces nothing. items is declared items?: on the root, so omitting it is valid TypeScript, and every other prop in that block is correct. Build green, lint green, test green, trigger wrong.
The mechanism
node_modules/@base-ui/react/select/value/SelectValue.js decides what to render in a five-branch chain (namespace prefixes stripped here):
if (typeof childrenProp === 'function') {
children = childrenProp(value);
} else if (childrenProp != null) {
children = childrenProp;
} else if (!hasSelectedValue && placeholder != null && !hasNullLabel) {
children = placeholder;
} else if (Array.isArray(value)) {
children = resolveMultipleLabels(value, items, itemToStringLabel);
} else {
children = resolveSelectedLabel(value, items, itemToStringLabel);
}items there is read off the root's store, not off the Value. So with no items on <Select.Root> and no function child, everything falls to resolveSelectedLabel(value, undefined, undefined) in node_modules/@base-ui/react/internals/resolveValueLabel.js, which walks past its items branches into fallback() → stringifyAsLabel → serializeValue, and serializeValue returns a string value unchanged.
You can run that resolver directly, no React involved:
const r = require('./node_modules/@base-ui/react/internals/resolveValueLabel.js');
r.resolveSelectedLabel('buy', undefined, undefined);
// 'buy'
r.resolveSelectedLabel('buy', { buy: 'Buy a template' }, undefined);
// 'Buy a template'
r.resolveSelectedLabel('es', undefined, undefined);
// 'es'That last line is the version of this bug that hurts most, because a locale switcher is a select whose values are locale codes and whose labels are language names. Ship it and your language menu offers es instead of Español.
The popup keeps looking right because it never touches that chain. SelectItem in my wrapper puts the children straight into ItemText:
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>Labels in the list come from JSX; the label in the trigger comes from a lookup table. Two sources, and only one of them was populated.
Radix works differently, which is exactly why nobody expects this. In @radix-ui/react-select@2.3.3, SelectItemText portals its own children into the trigger's value node when it is the selected item — node_modules/@radix-ui/react-select/dist/index.js, line 1030:
itemContext.isSelected && context.valueNode && !context.valueNodeHasChildren && !shouldShowPlaceholder(context.value)
? ReactDOM.createPortal(itemTextProps.children, context.valueNode)
: nullOne source of truth, moved into place with a portal. Under Radix a bare <SelectValue /> cannot show the wrong thing, because there is nothing for it to show but the item's own children. Base UI swapped the portal for a lookup, and a lookup can be empty.
The fix
Declare the labels once and hand them to the root. src/components/sections/ContactForm.tsx:
const subjectItems = {
buy: t("subjectOptions.buy"),
custom: t("subjectOptions.custom"),
other: t("subjectOptions.other"),
};
return (
<Select
value={field.value ?? null}
onValueChange={(value) => field.onChange(value ?? "")}
items={subjectItems}
>
<SelectTrigger id="vs-contact-subject" onBlur={field.onBlur}>
<SelectValue placeholder={t("subjectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="buy">{subjectItems.buy}</SelectItem>
<SelectItem value="custom">{subjectItems.custom}</SelectItem>
<SelectItem value="other">{subjectItems.other}</SelectItem>
</SelectContent>
</Select>
);The map feeds both the trigger and the items, so they cannot drift. SelectRoot.d.ts accepts three shapes for items — a Record<string, ReactNode>, an array of { label, value }, or an array of groups — and documents the consequence plainly: "When specified, <Select.Value> renders the label of the selected item instead of the raw value."
The other route is a function child, which the SelectValue chain hits first:
<SelectValue>{(v: string | null) => (v ? labels[v] : "Pick one")}</SelectValue>Use it when the label needs formatting rather than lookup. For anything driven by translations, items is the one to reach for, because it is a prop on the root and therefore visible at the same place you set value.
The third one, which throws instead
MenuGroupLabel is not optional-context-tolerant. In node_modules/@base-ui/react/menu/group-label/MenuGroupLabel.js it calls useMenuGroupRootContext() unconditionally, and that hook in menu/group/MenuGroupContext.js throws on a missing provider (the production branch swaps the string for a numbered formatErrorMessage(31)):
function useMenuGroupRootContext() {
const context = React.useContext(MenuGroupContext);
if (context === undefined) {
throw new Error(process.env.NODE_ENV !== "production"
? 'Base UI: MenuGroupContext is missing. Menu group parts must be used within <Menu.Group> or <Menu.RadioGroup>.'
: formatErrorMessage(31));
}
return context;
}So the shadcn-generated DropdownMenuLabel — which wraps MenuPrimitive.GroupLabel — throws if you drop it at the top of a menu the way Radix let you. Wrap it in a DropdownMenuGroup, or, if the heading is decorative rather than an accessible group name, render a plain element. My language menu does the latter: a <div> with the same typography classes, above a DropdownMenuSeparator. This one is loud, at least, and in development the message names the fix.
How to catch it next time
TypeScript covers the API removals and nothing else. For the label path, the check that settles it is to assert on the closed trigger, not the open list:
render(<SubjectSelect />);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Buy a template" }));
expect(screen.getByRole("combobox")).toHaveTextContent("Buy a template");If you skip the last line, or assert against the option instead of the trigger, the broken version passes — the option's text was always correct.
And a grep that sweeps the whole codebase in one pass. Every Select.Root either needs items, or needs a function child on its Value, or is displaying a raw code on purpose. Skip your own wrapper file, which defines the parts and uses neither:
grep -rl "<Select" --include=*.tsx src \
| grep -v "components/ui/" \
| xargs grep -L "items=\|SelectValue>"Anything it prints is a candidate. With the contact form fixed it prints nothing and exits clean on my repo, which is what makes it worth keeping in a check script rather than running once — a self-closing <SelectValue placeholder="…" /> inside a root with no items is precisely the shape it catches, and precisely the shape tsc will keep accepting.
One last note on where this bit me. My five Next.js templates still ship Radix wrappers — @radix-ui/react-select@^2.2.6 in each, resolving to 2.3.3 — while the studio site runs shadcn 4 on Base UI. The two Select wrappers export the same names, take the same JSX, and behave differently in exactly one place: the closed trigger. Moving a form between them compiles cleanly and renders a value where a label belongs. If you keep a Radix codebase and a Base UI codebase side by side, that's the seam to watch.
This is the pattern I ship in the Next.js appointment booking template, whose Select wrapper is still the Radix one.
Verified on @base-ui/react@1.6.0, shadcn@4.13.0, @radix-ui/react-select@2.3.3, next@16.2.10, react@19.2.7.