CogniPrep's entire design system is one directory:
components/ui/
alert-dialog.tsx badge.tsx button.tsx card.tsx
dialog.tsx dropdown-menu.tsx form-input.tsx
fullscreen-dialog.tsx input.tsx label.tsx
nav-button.tsx password-input.tsx skeleton.tsx textarea.tsx
Fourteen files, five Radix dependencies, and each Radix package is imported by exactly one of them:
grep -rho "@radix-ui/[a-z-]*" --include="*.tsx" components | sort | uniq -c
# 1 @radix-ui/react-alert-dialog
# 3 @radix-ui/react-dialog
# 1 @radix-ui/react-dropdown-menu
# 1 @radix-ui/react-label
# 1 @radix-ui/react-slot
That encapsulation is the part I would keep: nothing outside components/ui knows Radix exists, so replacing a primitive is a one-file change. Four of the fourteen are not shadcn components at all (form-input, fullscreen-dialog, nav-button, password-input) and there is no select, no tabs, no tooltip and no table, because the app has three native <select> elements, all of them in the admin area, and no tabs or tooltips anywhere.
696 uses of one component
grep -rho "<Button" --include="*.tsx" app components | wc -l # 696
grep -rl "from '@/components/ui/button'" --include="*.tsx" app components | wc -l # 289
The component itself is cva with six variants and four sizes, a rounded-full pill base, plus two props that are not from the shadcn template:
/**
* Shows a spinner, blocks further clicks and marks the button busy for
* assistive tech. Use for any action that is not instant.
*/
loading?: boolean;
/** Label to show in place of `children` while `loading`. */
loadingText?: React.ReactNode;
So: one button, 696 call sites, a pending state built in. Now look at what a browser finds on the pages those call sites supposedly produce.
The live count
// on each page, after load
({ buttons: document.querySelectorAll('button').length,
anchors: document.querySelectorAll('a').length });
| Page | <button> |
<a> |
anchors wearing the Button classes |
|---|---|---|---|
/ |
10 | 98 | 7 |
/pricing |
0 | 36 | 7 |
/games/shl |
0 | 56 | 5 |
/blogs |
0 | 132 | 4 |
/about |
0 | 41 | 5 |
Four of the five have no <button> element in the document, and every call to action on them is an anchor carrying the button styling:
Sign Up -> /signup
Start Practising Free -> /signup?provider=shl
This is asChild doing its job. Button renders through Radix's Slot, so <Button asChild><Link href="..."> merges the styling onto the link and the DOM gets an <a>. Which is the correct answer to a question plenty of design systems get wrong: a control that changes the URL is a link, and it should be middle-clickable, copyable, focusable in document order and announced as a link. A <button onClick={() => router.push(...)}> is none of those.
The homepage's ten are the only real buttons on any of those pages, and they all belong to the two small interactive demos in the landing copy: four choices and a "next" control each. Everything else that looks pressable is a link. I only know that because I counted the DOM rather than the call sites: counting source files would have told me this site has 696 buttons, and the number a screen reader meets on /pricing is zero.
Two of those ten also turned out to be live examples of the next section's finding, since the "next" control in each demo carries no type attribute at all.
The 371 elements that skip the kit
grep -rho "<button" --include="*.tsx" app components | wc -l # 371
Where they live is the interesting bit:
| Directory | raw <button>
|
<Button> |
|---|---|---|
components/games |
322 | 357 |
components/admin |
9 | 43 |
components/landing |
4 | 8 |
components/shared |
2 | 7 |
app/dashboard |
0 | 11 |
322 of the 371 are inside the practice screens, where the kit's pill-shaped primary button is simply the wrong shape for a control that has to sit in a dense grid, and where a one-off className beats five props. I do not think that is a failure. The kit covers the chrome of the application, and the screens that are the product have their own visual language.
What is a failure is the attribute nobody set:
grep -rho "<button[^>]*" --include="*.tsx" app components | grep -c "type=" # 3
grep -rho "<button[^>]*" --include="*.tsx" app components | grep -vc "type=" # 368
<button> defaults to type="submit". Inside a <form>, any of those 368 would submit it on click, and with a React Server Action on the form that means firing a server mutation from a control meant to toggle a panel. I checked whether we have that bug today, and we do not: only two files in the repository contain both a <form> and a raw <button>, and in both the button sits outside the form element. So this is a latent hazard rather than a live defect, which is the most dangerous category, because it becomes a defect when somebody wraps something in a form for an unrelated reason.
The usual guard here is a lint rule, and this project deliberately has no ESLint, so the guard has to be a test: fail on any <button in the tree that does not carry a type. That is a few lines against a codebase that already has hundreds of tests asserting things about source text, and it is the only version that survives the next contributor.
The dead file at the top of it all
components.json is the shadcn CLI's configuration, and it still says this:
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
}
There is no tailwind.config.ts:
ls tailwind.config.*
# zsh: no matches found: tailwind.config.*
Tailwind v4 is CSS-first, so this project's theme lives in @theme blocks inside app/globals.css, and the base colour is not slate, it is a set of OKLCH tokens. The file is harmless right now because nobody runs the CLI, and it is a trap for whoever first does, because the component it adds will be written against a config that does not exist. Same genre as the other dead config in this codebase: a plugin configured for a bundler we replaced, an icon package listed for a dependency we never had. Nothing fails. The file just quietly stops describing the project.
What I would change
- Add the
type="button"test. It is the one finding here with a failure mode. - Delete or rewrite
components.json. If we want the CLI, point it at reality; if we do not, the file is a lie with a$schema. - Leave the 322 raw buttons alone, and leave the kit at fourteen files. A design system earns its next component when a second screen needs it, not when a checklist says a kit should have tabs.
See it
The table is reproducible in one DevTools line per page. The pricing page is the clearest case: every action on it is an anchor, so document.querySelectorAll('button').length is 0 while the page is covered in things that look like buttons. Compare the homepage, whose ten real buttons all belong to the two interactive demos in the copy, and a provider page, where the primary call to action carries the provider in its href so that signing up lands you in the right place. The blog index is the extreme of the ratio at 132 anchors and no buttons at all.
Top comments (0)