Open the codebase of any product that started out fine and looks ragged a year later, and you will find the same thing: a <button> here with px-4 py-2, a <button> there with px-5 py-2.5, a third one that someone wrapped in a <div> with a bg-indigo-600 and a hand-tuned box-shadow. None of them came from the same place. Each was built where it was needed, by whoever needed it, on the day they needed it. That is the disease. The symptom is that your app looks slightly off and you can't say why.
This is not a discipline problem. You are not going to fix it by trying harder to remember the right padding. Memory is the wrong tool. The fix is structural: you build the button once, give it a small set of options, and then nobody types padding into a button ever again. The interface stays consistent not because everyone is careful but because there is only one source of the truth.
Here is how it actually happens, and it's worth being honest about because it feels productive every single time.
You need a card on the dashboard. There's a card-ish thing on the settings page already, so you copy its markup, paste it, and tweak the padding because this content is a little tighter. Ship it. Two weeks later you need a card on the billing page. You copy the dashboard one now, because it's closer, and you bump the border radius because it looked nice on a dribbble shot you saw. Ship it. Now you have three cards. They have three paddings, two radii, and two slightly different shadows. No single decision was wrong. The aggregate is a mess.
Every copy-paste is a fork. A fork has no upstream. When you later decide that all cards should have a 12px radius instead of 8px, there is no cards to change — there are forty cards, scattered, each one a tiny island. So you don't make the change. The inconsistency calcifies because fixing it is now a migration, not an edit.
Every copy-paste is a fork, and a fork has no upstream.
I think of visual drift as the UI equivalent of entropy. Left alone, a system trends toward disorder. Order is the thing that costs energy to maintain. In code, the energy-saving move — the thing that keeps order without ongoing willpower — is to make the consistent choice the only available choice. You can't paste a slightly-wrong button if there is no markup to paste, only a component to call.
A quick diagnostic: search your codebase for the hex value of your brand color, or for rounded-lg, or for shadow-md. If it appears in dozens of unrelated files instead of in one or two component files, you don't have a component system — you have copies. The count is your debt.
Stop thinking of a UI component as "some markup I reuse." Think of it as a contract. A contract has terms, and both sides are bound by them. The caller agrees to only ask for things the component offers. The component agrees to handle everything inside those terms correctly — every variant, every state, every bit of spacing it owns.
A reusable component is a contract. It defines a fixed set of props — its variants and sizes — the states it handles internally, and the spacing it owns. Honor the contract and the UI stays consistent for free: callers can only ask for things that look right, and looking right is the component's job, not theirs.
A contract has three clauses, and a component is only finished when all three are written down in code.
Clause one: the props. These are the choices the caller is allowed to make. Not "any CSS" — a small, named menu. A button might offer variant (primary, secondary, ghost, danger) and size (sm, md, lg). That's it. There is no color prop, no padding prop, no style escape hatch. If a caller wants a green outline button with 11px of padding, the answer is no — that combination isn't in the contract, and the reason it isn't is that it doesn't belong in your UI.
Clause two: the states. A real interactive component is responsible for how it looks when it's hovered, focused, active, disabled, and loading. The caller should never have to think about focus rings or disabled opacity. The component handles its own states — that's part of what you're buying when you call it. This is the same discipline as The Four States Rule applied at the component level: a thing that can be in multiple states must define all of them, not just the resting one.
Clause three: the spacing it owns. This is the clause everyone forgets, so it gets its own section below. For now: a component owns its internal padding and never its external margin.
Here is the button contract, written out. Notice how little surface area it exposes.
type ButtonProps = {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
loading?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
const variants = {
primary: "bg-indigo-600 text-white hover:bg-indigo-700",
secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200",
ghost: "bg-transparent text-slate-700 hover:bg-slate-100",
danger: "bg-red-600 text-white hover:bg-red-700",
};
const sizes = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
};
export function Button({
variant = "secondary",
size = "md",
loading,
disabled,
className,
children,
...rest
}: ButtonProps) {
return (
<button
disabled={disabled || loading}
className={cn(
"inline-flex items-center justify-center rounded-lg font-medium",
"transition-colors focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-indigo-500 disabled:opacity-50",
variants[variant],
sizes[size],
className
)}
{...rest}
>
{loading ? "…" : children}
</button>
);
}
Read what the contract did. The hex values, the radius, the focus ring, the disabled opacity, the height, the horizontal padding — all of it lives in exactly one file. A caller writes <Button variant="primary">Save</Button> and gets a correct button, including a focus ring they didn't have to remember and a disabled state they didn't have to design. Note that the colors here are written inline for the example; in a real codebase those would point at your tokens — your CSS variables or Tailwind theme — so the contract honors Tokens as Constants instead of hardcoding indigo in two places. And notice the default variant is secondary, not primary — the contract makes the quiet button the easy one to reach for, which is how you keep One Primary Action per screen without policing it.
A component isn't markup you reuse — it's a promise that anything the caller is allowed to ask for will look right.
You do not need a design system with eighty components. You need a small handful of primitives — the irreducible parts you compose everything else from. Get these right and the rest of the app is assembly.
The set is smaller than people expect. For most SaaS apps it's roughly five:
Button — every action, every variant, in one place.Input — text fields, and by extension selects and textareas that share its look. Owns its border, focus ring, error state, and disabled state.Card — the bordered, padded container that holds a unit of content. Owns its radius, border, background, and internal padding.Stack — a layout primitive that lays children out vertically (or horizontally) with consistent gaps. This is how you stop typing mb-4 on everything.Box — the lowest-level primitive: a div that accepts your spacing and surface tokens but nothing arbitrary. Sometimes you don't need it if Stack and Card cover your cases; include it when you find yourself reaching for raw <div> with utility soup.That's it. From those five you build a settings page, a pricing table, a form, a dashboard. Bigger components — a Modal, a Table, a Toast — are themselves composed from these primitives plus a little structure. The discipline is bottom-up: nail the atoms, then the molecules come cheap.
Here's the Input contract, so you can see the pattern repeat. Same shape as the button: small named options, states owned internally, spacing owned internally.
type InputProps = {
invalid?: boolean;
} & React.InputHTMLAttributes<HTMLInputElement>;
export function Input({ invalid, className, ...rest }: InputProps) {
return (
<input
aria-invalid={invalid}
className={cn(
"h-10 w-full rounded-lg border bg-white px-3 text-sm",
"placeholder:text-slate-400",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500",
"disabled:bg-slate-50 disabled:opacity-60",
invalid ? "border-red-500" : "border-slate-300",
className
)}
{...rest}
/>
);
}
Two contracts in, and you already feel the rhythm. The height is h-10 on both the button and the input — not a coincidence, a decision, made once, so a button next to an input lines up perfectly. That alignment is the kind of detail that reads as "designed," and you got it for free by sharing a primitive.
The failure mode at the other extreme is building a 40-component library before you've shipped a single screen. Build the primitive the second time you need the pattern, not the first. The first time, write it inline. The second time, you know the real shape of the contract — extract it then. Premature abstraction is as expensive as no abstraction.
Once people taste reuse, they overshoot. The next mistake is the mega-component: one Card that grows a title prop, then subtitle, then headerAction, then footer, then collapsible, then headerIcon, then variant="bordered" | "ghost" | "elevated", until it has thirty props and a renderHeader callback and nobody can tell what it does without reading the source.
Configuration scales badly. Every new requirement adds a prop, props interact, and you end up with combinations nobody tested. collapsible plus footer plus headerAction — does that even render correctly? Who knows. The contract has become too big to hold in your head, which means it's no contract at all.
The fix is composition: small parts that snap together. Instead of a Card that takes a title prop, you expose Card, CardHeader, CardBody, and CardFooter, and the caller arranges them.
<Card>
<CardHeader>
<h3 className="font-semibold">Billing</h3>
<Button variant="ghost" size="sm">Edit</Button>
</CardHeader>
<CardBody>
<Stack gap={2}>
<p className="text-slate-600">Pro plan, $29/mo.</p>
<p className="text-slate-600">Renews June 30.</p>
</Stack>
</CardBody>
</Card>
Look at what this buys you. The header can hold anything — a title, a title plus a button, two buttons, an icon — without Card knowing or caring. There is no headerAction prop because there's a CardHeader you put a button in. The number of layouts is unbounded; the number of props is near zero. Each piece has a tiny, honest contract: CardBody owns the body padding, CardHeader owns the header padding and the border beneath it, and that's the whole agreement.
The rule of thumb: when you're tempted to add the fourth boolean prop to a component, you don't have a configuration problem — you have a missing sub-component. Split it. A component with a children slot and three small parts beats a component with a control panel.
When you reach for the fourth boolean prop, you don't have a configuration problem — you have a missing sub-component.
This is also why composition keeps drift out better than configuration does. A mega-Card with an elevated variant invites someone to add a shinier variant next quarter. Composed parts have nothing to bolt onto — the way you get a different card is by arranging the same honest pieces differently, not by minting a new visual exception.
Here is the most common reason "I have components but it still looks off." Two components both try to own the space between them, or neither does, so margins collapse, double up, or vanish unpredictably from page to page.
The rule is simple and you should make it law in your codebase: a component owns its inside, never its outside. A Card owns its internal padding. It does not own the margin below it. A Button owns its padding. It does not own the gap to the next button. Margin is a relationship between two elements, and no single element should unilaterally decide a relationship.
So who owns the outside? The layout owns it. The parent that arranges the children decides the gaps between them. And the cleanest way to express that is flex or grid with gap — not margins on the children at all.
This is exactly what the Stack primitive is for. It is a layout component whose entire job is to own the space between its children, using a value drawn from The 8-Point Grid so every gap is a multiple of 8 (or 4).
type StackProps = {
gap?: 1 | 2 | 3 | 4 | 6 | 8; // maps to 4px,8px,12px,16px,24px,32px
direction?: "vertical" | "horizontal";
children: React.ReactNode;
};
const gaps = { 1: "gap-1", 2: "gap-2", 3: "gap-3", 4: "gap-4", 6: "gap-6", 8: "gap-8" };
export function Stack({ gap = 4, direction = "vertical", children }: StackProps) {
return (
<div className={cn("flex", direction === "vertical" ? "flex-col" : "flex-row", gaps[gap])}>
{children}
</div>
);
}
Now spacing has an owner, and the owner is always the same kind of thing. You stop writing mb-4 on cards and start wrapping them in <Stack gap={4}>. The difference is that gap only puts space between children — never a stray margin on the last one, never a margin that escapes its container and fights with the parent's padding. The whole class of "why is there extra space at the bottom" bugs disappears because the child stopped having an opinion about its outside.
The contrast in practice:
State the law explicitly somewhere a teammate (or future you) will read it: children control padding; parents control gaps; margin is a last resort. When a component does need to push away from a neighbor, that's a signal the layout should be doing the job instead. Make the layout do it.
A component owns its inside; the layout owns the outside. Padding belongs to the part, spacing between parts belongs to the parent.
You don't need a design-system project. You need five files and one rule. Here's the order.
Open a scratch file and write down the five things you compose everything from: Button, Input, Card, Stack, and Box (or your project's real equivalents). If a primitive doesn't exist yet, you now have its filename. Success: a list of five, each mapped to a path under components/ui/.
Define the variant and size unions, the loading and disabled states, and move every hex value, radius, and padding into that one file pointing at your tokens. Success: every call site is <Button variant="…" size="…"> with no inline color or padding anywhere.
Search the codebase for a hand-tuned UI override — a stray style={…}, a one-off box-shadow, a button built from a raw <div>. Delete it and replace it with a call to the real component. Success: the search that found it now returns one fewer result, and the page looks the same or better.
Write the law in your repo: children own padding, layouts own gaps, margin is the exception. Then convert one screen from mb-* on children to a Stack with a gap. Success: no margins on the children of that screen, and the rhythm is unchanged.
Grep for shadow- and rounded- across the app. Every value that isn't a token or a primitive's internal choice is drift. Collapse the variants down to the two or three you actually meant. Success: those utilities appear almost exclusively inside your primitive files.
The point of all of this isn't elegance for its own sake. It's that a contract you can't violate is the only kind that survives a year of shipping under deadline. Discipline fades; a missing prop is forever.
Consistency you have to remember will rot; consistency you can't violate is permanent.