The shell should not own the padding.
A reusable container cannot know whether every child needs an inset or needs to reach the edge. Put spacing on a content region instead, then make the default easy to remove.
- frontend
- ux
- web-design
- design-systems
The first version of a card component is almost always beautiful.
It has a quiet border, a sensible radius, and enough padding to make a heading and two paragraphs feel expensive. The implementation is one line of layout code.
function Card({ className, ...props }: React.ComponentProps<"section">) {
return (
<section
className={cn("rounded-xl border bg-card p-6", className)}
{...props}
/>
);
}
Then the card gets used.
One screen needs a cover image that reaches the top and side edges. Another needs list dividers that run the full width. A dashboard puts a table inside it and loses precious mobile space. Someone adds -m-6. Someone else copies the component and calls it FlushCard. Six months later, changing the default padding feels like archaeology.
The border was never the problem. The padding was making a decision about content the card did not understand.
The happy path hides the coupling#
A generic card can hold almost anything. That is the useful part of accepting arbitrary children, and it is why the card knows so little about what belongs inside it.
A paragraph usually wants an inset. An image often wants to be full bleed. A table may need a padded heading and edge-to-edge rows. Each has a different relationship with the same boundary.
When Card puts padding on its root, it gives all of those children the same answer before it knows the question.
Card · p-4CardContent · p-4Both versions look the same.
Card · p-4Search is faster, filters persist, and shared views have a new home.
Read the changes →CardContent · p-4Search is faster, filters persist, and shared views have a new home.
Read the changes →The image wants the outer edge.
Card · p-4Thursday · 6:00 PM · Studio 04
CardContent · p-4Thursday · 6:00 PM · Studio 04
The heading wants an inset. The rows do not.
Card · p-474 GB of 100 GB used
CardContent · p-474 GB of 100 GB used
The text pair is why the mistake survives code review: both versions look the same. Media and data expose the difference. One architecture needs an exception; the other only adds space to the regions that need it.
Parent padding changes every child's available width and prevents anything from reaching the boundary. It is not just decoration. It is a layout decision imposed on unknown content.
Separate the shell from the inset#
A reusable card has at least two jobs that are easy to accidentally combine:
- The shell groups something visually with a background, border, radius, shadow, and clipping.
- The content region creates an inset around a known piece of content.
Those jobs can be two components.
function Card({ className, ...props }: React.ComponentProps<"section">) {
return (
<section
className={cn(
"overflow-hidden rounded-xl border bg-card text-card-foreground",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("p-6", className)} {...props} />;
}
The common case remains easy, but the component can now express a boundary without pretending everything inside it needs the same inset.
<Card>
<img className="aspect-video w-full object-cover" src={coverUrl} alt="" />
<CardContent>
<h2>Design systems meetup</h2>
<p>Thursday at 6:00 PM</p>
</CardContent>
</Card>
Nothing has to escape. The image is a direct child of the shell, so it reaches the shell. The copy is inside CardContent, so it gets padding. CardHeader and CardFooter can be additional content regions when their structure earns a name.
This is a small application of a broader rule: ownership should follow knowledge.
| Decision | Best-informed owner |
|---|---|
| Border, background, radius, clipping | Card shell |
| Inset around a known block of copy | CardContent |
| Gap between known form fields | Form or stack layout |
| Space between separate cards | Surrounding grid or page |
| Whether media is full bleed | Media slot or composition site |
A default should be a suggestion, not a trap#
Most card content should have padding. Keeping p-6 on CardContent makes the common case convenient without forcing that choice on every child of the shell.
The goal is not zero defaults. The goal is a cheap, intentional way out.
<CardContent className="p-0 sm:p-6">
<ProjectTable />
</CardContent>
When a component accepts utility classes, merge them with a cn helper backed by tailwind-merge. Then a consumer's p-0 can replace the default p-6 predictably instead of relying on class order.
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("p-6", className)} {...props} />;
}
For a larger design system, a named prop such as inset="none" or inset="sm" can make the supported choices more discoverable. A local component often needs nothing more than className and cn.
Negative margins are a symptom#
If edge-to-edge media needs -mx-6 -mt-6, the child has learned that its parent uses p-6. Change the parent spacing and the child has to repay a different amount. The code describes a debt to an ancestor instead of the layout we wanted: media at the shell edge.
If the exception is rare and the component comes from a library you cannot change, pay the debt and move on. If the pattern appears twice, improve the API.
Sometimes the parent really does know#
A button should own its internal padding. It controls its label and icons, and that padding contributes to the hit target. A badge, input, menu item, or tightly specified alert may also have a closed enough anatomy to set its own inset confidently.
Even a card can own padding when it is not actually generic. PricingPlanCard may render a fixed title, price, feature list, and action itself. It knows the content and can own the composition.
There is no ban on parents creating layout. A grid should place its children, and a stack should create gaps between them. Those components exist to describe a known relationship. The warning applies to generic surfaces that accept arbitrary children while silently assuming a particular kind of child.
Before adding padding to such a component, ask:
- Does this component know the structure it is spacing?
- Is full-bleed media, a scroll region, or an edge-to-edge list a plausible child?
- Can a consumer remove the inset without compensating for it somewhere else?
If the answers are “no,” “yes,” and “no,” the padding belongs on a content part.
The end user will never see CardContent. They will see the result: media that aligns cleanly, tables that remain usable on small screens, dividers that meet the intended edge, and fewer almost-identical cards scattered across the product.
This is a UX decision before it is a component-style preference. A flexible API makes the correct visual result cheap. Teams repeat what is cheap.
The shell should own the shell. The content should own the inset. The surrounding layout should own the gap. Defaults can guide all three, but none of them should become a trap.