Skip to main content
ASoc
Tutorial

React UI Libraries: When Hand-Rolling 91 Components Actually Wins

This storefront ships zero UI-library dependencies across 91 hand-rolled components. The real tradeoff — bundle cost against accessibility coverage you skip rebuilding — measured against our own code.

The ASoc Team7 min read

A React UI library (MUI, Chakra, Ant Design, Radix, shadcn/ui) buys you pre-built, accessible components fast; hand-rolling buys you a smaller bundle and total control over every pixel, at the cost of building and maintaining the primitives yourself. This storefront picked hand-rolled — 91 components, zero UI-library dependencies — and the real numbers behind that choice are the differentiator most "best React UI libraries" roundups don't have, because none of them ship a production site either way.

Library or hand-rolled: the axes that actually decide it

AxisUI library (MUI, Chakra, Ant Design…)Hand-rolled (this repo)
Time to first componentMinutes — import and styleHours — write it, test it, document the variant API
Bundle costThe library's runtime, on top of React — often 30-100+ KiB gzipped before you use a single componentZero beyond what you write; no dependency to audit or update
Design consistencyEnforced by the library's theme systemEnforced by convention and code review — nothing stops a one-off <button> unless someone catches it
Accessibility baselineBuilt in — focus traps, ARIA roles, keyboard nav tested upstreamYou own it, and you'll re-derive bugs the library already fixed (see react-modal's cross-origin-iframe case)
Visual customizationFights the theme system past a point; deep overrides get verboseTotal — the component is your code
Long-term maintenanceSomeone else patches it; you absorb their breaking changes on upgradeYou patch it; nobody else's roadmap decides your migration schedule
Onboarding a new engineerLearn the library's API and conventionsLearn this codebase's conventions — smaller surface, less transferable

If you read one row: bundle cost. It's the row a component-count audit of this actual repo can put a real number against, which the rest of this post does.

What "hand-rolled" looks like at 91 components

This repo's component tree, counted directly rather than estimated:

LayerCountRole
Atoms6Container, Button, SectionLabel, SectionHeading, Logo — no business content, pure and prop-driven
Molecules41Small compositions of atoms — cards, form units, nav items
Organisms33Full page sections, each owning its data mapping
Templates11Page-level composition, no content of its own
Total91

Zero of them import from @mui/material, @chakra-ui/react, antd, @radix-ui/*, or any other component package — package.json has no UI-library dependency at all, grep-verified against this exact checkout. Every button, card, modal and dropdown in the 8 main routes and 111 product pages is this repo's own code.

Here's the smallest atom, in full, because it's short enough to make the actual tradeoff concrete:

// src/components/atoms/Button.tsx
export type ButtonVariant = "primary" | "outline" | "dark";

const base =
  "inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-base font-medium shadow-xs duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";

const variants: Record<ButtonVariant, string> = {
  primary: "bg-primary text-white hover:bg-primary-600",
  outline: "border border-stroke-tertiary bg-white text-text-color hover:bg-gray-50 hover:text-gray-800",
  dark: "bg-gray-900 text-white hover:bg-gray-800",
};

export default function Button({ variant = "primary", href = "#", external = false, className = "", children, ...rest }) {
  return (
    <a
      href={href}
      className={`${base} ${variants[variant]} ${className}`.trim()}
      {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
      {...rest}
    >
      {children}
    </a>
  );
}

Thirty-some lines, a closed set of three variants, one focus-visible ring baked in once. That last part is the honest cost line: a library's Button ships a correct focus ring, a documented ARIA contract, and a maintainer who fixes edge cases you haven't hit yet. This one has a focus ring because someone wrote it in, and only in the states this codebase actually needed. react-modal — a separate post on this blog — documents a real bug this project hit in exactly that gap: a cross-origin iframe stealing focus out of a hand-rolled modal, the kind of edge case a library like Radix's Dialog had already solved.

The consistency question a library answers for free

A UI library's theme system is a forcing function: reach for <Button>, and you get the house style, because there's no undecorated <button> sitting one keystroke away. Hand-rolling has no such guardrail built in — this repo's version of it is the Atomic Design layering rule from its own architecture doc: molecules receive content via props and may only import atoms; organisms may import molecules, atoms, and their one data file; nothing imports sideways within a level. That rule is enforced by code review and import direction, not by a compiler — a stray inline <a className="..."> styled to look like a button would pass next build today. A library makes that mistake structurally harder to make; hand-rolling makes it a discipline you keep paying for.

The bundle number that makes "zero dependencies" concrete

react-dom itself is this app's unavoidable floor — 221 KiB raw / 70 KiB gzipped, measured against this site's own build and published in Astro vs Next.js for a marketing site. Every UI library adds to that floor before a single button renders: MUI's core plus Emotion routinely lands 80-120 KiB gzipped once a real app's usage is tree-shaken; Ant Design's default import is worse; utility-first libraries built for tree-shaking (Radix primitives, shadcn/ui's copy-in-your-repo model) land much closer to zero, which is the same bet this repo made — own the code, ship only what's used. That overlap is why shadcn vs. Tailwind is the wrong comparison to run: one is built on the other, and the decision that actually matters is where the client boundary lands.

The number this repo can't publish is the one a library would have cost here specifically, because nobody built the same 91 components twice to measure it. Treat every general library-size figure above as the vendors' own published numbers, not a controlled comparison against this exact app — the same caveat this blog's Supabase-vs-Firebase post states about the side it didn't build.

Where a library is the better call, plainly

  • A small team shipping fast with a generic admin or dashboard UI. Data tables, date pickers, and form components are exactly what a library front-loads, and building them from scratch is real weeks of work for something a library already tested.
  • An app needing WCAG-correct components across the board, on a deadline. Radix, React Aria and MUI have accessibility engineering hours behind them a small team can't match before launch.
  • A design system that already targets a library's theming primitives. Fighting a library's theme layer costs more than adopting one that fits.
  • Anywhere consistency has to survive contributors who don't know the codebase. A library's import boundary is a hard wall; a hand-rolled convention needs someone enforcing it in review, every time.

Mistakes and how they show up

MistakeWhat happensFix
Hand-rolling without an atomic layering ruleEvery new page invents its own button/card, and consistency erodes silentlyDefine atom/molecule/organism boundaries in writing, and enforce import direction
Adopting a library "for one component"The whole theme runtime ships even if you use one <Dialog>Check what the library's tree-shaking actually achieves before assuming it's free
Assuming a hand-rolled component matches a library's a11y coverageFocus traps, ARIA roles and edge cases (iframes, RTL, reduced motion) go unhandled until a real bug surfacesTest against the same edge cases the libraries publicly document fixing
Comparing bundle sizes without tree-shaking either side"Library X is 40 KiB" or "hand-rolled is 0 KiB" are both meaningless without a real buildMeasure your actual bundle, both ways, before deciding
No focus-visible state on a hand-rolled interactive elementKeyboard users lose visible focusBake it into the base class once, as this repo's Button atom does

Frequently asked questions

Is hand-rolling components a good default for a new project? Not automatically. It's the right call when the design is genuinely custom (a pixel-faithful marketing site, like this one, gains nothing from a generic component theme) and the team is small enough that a shared convention document actually gets read. For an internal admin tool with a generic UI, a library usually wins on time-to-ship.

Does Tailwind CSS replace the need for a component library? No — Tailwind is a styling layer, not a component layer. It styles both approaches equally; the decision here is about who writes and maintains the component's structure, states and accessibility behavior, which Tailwind doesn't provide either way.

What's the actual risk of zero UI-library dependencies? Every accessibility edge case, browser quirk and keyboard-navigation bug becomes this codebase's bug to find and fix, on this codebase's timeline, instead of a maintained package's changelog. react-modal's cross-origin-iframe finding is a real example of that cost landing.

Can you mix a UI library with hand-rolled components? Yes, and it's common — use a library for complex primitives (data tables, comboboxes, date pickers) where building from scratch is genuinely expensive, and hand-roll the simple, highly-branded pieces (buttons, cards, section headers) where the library's theme would fight your design anyway. Nothing about the atomic-design layering above requires every atom to be hand-rolled — an imported primitive can sit at the same layer as Button if it's wrapped behind this repo's own prop API.

Templates built the same way

ASoc Till is a POS-system landing page built from the same 91-component system — no UI-library dependency, every interactive element hand-rolled to the house style. ASoc Amplify is a social-media-marketing landing page, and ASoc Beacon a mobile-device-management landing page — both proof that a hand-rolled atomic system scales across genuinely different product categories without forking the component set.

Browse the full set of Next.js landing page templates, or the Tailwind landing page templates. For the accessibility edge case a library would have caught first, see React Modal.

Keep reading

Tutorial9 min read

Robots.txt and Sitemap.xml in Next.js: One Declared Date, 420 Routes

The STOREFRONT_COPY_REVISED fix for a sitemap that told Google 111 pages changed on a date nothing did, plus the noindex-vs-disallow split this codebase actually uses.

Read more