DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our Button has 696 call sites, and four of our five busiest public pages contain no button element at all

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

<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
}
Enter fullscreen mode Exit fullscreen mode

There is no tailwind.config.ts:

ls tailwind.config.*
# zsh: no matches found: tailwind.config.*
Enter fullscreen mode Exit fullscreen mode

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

  1. Add the type="button" test. It is the one finding here with a failure mode.
  2. 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.
  3. 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)