Coming from native HTML#

Keep the browser semantics you rely on, but do not assume a styled component has the same API as the native element. These five differences are worth checking before replacing a working screen.

1. Compound parts and server components#

Both Card.Header and its named export CardHeader work. Inside a Next.js App Router server page, import named parts instead of reading properties from a client-component namespace:

import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";

export default function Page() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Account</CardTitle>
      </CardHeader>
      <CardContent>Account details</CardContent>
    </Card>
  );
}

Do not assume there is a DialogContent export or Dialog.Content part. Dialog itself owns the content. Give it a trigger and children:

import { Dialog, DialogTitle, DialogDescription } from "@/components/ui/dialog";

<Dialog trigger={<button>Open account</button>} size="lg">
  <DialogTitle>Account</DialogTitle>
  <DialogDescription>Update account details.</DialogDescription>
</Dialog>;

The legacy direct Dialog.Trigger child also works. Put callback handlers and controlled client state in a "use client" component; named exports do not make functions serializable across the server boundary. See Installation for the complete Next.js setup.

2. An empty Select item is not an “All” option#

Native HTML accepts <option value="">All</option>. Radix Select reserves the empty string for clearing the selection and displaying its placeholder; <Select.Item value=""> throws.

Use a non-empty sentinel that cannot collide with a real value:

const ALL = "__all__";
const [category, setCategory] = useState(ALL);
const visible = rows.filter((row) => category === ALL || row.category === category);

<Select value={category} onValueChange={setCategory}>
  <Select.Trigger aria-label="Category">
    <Select.Value />
  </Select.Trigger>
  <Select.Content>
    <Select.Item value={ALL}>All</Select.Item>
    <Select.Item value="food">Food</Select.Item>
  </Select.Content>
</Select>;

Translate the sentinel at your filter or submission boundary. Setting the root’s value to "" is useful for a placeholder; it is not an empty-valued item.

3. Radix controls do participate in native forms#

The visible controls are buttons or composite widgets, but Radix renders hidden native form controls for Select, Checkbox, Switch and RadioGroup. You do not need to add a second hidden input for each one.

Put the component inside the form, provide name, and use its own uncontrolled default prop. Native defaultValue is not a checkbox’s defaultChecked:

<form>
  <Select name="category" defaultValue="food">
    <Select.Trigger aria-label="Category">
      <Select.Value />
    </Select.Trigger>
    <Select.Content>
      <Select.Item value="food">Food</Select.Item>
    </Select.Content>
  </Select>
  <Checkbox name="receipts" value="yes" defaultChecked aria-label="Keep receipts" />
  <Switch name="alerts" value="yes" defaultChecked aria-label="Enable alerts" />
  <RadioGroup name="account" defaultValue="personal" aria-label="Account type">
    <RadioGroup.Item value="personal" aria-label="Personal" />
    <RadioGroup.Item value="business" aria-label="Business" />
  </RadioGroup>
  <button type="submit">Save</button>
</form>

new FormData(form) initially contains category=food, receipts=yes, alerts=yes and account=personal. Unchecked checkboxes/switches and disabled controls are omitted, following native successful-control rules. Without name, there is no submitted entry. Controlled components need onValueChange or onCheckedChange, not a native input’s onChange.

Keep visible labels in the real UI; the compact example uses accessible names to make the wiring explicit. RadioGroup is the export from radio.tsx, not a component named Radio.

4. Nested routes need segment-aware matching#

An ad hoc pathname.startsWith(href) can highlight both /transactions and /transactions/new, and incorrectly match /transactions-old.

<Navigation value={pathname} match="prefix">
  <Navigation.Link value="/" href="/">
    Home
  </Navigation.Link>
  <Navigation.Link value="/transactions" href="/transactions">
    Transactions
  </Navigation.Link>
  <Navigation.Link value="/transactions/new" href="/transactions/new">
    New
  </Navigation.Link>
</Navigation>

Prefix mode chooses the longest mounted segment-boundary match. It ignores query strings, hashes and trailing slashes. Exact matching remains the default. Prefix selection appears after links mount; use explicit active or a custom isActive(pathname, value) matcher when your router already knows the answer. Because links mount only in the browser, server-rendered HTML and the first paint carry no aria-current="page" or data-active in prefix mode; the active link appears after hydration. Server-rendered apps that need the current link in the server HTML should pass active on that link (or use isActive), which is resolved during render. An explicit active prop wins and is excluded from automatic prefix selection. Custom matchers are responsible for their own exclusivity.

5. Table owns horizontal scrolling#

Do not put a wide table in a mobile page and let the whole document overflow. Table now creates its own focusable, horizontally scrollable region:

<Table aria-label="Invoices" scrollLabel="Scroll invoices">
  <Table.Header>{/* headings */}</Table.Header>
  <Table.Body>{/* rows */}</Table.Body>
</Table>

The table’s ref, className, ARIA attributes and native props still target the inner <table>. The scroll region inherits its accessible name from aria-label or aria-labelledby; scrollLabel can override that name. You usually no longer need an extra overflow wrapper. Preserve the region’s keyboard focus indicator when customizing.

Once migrated, use the Updating guide to keep local changes when taking upstream fixes.