Primitives over raw elements
Button, Input and Select ship focus, disabled and invalid states.
From shadcn (Vercel plugin), Anti-patterns: raw elements where primitives exist.
Tab through the same form
Click Email, type abc, press Tab, then submit.
Raw elementsBad
Email
Subscribe
- Label focuses input
- Visible focus
- Button reachable by Tab
- Invalid state shown
Label + Input + ButtonGood
- Label focuses input
- Visible focus
- Button reachable by Tab
- Invalid state shown
<form onSubmit={onSubmit} noValidate>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" aria-invalid={!!error} aria-describedby="email-error" />
<p id="email-error">{error}</p>
<Button type="submit">Subscribe</Button>
</form>
Disabled has to mean disabled
Click each Save fast, then force Disabled and click again.
<div onClick>Bad
Save changes
onClick: 0
className={disabled ? "opacity-50" : ""}<Button>Good
onClick: 0
<Button disabled={pending}><Button disabled={pending} onClick={save}>
{pending && <Loader2 className="animate-spin" />}
Save changes
</Button>
Open a dropdown by keyboard
Tab to each trigger; press ↓, Enter, Esc or m.
div dropdownBad
Currency
USD · US dollar
<div onClick><Select>Good
Currency
<Select><Select value={currency} onValueChange={setCurrency}>
<SelectTrigger aria-label="Currency"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD · US dollar</SelectItem>
<SelectItem value="MXN">MXN · Mexican peso</SelectItem>
</SelectContent>
</Select>
Why it matters, pros and cons
Why it matters
A raw element starts with none of the states users rely on.
Pros
- States live in the component, not call sites.
- Roles and ARIA come from Radix.
- One token change restyles every ring.
Cons
- Heavier than native; native select suits mobile-heavy pages.
- Some need providers, like
TooltipProvider. - Radix and Base UI APIs differ; pick one.