BlogWritingmigrations that happen
Forty-eight identical components
Three apps, each with its own copy of the same 49 shadcn primitives. Extracting them into one package was mostly mechanical. The two interesting parts were the one component that was not identical, and the stylesheet that silently lost two thirds of itself.
- Date
- Reading time
- 7 min
- Status
- Draft
01 The starting point
Three Vite and React apps, generated from the same scaffold at different times, each carrying src/components/ui with 49 shadcn primitives: accordion, alert-dialog, button, calendar, card, and so on down the list. One hundred and forty-seven files doing the same job three times. A fix to the select had to be made three times, and in practice was made once.
The first question was not how to extract them but whether they were still the same. A diff across the three copies answered it: 48 of the 49 were byte-for-byte identical. The 49th was the button.
02 The extraction
The 48 moved into packages/ui, with the two shared hooks (use-toast, use-mobile) and the cn class helper. One hundred and thirty-nine local files were deleted.
The package exports by subpath, mirroring the old import paths one for one:
"exports": {
".": "./index.ts",
"./*": "./src/components/*.tsx",
"./hooks/use-toast": "./src/hooks/use-toast.ts",
"./lib/utils": "./src/lib/utils.ts"
}
So @/components/ui/card became @portfolio/ui/card, and nothing else about the call site changed. Each app kept a thin src/lib/utils.ts that re-exports cn from the package, so existing @/lib/utils imports kept working too.
The acceptance test was simple: adopting the package must change nothing visually. Since every moved file was identical to the one it replaced, that held by construction. The point of the exercise was not to change the apps. It was that a fix to a tooltip now lands in all three at once.
03 The component that was not identical
The button had diverged in each app, and the differences meant something. The German learning app had added hero, soft and article variants. The health tracker had added tertiary, success and icon. Their base radius differed too: rounded-lg, rounded-xl, rounded-md. These are not accidents of copy-paste; they are product decisions encoded in a component.
The rule that came out of this: flatten where divergence is accidental, keep local copies where divergence carries meaning. The package ships the plain base button. The two product apps keep their own button.tsx until the variants are unified deliberately rather than by whichever copy happened to win the merge.
Two more primitives followed the button out of the package for the same reason: alert-dialog and calendar render through buttonVariants, so an app that keeps its own button keeps those too. Identical source, different dependency.
04 Where divergence was improvement
Divergence is not always drift. Two of the health tracker's components had moved in a strictly better direction. Its dialog tracks the mobile visual viewport through a --vvh variable and respects safe-area insets, which matters on a phone with a keyboard open. Its chart validates colour values before writing them into inline CSS.
Those two became the package's versions. The health tracker adopted them unchanged, and the other apps gained the improvements the moment they imported them. The diff told that story too: in the built CSS, the health tracker was byte-identical before and after; the other two apps dropped nothing and gained exactly the seven utilities the mobile-aware dialog needs.
05 The stylesheet that lost two thirds of itself
This is the part worth the post. Tailwind only generates CSS for class names it finds in the files listed in content. The package's components ship class names, so Tailwind has to be told where they live.
The obvious place to say so is once, in the shared preset:
content: [
'./src/**/*.{ts,tsx}',
path.join(__dirname, '../*/src/**/*.{ts,tsx}'),
],
It built. It typechecked. It looked fine at a glance. And every shared component had shipped with no CSS at all. Tailwind does not reliably honour content globs declared only in a preset; the globs it trusts are the ones in the app's own config. The built stylesheet had shrunk by two thirds, and nothing in the toolchain had said a word.
What caught it was not looking at the apps. It was diffing the built CSS, rule by rule, against the previous commit. The fix is one line in each app's tailwind.config.ts:
content: ["./src/**/*.{ts,tsx}", "../../packages/*/src/**/*.{ts,tsx}"],
The lesson generalises beyond Tailwind. When a refactor is supposed to be behaviour-neutral, verify it by comparing outputs, not by inspecting inputs. A diff of the artefact is the only test that cannot be fooled by a build that succeeds for the wrong reason.
06 Small things that bit
- The package needed its own
reactand@types/reactdev dependencies. Without them TypeScript checked the components with no JSX types and reported phantom prop errors on every file. - The package got its own ESLint config. shadcn code breaks two rules on purpose (
react-refresh/only-export-components, unused parameters in forwarded refs); those are scoped off forsrc/componentsrather than disabled globally. - Hook import paths broke when the files moved;
../hooks/use-toastis not@/hooks/use-toast. Mechanical, but it is where a careless extraction leaves runtime errors behind a green build.
07 What comes next
The button is the keystone. The sequence from here:
- Agree one base button whose radius and shadow come from the brand tokens.
- Move the app variants that survive review into the package.
- Delete the local
button.tsx,alert-dialog.tsxandcalendar.tsxand point the apps at the package.
Until then the package holds 48 components and the apps hold the one that still carries their differences. That is a more honest state than a package of 49 with three sets of variants nobody chose.
The repo is public at github.com/Akash-M/portfolio; the package is under packages/ui, and its README keeps the table of what stayed local and why.