Radix UI

17 fragments · Layer 3 Synthesized high · 17 evidence · updated 2025-01-31
↓ MD ↓ PDF

Summary

Radix UI + shadcn/ui is the standard component stack across all active projects — six confirmed: asymxray, hazardos, eydn-app, stride-v2, orbitabm, and contentcommand. The most common sharp edge is the Select component's rejection of empty string values, which fails silently and requires "none" as the sentinel instead. The asChild pattern is non-optional when Radix primitives need to render as anchor tags or other non-button elements; skipping it produces broken link behavior that looks like a routing bug. Accessibility gaps (missing aria-label on icon-only buttons) accumulate quietly and tend to require a large-scale sweep to fix — in hazardos this was 45 files in a single commit.


TL;DR

What we've learned
- Radix Select silently misbehaves with value="" — use value="none" and handle the sentinel in your form logic
- asChild is required any time a Radix interactive primitive needs to render as something other than its default element (e.g., a Button that should be an <a>)
- Icon-only buttons accumulate missing aria-label attributes across codebases; plan a dedicated accessibility pass or enforce it in review
- Calendar from shadcn/ui requires both mode and initialFocus props when embedded in forms — omitting either causes broken date selection behavior
- Tailwind v4 compatibility breaks shadcn/ui base styles in two specific ways (see Failure Modes below)

External insights

No external sources ingested yet for this topic.


Common Failure Modes

Select component rejects empty string values

Radix UI's Select primitive does not treat "" as a valid value — the component either ignores the selection or behaves inconsistently when value is set to an empty string. This is not a runtime error; it fails silently, which makes it hard to diagnose from user reports.

Fix: use "none" (or any non-empty sentinel) as the unselected state, and map it back to null/undefined before submitting to your API.

// ❌ breaks silently
<Select value="">
  <SelectItem value="">No selection</SelectItem>
</Select>

// ✅ works
<Select value="none">
  <SelectItem value="none">No selection</SelectItem>
</Select>

// In submit handler:
const payload = { industry: formValue === "none" ? null : formValue };

Observed in asymxray during industry dropdown work.
[1]


When a Radix Button or similar interactive primitive wraps an anchor tag without asChild, Radix renders a <button> containing an <a> — invalid HTML that browsers handle inconsistently. The symptom looks like a routing failure or a click that does nothing, not a component misconfiguration.

Fix: pass asChild to the Radix primitive and put the <Link> (or <a>) as the direct child.

// ❌ renders <button><a>...</a></button>
<Button>
  <Link href="/forgot-password">Forgot password?</Link>
</Button>

// ✅ renders <a> with button styling
<Button asChild>
  <Link href="/forgot-password">Forgot password?</Link>
</Button>

Observed in asymxray on both the industry dropdown and the forgot-password link.
[2]


Calendar missing required props in form context

The shadcn/ui Calendar component requires mode and initialFocus when used inside a form (e.g., inside a Popover triggered by a form field). Without mode, the calendar renders but doesn't allow selection. Without initialFocus, keyboard navigation doesn't land on the calendar when the popover opens — a silent accessibility failure that also confuses mouse users who expect focus to move.

// ✅ correct usage inside a form popover
<Calendar
  mode="single"
  initialFocus
  selected={field.value}
  onSelect={field.onChange}
/>

Observed in hazardos when adding date fields to site survey forms.
[3]


Icon-only buttons missing aria-label accumulate silently

Radix Button and shadcn/ui Button don't enforce accessible labels. Icon-only buttons (trash, edit, close, etc.) ship without aria-label and pass visual review because they look correct. Screen readers announce them as unlabeled interactive elements. This compounds — in hazardos, a single accessibility pass touched 45 files to retrofit labels.

Fix: treat aria-label as required on any button whose visible content is only an icon. Enforce in code review, not after the fact.

// ❌ no accessible name
<Button variant="ghost" size="icon">
  <Trash2 className="h-4 w-4" />
</Button>

// ✅
<Button variant="ghost" size="icon" aria-label="Delete record">
  <Trash2 className="h-4 w-4" />
</Button>

Observed in hazardos (45-file sweep).
[4]


Tailwind v4 breaks shadcn/ui base styles in two ways

Upgrading to Tailwind v4 while using shadcn/ui breaks in two distinct places:

1. darkMode config format changed. Tailwind v4 requires a string, not an array:

// ❌ Tailwind v3 format — breaks in v4
darkMode: ["class"],

// ✅ Tailwind v4 format
darkMode: "class",

2. @apply no longer works for base styles in globals.css. Tailwind v4 requires direct CSS properties instead:

/* ❌ breaks in v4 */
@layer base {
  body {
    @apply bg-background text-foreground;
  }
}

/* ✅ works in v4 */
@layer base {
  body {
    background-color: hsl(var(--background));
    color: hsl(var(--foreground));
  }
}

Both issues appeared together in hazardos during the Tailwind v4 migration.
[4]


What Works

Radix + shadcn/ui as the full component foundation

Using shadcn/ui's Dialog, Button, Select, Checkbox, Tabs, Accordion, and Command components as the baseline — rather than custom implementations — has held up across six projects. The copy-into-repo model means components are fully owned and can be modified without fighting library constraints. Standardizing on this stack in asymxray replaced a mix of custom modals and form controls with a consistent pattern.
[5]

DataTable + Tabs + Accordion for data-heavy pages

For CRUD pages and dashboards with multiple entity types, combining shadcn/ui DataTable with Tabs for top-level navigation and Accordion for collapsible detail sections handles complexity without custom layout work. The Command component works well for searchable entity selection within these pages. Validated in hazardos (site survey office views, 20 files) and orbitabm (verticals CRUD page).
[6]

Combobox for entity selection in forms

The shadcn/ui Combobox (built on Radix Popover + Command) handles customer/entity selection in forms better than a plain Select when the list is long or searchable. The filtering is client-side by default, which is fine for lists under ~500 items. Observed in hazardos for customer selection in site survey forms.
[7]

Semantic color tokens + warm gray palette as design system foundation

Defining CSS custom properties for semantic colors (--background, --foreground, --primary, etc.) alongside a warm gray palette and custom shadow tokens gives shadcn/ui components a coherent visual identity without fighting the default styles. Applied in asymxray across 3 files (+1158/-102 lines) — the ratio of additions to deletions reflects how much the defaults needed to be overridden.
[8]


Gotchas and Edge Cases

Select value must be a non-empty string — no undefined either

The empty-string issue extends to undefined. If a Select's controlled value prop is undefined (e.g., before a form is populated from an API), the component can enter an uncontrolled state mid-session. Initialize form fields with "none" or a valid default, not undefined.

asChild composes props — it's not just a render swap

When asChild is used, Radix merges the primitive's props (event handlers, ARIA attributes, data-* attributes) onto the child element. If the child already has conflicting props (e.g., its own onClick), the merge behavior may not be what you expect. The Radix child's handler runs, but prop precedence follows Radix's merge order, not React's standard override rules.

shadcn/ui components are copied, not imported — updates are manual

Because shadcn/ui copies component source into your repo, upstream fixes (including accessibility patches and Radix version bumps) don't arrive automatically. When Radix releases a breaking change or a shadcn/ui component is updated, you have to manually diff and re-copy. This has not caused a documented incident yet, but it's a maintenance surface that grows with the number of components used.

Custom Tooltip vs. shadcn/ui Tooltip

In eydn-app, a custom Tooltip component was built without external dependencies rather than using shadcn/ui's Tooltip (which wraps @radix-ui/react-tooltip). The reason was specific accessibility control requirements. The shadcn/ui Tooltip works for standard cases, but if you need fine-grained control over focus behavior or delay timing, the Radix primitive directly (or a custom wrapper) gives more flexibility than the shadcn/ui abstraction.
[9]

Tooltip scale: 35 instances across 8 pages is manageable

In eydn-app, 35 contextual help tooltips were deployed across 8 dashboard pages in a single pass. At this scale, tooltip content becomes a content management problem — keeping tooltip text accurate as features change requires the same discipline as keeping docs current. No tooling exists for this yet.
[9]


Where Docs Disagree With Practice

Tailwind v4 @apply in globals.css

Tailwind v4 docs describe @apply as still supported, but in practice it fails for base layer styles in globals.css when used with shadcn/ui's CSS variable setup. The failure mode is that styles silently don't apply — no build error, no console warning. Direct CSS properties are the reliable path.
[4]


Tool and Version Notes



Sources

Synthesized from 17 fragments: git commits across asymxray, hazardos, eydn-app, stride-v2, orbitabm, and contentcommand. No external sources ingested. No post-mortems. Date range: unknown to unknown.

Fragments (17)