Why We Still Hand-Code Component Systems (vs Tailwind UI / shadcn)
Every few months, a client asks us: "Why not just use Tailwind UI?" or "Can't we speed this up with shadcn/ui?"
Fair questions. Pre-built component libraries have gotten good. Tailwind UI ships beautiful, accessible components. shadcn/ui gives you copy-paste React components with Radix primitives underneath. For marketing sites, MVPs, or internal tools that'll never scale past 5 users, they're excellent choices.
But for the software we ship — 16 production CRMs serving hotels, law firms, pharma distributors, and logistics companies — pre-built libraries create more problems than they solve. Here's why we still hand-code our component systems, and when you should too.
The Integration Tax Nobody Talks About
Tailwind UI and shadcn look plug-and-play until you need them to do something they weren't designed for.
Last year, we built PharmaCare CRM. Pharmacies need a specific inventory interaction: scan a barcode, verify batch number, check expiry, update stock — all in under 3 seconds because the customer is standing there waiting. The UX requires:
- A barcode input that auto-focuses after each scan
- Batch number autocomplete that pulls from a 50,000-row table without lag
- Expiry date validation that accounts for regional regulatory windows (6 months before expiry = can't sell)
- Real-time stock updates that sync across 3-5 concurrent terminals
Tailwind UI's input components don't handle barcode scanner hardware events. shadcn's Combobox chokes on 50k options even with virtualization. Their DatePicker has no concept of regulatory compliance rules.
You can extend these libraries. But now you're:
- Fighting their internal state management
- Overriding styles that assume you want their design opinions
- Debugging interactions between your custom code and their component logic
- Maintaining a fork that breaks on every library update
The "time saved" evaporates. Worse, you're now stuck — too invested to rip it out, too constrained to move fast.
Control Over Composition
Pre-built libraries make decisions for you. That's the point. But in production software, those decisions cascade.
Tailwind UI components are closed compositions. You get the HTML structure they chose. Want to move the icon from left to right in a button? You're rewriting the entire component. Need a tertiary button variant? Hope they thought of that, or you're adding custom classes that fight their utility-first philosophy.
shadcn/ui is better — it's literally code you copy into your repo. But it still assumes:
- You want Radix UI primitives (great library, but heavy)
- You're using Tailwind with their specific config
- You're okay with their a11y approach (which is good, but not always aligned with specific WCAG 2.2 AA requirements we contract for)
- Your design language fits their structure
When we built LegalEase CRM, law firms needed:
- A document viewer that renders contract annotations inline
- A sidebar that collapses to 40px on large screens but stays full-width on tablets (not mobile-first, desk-first)
- A comment thread component that nests 8 levels deep with visual indicators for comment status (draft, reviewed, approved)
None of this maps to shadcn's primitives. We'd spend more time bending their code than writing our own.
Here's our component philosophy instead:
- Primitives, not compositions. We build
<Button />,<Input />,<Select />as atomic units with clear APIs. - Slots over variants. Instead of 47 button variants, we have slots:
iconLeft,iconRight,suffix. Compose them. - Context-aware defaults. Our
<DataTable />knows if it's inside a/crm/inventoryroute vs/erp/accountingand adjusts density, column priorities, and actions accordingly. - Feature flags at the component level. When we add a new interaction pattern (like the batch number autocomplete), we ship it behind a flag so 15 other CRMs don't suddenly have a refactor dependency.
This isn't free. We have a 47-component library that we maintain. But when a new CRM needs a specialized interaction, we extend primitives in hours, not days.
Bundle Size Is a Feature
Tailwind UI is CSS — negligible bundle impact. But shadcn pulls in Radix UI, which pulls in:
@radix-ui/react-dialog: 28kb@radix-ui/react-dropdown-menu: 34kb@radix-ui/react-popover: 29kb@radix-ui/react-select: 41kb
Add class-variance-authority, clsx, tailwind-merge, and suddenly you're at 180kb before you've written a single line of business logic.
Our hand-coded <Modal /> is 8kb. Our <Dropdown /> is 6kb. We don't need Radix's focus management because we built our own that handles our specific keyboard navigation requirements (we support Urdu/Arabic RTL, which Radix does... mostly).
For our AI agents that ship as embeddable widgets (the chatbot that goes on a hotel website, the appointment scheduler that loads in a clinic's patient portal), every kilobyte matters. A 180kb dependency is a non-starter.
When Pre-Built Makes Sense
We're not ideologues. Tailwind UI and shadcn are great for:
- Marketing sites — Beautiful, one-off pages where you're not maintaining long-term.
- Internal tools — If it's used by 3 people and the interaction patterns are standard CRUD, use shadcn and ship it today.
- Prototypes — When you need to validate a product concept, pre-built components are perfect.
- Teams without design systems experience — If you don't have someone who can architect a component library, shadcn is a much better starting point than chaos.
But if you're building:
- Software with custom interaction models (inventory, booking, scheduling, compliance)
- Products where performance is a competitive advantage
- Systems where accessibility requires specific implementations (healthcare, government)
- Multi-tenant SaaS where different customers need different UX density
...then hand-coding your component system is faster, cheaper, and more maintainable in the long run.
Our Stack
For context, here's what we actually use:
- Base styles: Tailwind (utility classes are great, pre-built components are not)
- Primitives: Hand-coded React components with TypeScript
- State: Zustand for global, React Context for component trees
- Accessibility: Manual ARIA implementation, tested with NVDA and JAWS
- Icons: Lucide (same as shadcn, no complaints there)
- Animation: Framer Motion where justified, CSS transitions otherwise
We ship this as a private npm package across all 16 CRMs and our modular ERP. When we add a feature to <DataTable />, every product gets it on their next deploy.
The Real Cost
Hand-coding component systems costs about 120-160 hours upfront to build the first 30 components. Then 8-12 hours per month to maintain and extend.
Using shadcn costs 0 hours upfront. Then 4-6 hours per sprint fighting customization edge cases, plus 16-20 hours every time you need a net-new pattern that doesn't fit their primitives.
Over 12 months, hand-coding is cheaper. Over 24 months, it's dramatically cheaper. And you own the system — no library updates that break your product the week before a client demo.
For TechNova's clients — SMBs running hotels, clinics, warehouses, law firms — software isn't a nice-to-have. It's the operational backbone. When HotelDesk goes down, front desk staff can't check guests in. When CargoTrack lags, shipments miss customs windows.
We hand-code component systems because the alternative is slower when it matters.