Every web project we take on lives or dies by its UI foundation. Choose too rigidly opinionated a component library and you spend half the engagement fighting its defaults. Build everything from scratch and you're reinventing the wheel, poorly, every time. For the last several years, shadcn/ui has been our answer to that tension, and the more we use it, the more convinced we are it's the right call.
This isn't a neutral feature overview. It's an honest look at why shadcn/ui fits the way we work, how we architect components in real projects, and what makes it hold up under the kind of scrutiny that matters: accessibility, long-term maintainability, and testability.
What shadcn/ui Actually Is, and Why That Matters
First, the thing that trips people up: shadcn/ui isn't a component library you install as a dependency. It's a collection of components you copy into your own codebase, and that distinction changes everything. Run the CLI, pick what you need, and the component source lives in your project. You own it. You can read it, modify it, extend it, or delete it without waiting on a package maintainer.
Under the hood, each component is built on Radix UI primitives, unstyled, accessible building blocks that handle the hard interaction logic, and styled with Tailwind CSS utility classes. That layered model means you get the best of both worlds: interaction behavior that's been battle-tested across thousands of projects, and visual styles that are completely yours to control.
For us, that ownership model aligns directly with how we build for clients. We don't hand off black boxes. We hand off code that a developer can open, read, and reason about on day one.
Ease of Use, Get It, Configure It, Ship It
Adding a shadcn/ui component to a project is genuinely fast. The CLI handles the heavy lifting, it adds the component file, installs any required Radix primitives, and wires up the necessary Tailwind configuration. For most components, it's a single command:
“npx shadcn@latest add button”
That gets you a fully functional, accessible Button component with variants, sizes, and all the Tailwind class structure in place. From there, you customize it to match your design system, adjusting tokens, adding variants, or extending props, without having to touch any library internals.
Consistent defaults, easy to override
The components ship with sensible defaults that look good out of the box, which is useful for prototyping quickly. But the real benefit shows up in production: because the component is plain TypeScript with Tailwind classes, overriding anything is just editing code. There's no specificity battle with a third-party stylesheet, no !important hacks, no theme configuration buried in a provider stack.
Combined with Tailwind CSS v4's CSS-native configuration, the entire design token system, colors, spacing, typography, border radii, lives in a single CSS file and flows through every component automatically. Adding a new brand color or tweaking a size scale is a one-line change with project-wide effect.
Built for Reuse, Component Architecture Patterns We Rely On
Reusability is where shadcn/ui really earns its place. Because each component is a standard React component that you own, we can build structured patterns on top of it without friction. Here's how we typically organize things across a project:
Three-layer component hierarchy
- Primitive layer (shadcn/ui base), The raw components pulled in via the CLI. We rarely use these directly in pages.
- Design system layer, Wrappers that apply project-specific variants, tokens, and constraints. This is where a client's brand personality lives. A PrimaryButton or CardSection at this layer wraps the shadcn primitive and enforces design decisions.
- Feature/composition layer, Components assembled from the design system layer that represent real UI patterns: a ContactForm, a PricingCard, a DashboardStatRow. These are what routes and pages consume.
This hierarchy keeps things clean over time. Changes to brand color or button radius happen at the design system layer and propagate everywhere. Business logic stays in feature components. Primitive behavior stays in the shadcn base, and if shadcn ever ships an updated component, the upgrade path is clear.
Composition over configuration
shadcn/ui is built around React's composition model, not configuration props. A Dialog isn't controlled by a single isOpen prop passed to a monolithic component, it's composed from Dialog, DialogTrigger, DialogContent, DialogHeader, and DialogTitle pieces that you arrange yourself. That composability means you can build exactly the layout you need without fighting a rigid template.
For complex UIs, data-heavy dashboards, multi-step forms, contextual menus, that flexibility matters a lot. We've never run into a design requirement that the composition model couldn't accommodate cleanly.
Accessibility from the Ground Up, Radix UI and Base UI
Accessibility is the area where shadcn/ui provides the most quiet, underappreciated value. Because it's built on Radix UI primitives, a wide range of accessibility behaviors come standard, and they come correct.
- Focus management, Dialogs, sheets, and popovers trap focus within their bounds when open and restore focus to the trigger on close. No custom focus trap logic needed.
- ARIA attributes, Roles, aria-expanded, aria-haspopup, aria-controls, and related attributes are set and updated automatically by Radix as state changes.
- Keyboard navigation, Select menus, dropdown menus, tabs, and radio groups all respond correctly to Arrow keys, Enter, Space, Escape, and Home/End, matching ARIA Authoring Practices Guide patterns.
- Screen reader announcements, State changes in components like checkboxes, switches, and accordions are communicated to assistive technology without extra wiring.
It's worth being honest about what this covers and what it doesn't. Radix handles interaction accessibility, the widget behavior layer. Semantic structure, page-level landmarks, heading hierarchy, and accessible copy are still our responsibility. But that's exactly the right division of labor: we focus on the architecture and content decisions that require human judgment, and Radix handles the primitive interaction patterns that would otherwise take significant time to implement and test correctly.
Base UI as a forward-looking alternative
It's also worth noting that the shadcn/ui team has been incorporating Base UI, a newer primitives library from the MUI team, as an alternative backing layer for select components. Base UI brings similar accessibility guarantees with some architectural differences in animation handling and composition patterns. We're watching its maturation closely, and it reinforces the broader point: shadcn/ui is not tightly locked to a single primitives vendor. The abstraction holds.
Bulletproof Testing, Why Owned Components Are Easier to Test
One of the less-discussed advantages of the shadcn/ui model is how well it plays with testing. Because the components live in your codebase as plain TypeScript, they're first-class citizens of your test suite, no special mocking, no peer dependency conflicts, no waiting on a library update to expose the internals you need.
Component unit tests with Testing Library
We test shadcn/ui-based components with Vitest and React Testing Library, querying by accessible role, label, and text rather than CSS selectors or implementation details. Because Radix wires up ARIA roles correctly, queries like getByRole('dialog'), getByRole('button', { name: 'Submit' }), and getByRole('menuitem') work exactly as expected, no custom test IDs required for standard interactions.
That's a meaningful win. Tests written against semantic roles and accessible names are inherently more resilient to refactors than tests written against class names or DOM structure. When we restyle a component or reorganize its internal markup, the tests continue to pass as long as the accessible interface stays the same, which is exactly the right contract.
Integration and end-to-end coverage
At the integration level, shadcn/ui components behave predictably in Playwright end-to-end tests for the same reason, Radix emits the correct ARIA state, so page.getByRole selectors work reliably across browsers. Dialogs open and close on cue, dropdowns respond to keyboard navigation, and form validation states are reachable without fragile locator workarounds.
The result is a test suite that covers real user behavior, not implementation internals, and stays green through visual and structural changes. That's the kind of test coverage that actually gives you confidence when shipping.
The Bottom Line
shadcn/ui occupies a genuinely useful position in the UI landscape: it's opinionated enough to give you a strong starting point, and flexible enough to get out of the way when your design diverges from the defaults. The ownership model keeps the codebase honest. The Radix foundation handles accessibility correctly so we can focus on the things that require human judgment. The composition model adapts to complex designs without compromise. And the plain TypeScript components are straightforward to test the way tests should be written, against accessible behavior, not implementation details.
If you're building a new web product and evaluating UI foundations, it's worth a serious look. And if you want to talk through how it might fit your project specifically, we're always happy to dig into the details.
Work With Us
Have a project in mind?
We build the web’s most demanding applications. Let’s talk about yours.