Modular Frontend Development
30. August, 2024 • 8 min read • Teach
Components you own
Every component library I have adopted has eventually turned into a fight. You want a button that is 2px shorter, the library disagrees, and you end up with a stylesheet full of specificity hacks aimed at code you cannot read. shadcn/ui takes a different route: it hands you the source and gets out of the way.
That sounds like a small distinction. It changes almost everything about how the library behaves in a real project, and it is worth being precise about what it actually is, because a lot of write-ups (including one I nearly published) describe it wrongly.
What shadcn/ui actually is
shadcn/ui is not an npm dependency. There is no import { Button } from 'shadcn-ui'. You run a CLI, it copies TypeScript source files into your repository under components/ui/, and from that point on the code is yours. You import from your own project:
import { Button } from '@/components/ui/button';It is also not headless, and this is the description I see most often and which is simply wrong. The headless layer is Radix UI, which provides the unstyled behaviour: focus trapping, keyboard navigation, ARIA wiring, portal management. shadcn/ui is a set of opinionated Tailwind styles sitting on top of those primitives, plus a distribution mechanism. The components arrive styled, themed with CSS variables, and looking like something you could ship.
So the pitch is not “unstyled components you can style”. It is “a good default implementation, in your repository, that you can edit”. Those are very different products.
Why modular in the first place
The argument for breaking an interface into small, single-purpose components is not new and I won’t belabour it. Reuse, obviously. Components you can debug in isolation. A consistent visual language, so the fourth developer to add a form doesn’t invent a fifth shade of grey.
The one benefit I’d underline is that decoupling behaviour from presentation lets you change one without touching the other. A dropdown’s keyboard handling and a dropdown’s padding have no business living in the same commit, and most of the time in a hand-rolled component they do.
Getting started
The CLI was rewritten this month and the package moved from shadcn-ui to plain shadcn. If you follow an older tutorial you will see npx shadcn-ui@latest, which still resolves but is the deprecated name. Use:
npx shadcn@latest initRun it inside an existing project and it detects the framework. Next.js, Vite, Remix and Laravel all work out of the box now, which was the main point of the rewrite. Run it in an empty directory and it will offer to scaffold a new project first.
The prompts ask about your style preset, base colour and whether you want CSS variables for theming. Answers land in components.json at the root, which is the file the CLI reads on every subsequent command. It also records your import alias, so @/components/ui resolves correctly.
Then add a component:
npx shadcn@latest add buttonThat writes components/ui/button.tsx into your project and installs whatever it depends on, here @radix-ui/react-slot and class-variance-authority. It leans on the cn helper that init already put in lib/utils.ts. Open the file. It is about forty lines and you will understand all of them, which is not something I can say about most component libraries.
Extending the button
Here is where I disagree with most of the shadcn/ui examples floating around, including the ones I wrote first. The obvious move is to override the styles at the call site:
// don't do this
<Button className="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded">
Click me
</Button>It works. It also scatters your design decisions across every file that renders a button, and in six months nobody can answer “what colour is a primary button” without grepping.
The generated button.tsx defines its styles with class-variance-authority. Add your variant there instead:
// components/ui/button.tsx
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent',
brand: 'bg-brand-500 text-white hover:bg-brand-600',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);And then the call site says what it means:
import { Button } from '@/components/ui/button';
export default function MyButton() {
return (
<Button variant="brand" size="lg">
Click me
</Button>
);
}One vocabulary, one file, and a variant prop that is typed, so a typo is a build error rather than a button with no background.
The className prop still exists for the genuine one-offs, and it merges properly rather than fighting the base classes, because cn() runs tailwind-merge under the hood. Conflicting utilities get resolved instead of stacking.
Adding state
Nothing special is needed for behaviour. It’s your component:
'use client';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
export default function LoadingButton() {
const [loading, setLoading] = useState(false);
return (
<Button disabled={loading} onClick={() => setLoading(true)}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? 'Loading...' : 'Submit'}
</Button>
);
}lucide-react is the icon set shadcn/ui defaults to, and it comes along with the components that need it.
A dialog, not a modal
There is no Modal component in shadcn/ui, and npx shadcn@latest add modal will get you nothing. The component is called Dialog, and it is composed from several exports rather than being a single element with an isOpen prop.
npx shadcn@latest add dialog'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
export default function DeleteProjectDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Delete project</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This permanently deletes the project and everything in it.
</DialogDescription>
</DialogHeader>
<Button variant="destructive">Delete</Button>
</DialogContent>
</Dialog>
);
}The open state lives inside Dialog unless you lift it out with open and onOpenChange. asChild is a Radix idea worth learning: it tells the trigger to render its child instead of its own element, so you get a <button> with the trigger’s behaviour rather than a button nested inside a button.
DialogTitle and DialogDescription aren’t decorative. Radix uses them to wire up aria-labelledby and aria-describedby. Leave the description out and the console tells you off, which is the correct behaviour and mildly startling the first time.
What this buys you, and what it costs
The good parts are real:
- You own the code. The button that is 2px too tall is a file you can edit. No
!important, no wrapper component whose only job is to undo a style. - Accessibility is handled by Radix. Focus management, escape-to-close, scroll locking, keyboard navigation. Getting a dialog right by hand takes longer than people expect, and most hand-rolled ones are subtly wrong.
- It composes. Because they’re plain React components with no framework of their own, you can wrap, extend and combine them without the library having anticipated it.
- It reads as Tailwind. If your project already uses Tailwind, nothing new arrives in the stylesheet.
The cost is the same fact from the other side. You own the code, so you own the maintenance. There is no npm update that brings you upstream fixes. When shadcn/ui improves a component, you go and look at the diff yourself, and if you have edited that file, you merge it by hand. For a design system you intend to keep for years, that is fine and arguably what you wanted. For a team that wants to install a dependency and forget about it, it is the wrong tool and will feel like one within a quarter.
Growing it into a design system
The natural progression is to stop treating components/ui/ as vendored code and start treating it as your library. Add your variants to the generated files. Add your own components alongside them using the same cva pattern so the shape stays consistent. Point your Tailwind theme at the CSS custom properties in globals.css and change the whole palette from one place.
At that point the question of whether shadcn/ui is a dependency stops making sense, which I think is the intent.
I have not yet used the custom registry feature that arrived with the CLI rewrite, where you serve your own components over a URL and add them the same way. That looks like the missing piece for sharing a design system across several repositories without publishing a package, and it is what I want to try next 🙂
‘Till next time!