Reach for Radix? Fork shadcn/ui? Go with native web components? This is one of the most consequential decisions a frontend team makes, and the answer is less obvious than the framework evangelists suggest.
The build-vs-buy spectrum
It’s not a binary choice. The real spectrum looks like this:
- Use a CSS framework (Tailwind, Bootstrap). Fastest start, least control. You’re accepting someone else’s design decisions wholesale.
- Adopt a headless library (Radix, React Aria, Headless UI). Accessible primitives with tested interaction patterns. You own the visual layer.
- Fork and customise (the shadcn/ui model). Copy components into your codebase and modify freely. Full ownership, full maintenance burden.
- Build with web standards (native web components, Lit). Framework-agnostic components that work everywhere. Higher initial effort, broadest compatibility.
- Build from scratch (custom framework-specific library). Complete control over everything. Maximum effort, maximum maintenance cost.
Each step trades speed for control. The right choice depends on where your constraints actually are.
When headless primitives are the right default
For most React teams, headless primitives are the right starting point. Radix or React Aria give you accessible, tested interaction patterns (focus management, keyboard navigation, ARIA attributes) while you own the visual layer completely.
Choose this when:
- You have a design system with specific visual requirements but standard interaction patterns
- Accessibility is non-negotiable (it should be)
- You have a small frontend team that should be building product features, not reimplementing focus traps
- Your application runs in a single framework
The headless approach reduces the accessibility cost by an enormous margin. Building a fully accessible combobox from scratch, one that handles keyboard navigation, screen reader announcements, virtual scrolling, and RTL support, can take weeks. React Aria gives you all of that through hooks. Your team’s job becomes styling, not reinventing the wheel.
The limitation is framework lock-in. Radix is React-only. React Aria is React-only. If your platform spans multiple frameworks, or if you’re building micro-frontends where different teams might use different tools, headless React primitives won’t help the non-React parts.
The shadcn/ui model: control through ownership
shadcn/ui’s insight is that sometimes you want components in your codebase, not in node_modules. You copy the source, you own it, you modify it freely. No version management, no upstream breaking changes, no waiting for maintainers to merge your PR.
This model has exploded in popularity for good reason. It’s fast to get started, the code quality is high (it’s built on Radix and Tailwind), and it gives you complete freedom to diverge from the defaults.
Choose this when:
- You’re building a product where speed matters more than long-term component consistency
- Your team is small enough that component maintenance isn’t a dedicated function
- You need to iterate rapidly on component implementations
- Your design diverges significantly from any existing library
Be careful when:
- You have multiple applications that need consistent components. Copied code diverges over time. Without a shared package, each application ends up with its own slightly different version of every component.
- Accessibility updates in the upstream library don’t automatically flow to your copies. You’re responsible for tracking and applying those changes.
- You’re building for a platform with multiple brands. Copied components make brand theming harder because each brand needs changes applied to each copy.
When to build with web components
This is the path I’ve taken most recently, and it’s the right choice for a specific set of constraints that are more common than people realise.
Choose native web components when:
- Your components need to work across multiple frameworks. In a micro-frontend architecture where different teams might use React, Angular, Vue, or vanilla JavaScript, web components are the only component model that works everywhere without wrappers or adapters.
- You need true style encapsulation. Shadow DOM prevents style leakage in both directions. Your component styles don’t affect the consuming application, and the consuming application’s styles don’t break your components. This matters enormously when your components are consumed by teams you don’t control.
- You’re building for longevity. Web standards don’t have major versions. A web component built today will work in browsers ten years from now. Framework-specific components need to be migrated every time the framework ships a breaking change.
- You need brand theming through CSS custom properties. Web components and custom properties work naturally together: the properties pierce the shadow boundary, giving consuming applications a clean theming API without touching component internals.
The real costs:
- The development experience is more verbose than framework components. No JSX, no reactive templates (unless you use Lit), more boilerplate for lifecycle management.
- Server-side rendering support is limited. Declarative Shadow DOM is getting better but isn’t universally supported yet.
- The ecosystem of ready-to-use accessible web components is smaller than the React ecosystem. You’ll build more from scratch.
- Testing web components requires different tooling than testing React components. Your team needs to learn the testing patterns.
Despite these costs, web components have proven to be the right choice for our multi-brand, multi-team platform. The framework independence alone justifies the additional development effort.
The decision framework
When I’m evaluating this decision for a team, I ask three questions:
1. How many frameworks do your components need to support?
If the answer is one (usually React), use headless primitives. If the answer is more than one, or “we might change frameworks in the future,” web components deserve serious consideration.
2. How many teams will consume these components?
If fewer than three teams, the shadcn/ui fork model works well. Low coordination overhead, fast iteration. Above three teams, you need a proper shared package with versioning, documentation, and a contribution model. The fork model doesn’t scale because divergence across copies becomes unmanageable.
3. What’s your maintenance budget?
Every component you own is a component you maintain. Be honest about whether your team has the capacity for ongoing maintenance: accessibility updates, browser compatibility fixes, new feature requests, documentation, and support.
If the answer is “we don’t have dedicated component library time,” use headless primitives and style them. That’s not a compromise. That’s the correct engineering decision for your constraints.
Hybrid approaches
In practice, most mature teams end up with a hybrid. At one end, you have primitive components (buttons, inputs, icons) that you own completely because they’re core to your brand identity and used everywhere. At the other end, you have complex components (rich text editors, data grids, date pickers) where the interaction complexity justifies using a well-tested third-party implementation.
The key is being deliberate about where you draw the line. Own the components that define your product’s identity and where you need full control. Use existing solutions for the components where the interaction patterns are standard and the implementation is genuinely hard.
My recommendation
For a single-framework team building a product: headless primitives (Radix or React Aria) plus your own styling layer. You get accessible, tested foundations without giving up visual control. Best ratio of investment to outcome.
For a multi-framework platform or micro-frontend architecture: native web components. The upfront investment is higher, but framework independence and style encapsulation pay for themselves over time.
For a startup moving fast with a small team: shadcn/ui or similar fork model. Speed matters more than long-term consistency when you’re still figuring out what you’re building.
Start where your constraints are real. Build from scratch only when you hit a genuine limitation that the existing options can’t solve. You’ll know when that happens, and by then you’ll have the experience to build well.