UI local library: best practices
A UI component library is the one part of a project you build once, and the reuse it on similar stack projects. The only requirement is a shared base framework, Next.js in this case, and the argument holds unchanged if that framework happens to be Nuxt on Vue instead.
That is also why it is worth building deliberately rather than letting it accumulate. A library that travels between projects has answered a handful of questions on purpose: which base library it starts from and how much of that base you know, how the variants are handled, how positioning and sizing works or is it handled by the parent wrapper, and most importantly for performance, how much of it needs to run in the browser at all. Answer those once and the next project starts with a proficient toolbox. Leave them to accumulate and the next project starts with a folder.
Why this extra code is worth adding
Every decision below is cheap while you are writing the component and expensive once thirty files import it. A size cap takes ten minutes to add today. After the component is everywhere it takes an afternoon.
The cost of skipping them is quiet, which is exactly what makes it expensive. A component library behaves like a set of promises, and most of them never appear in the type signature: the space a component assumes it will occupy, the parent it needs around it, who gets to decide what it looks like. When one of those breaks nothing throws. The component renders, the build passes, and something on the page is subtly wrong until somebody notices weeks later.
Choosing a base library
The first decision is not which library looks best, it is how much of it you want to know about and learn how to use.
- shadcn/ui. Not a dependency at all. A CLI copies the component source into your project, built on Radix primitives with Tailwind and
cva. You own the code, so restyling is editing a file and no upgrade can change your buttons overnight. The cost is the same coin flipped: nothing updates itself, upstream fixes never reach you, and a team that copies components without reading them ends up with a folder nobody understands. - Radix UI. Primitives only: behaviour, keyboard handling and accessibility, with no styling whatsoever. It is the strongest foundation on this list if you care about a dialog that traps focus correctly and a dropdown that behaves on a phone. The cost is that you start from zero visually, which a project with a tight deadline and no designer will feel immediately.
- Material UI. The largest and most mature set, with pieces you will not find elsewhere, a production grade data grid among them. The cost is weight and identity: it ships a CSS-in-JS runtime, the bundle is substantial, and the Material look is hard to remove rather than merely restyle.
- Chakra UI. The most pleasant of the five to build with, with accessible defaults and style props that make layout fast. But style props scatter design decisions across your JSX, which is precisely what a design system exists to prevent. And version 3 rewrote the styling engine, so a good deal of the tutorials and forum answers you will find, no longer apply.
- Ant Design. The right answer for an internal tool or an admin panel, where you need tables, filters, date pickers and forms that already work correctly. The wrong answer for a marketing/brochure site: the visual identity is strong, the bundle is heavy, and almost nothing it is best at is what a marketing page needs.
For our escenario, a CMS driven marketing site, the sensible default is Radix for behaviour with shadcn for the starting styles, because what we need most is control over the look and what you least want is a dependency that can change your buttons on a Tuesday. The question is never which library is better, it is whether this project needs visual control or component coverage.
Handling variants
The most common way a design system stops being a system: one component passes className="px-4 py-2 bg-primary-600 text-white" to a button. It works, it looks right, and now two files decide what a primary button is. Multiply that by a year and the button component is decorative, because every caller overrides it.
The fix is to make variants data instead of class strings. The component declares its own combinations once, and the caller picks by name. Nothing outside the component ever names a colour or a padding value again.
import { cva, type VariantProps } from 'class-variance-authority'
const chipVariants = cva(
'inline-flex w-fit items-center gap-1.5 border font-bold uppercase tracking-[0.12em]',
{
variants: {
variant: {
solid: 'border-transparent bg-primary-600 text-white',
outlined: 'border-current bg-transparent',
ghost: 'border-transparent bg-transparent',
},
size: {
sm: 'px-2 py-0.5 text-[10px]',
md: 'px-3 py-1 text-[11px]',
lg: 'px-4 py-1.5 text-xs',
},
},
defaultVariants: { variant: 'solid', size: 'md' },
},
)
export type ChipProps = VariantProps<typeof chipVariants> & { label: string }
// And then on the component:
<Chip variant="outlined" size="sm" label={category.label} />class-variance-authority is the default for good reasons. It is tiny, it types the variants for you through VariantProps, and it does nothing else. Three alternatives are worth knowing about before you commit.
- tailwind-variants. A superset of cva from the HeroUI team. It adds slots, so a single definition can style several elements of the same component, plus responsive variants and composition through
extend. It also resolves Tailwind class conflicts internally, which cva leaves totailwind-merge. Worth the switch the moment a component has more than one styled element, like a card with a header, a body and a footer. - Panda CSS. A different bet entirely: zero runtime CSS-in-JS with recipes built in, from the Chakra team. It only makes sense if you are not already on Tailwind, because it replaces it rather than sitting on top of it.
- vanilla-extract recipes. The same idea for projects using vanilla-extract. Type safe, compiled away at build time, and tied to that ecosystem.
- CSS Modules with data attributes. The no library option:
data-variant="solid"on the element and one selector per variant in the stylesheet. Zero dependencies and it works with any styling approach, but you write the types yourself, so nothing stops a typo from reaching production.
Whichever you pick, keep accepting className and merge it last. Without that escape hatch a caller cannot place the component in a layout. The rule that keeps it honest: `className` is for layout, never for appearance. Margins, widths, grid placement, yes. Colours, padding, typography, never again.
Sizing, and testing it mobile-first
Who owns the size is the question almost nobody asks out loud. The answer is not a preference, it falls out of a single test: does the correct value of this dimension depend on where the component is? An icon is 20 pixels whether it sits in a button, a table or a toast. A card's width depends entirely on the grid it was dropped into. The component owns the dimensions that do not depend on context, and the parent owns the ones that do.
Size the component should own:
- Icons. Fixed pixel values, never fluid. An icon that scales with its container becomes a smudge at one end and a billboard at the other, and it stops lining up with the text beside it.
- Avatars. A small set of fixed tokens (32, 44, 64). A circular avatar that grows with its column looks broken next to a name that does not.
- Spinners and loaders. Fixed, and usually smaller than you think. A 44 pixel spinner inside a 130 pixel cell reads as a loading screen rather than a detail.
- Button and input height. The height is a touch target before it is a style, so it belongs to the component. Width is a different question and usually belongs to the parent.
- Badges, chips and tags. They hug their content by nature. Give them
w-fitand let the text decide. - Logos. Fixed height, automatic width. The other way round distorts them the moment the aspect ratio of the file changes.
Size the parent should own:
- Cards. The grid decides the width. A card that sets its own is a card that cannot be reused in a different layout.
- Sections and CMS blocks. Full width by definition. The page, not the block, decides the container and the gutters.
- Grids and lists. They fill what they are given and distribute it internally.
- Images inside content. Width from the parent, aspect ratio from the component.
- Modals and drawers. The exception that proves the rule: they size against the viewport, not the parent, because they escape the layout entirely.
For these, a fixed width is not a safeguard, it is a bug waiting for the first responsive breakpoint. Let them fill and let the layout decide.
Test it mobile first, not mobile last
Responsive work usually means building the desktop layout and then squeezing it, which is exactly why the phone version always feels like a compromise. Start at the narrowest width you actually support instead. It forces the component to earn every element it has, and widening a design is far easier than shrinking one.
The one that catches the most people is 100vh. On mobile it means the viewport with the browser chrome hidden, so a section sized that way is taller than the screen until you scroll, and the thing you wanted visible at the top sits just below the fold. 100dvh is the fix, and testing in a simulator will never show you the problem.
Use 'use client' only when it is needed
A component needs the directive when it uses state, an effect, an event handler or a browser API. That is the whole list. Reading props, mapping over an array, rendering markup and composing other components are all things a server component does perfectly well, and most of a brochure site is exactly that. Rendered on the server, those components ship no JavaScript at all.
So push the boundary as deep as it will go. A card that triggers a loading state on click does not need to be a client component: its markup is static and only the handler is interactive, so the handler goes into a small client leaf and the card stays on the server. The test is mechanical. Remove the directive, see what breaks, and if what breaks is one small piece, extract that piece instead of giving up and marking the whole file.

