You round a card at 16px. You round the thumbnail inside it at 16px too, because that's the design token. Then you squint at it and something is off — the gap between the two curves looks fatter at the diagonal than it does along the straight edges, and the inner corner reads as slightly pinched.
Your eyes are right. Two rounded rectangles only look parallel when their radii differ by exactly the distance between them.
The rule
inner radius = outer radius − gap
Enter fullscreen mode Exit fullscreen mode
Where gap is the distance from the outer box's border edge to the inner box's border edge: border-width + padding.
The reason is just where the arc centres land. Put the outer box's top-left corner at (0, 0) with radius R: its corner arc is a quarter circle centred at (R, R). Inset the child by p on both sides and give it radius r, and its arc is centred at (p + r, p + r).
Those two centres coincide only when p + r = R, i.e. r = R − p. Concentric arcs, constant p gap the whole way round the curve. Any other value and the gap swells or collapses at 45°.
In CSS
Derive it instead of hardcoding two numbers that will drift apart the first time someone changes the padding:
.card {
--radius: 16px;
--pad: 8px;
padding: var(--pad);
border-radius: var(--radius);
}
.card > img {
border-radius: calc(var(--radius) - var(--pad)); /* 8px */
display: block;
width: 100%;
}
Enter fullscreen mode Exit fullscreen mode
Clamp it, and mind the unit on the zero
When the padding exceeds the radius, calc() produces a negative length. Negative border-radius is invalid, so the whole declaration is thrown away and you inherit whatever was there before — usually not what you wanted, and silent. Clamp it:
border-radius: max(0px, calc(var(--radius) - var(--pad)));
Enter fullscreen mode Exit fullscreen mode
Note it's 0px, not 0. Inside a CSS math function you can't compare a unitless number against a length; max(0, calc(...)) is invalid and gets dropped exactly like the negative value you were trying to avoid.
Borders count as gap
border-radius applies to the border box, and the browser already narrows the curve of a border's inner edge by the border width for you. But your child element sits border-width + padding in from the parent's border box, so both go into the subtraction:
.card {
--radius: 16px;
--border: 1px;
--pad: 12px;
border: var(--border) solid #d4d4d8;
padding: var(--pad);
border-radius: var(--radius);
}
.card > .inner {
border-radius: max(0px, calc(var(--radius) - var(--border) - var(--pad)));
}
Enter fullscreen mode Exit fullscreen mode
Forgetting the border width is why 1px-off corners survive review — at 1px it's subtle, at 3px on a chunky outlined card it's obvious.
Uneven padding needs an ellipse
Horizontal padding is often bigger than vertical. One radius value can't stay parallel on both axes when the gaps differ, so give the corner two: a horizontal radius and a vertical one. The shorthand takes them either side of a slash.
.card {
--radius: 20px;
--pad-x: 12px;
--pad-y: 6px;
padding: var(--pad-y) var(--pad-x);
border-radius: var(--radius);
}
.card > .inner {
/* horizontal / vertical */
border-radius:
max(0px, calc(var(--radius) - var(--pad-x)))
/
max(0px, calc(var(--radius) - var(--pad-y)));
}
Enter fullscreen mode Exit fullscreen mode
Strictly, the true parallel curve of a circle is another circle, not an ellipse — but this keeps both arc centres on the same point, and that's what reads as correct. It's how most design systems handle it.
The one that quietly breaks the math
The spec has an overlap rule: if the two radii on any one side add up to more than that side's length, the browser computes a scale factor f = min(side length ÷ sum of that side's two radii) across all four sides, and if f < 1 it multiplies every corner radius by f.
So border-radius: 40px on a chip that's 60px tall doesn't render at 40px. Each vertical side needs 40 + 40 = 80px of radius in a 60px space, so f = 0.75 and every corner comes out at 30px.
Your inner element never hears about this. It's still computing calc(40px - 12px) = 28px from the specified value, while the parent is actually drawing 30px. The gap is now wrong on every corner, and the DevTools computed style shows 40px, because the scaling happens at used-value time.
Two ways out:
- Keep every radius at or below half the shortest dimension of the box, so
fis always1and your arithmetic holds. - If you actually want a pill, don't derive anything — set a large radius on both boxes and let the rule do its job:
.pill { border-radius: 999px; }
.pill > .dot { border-radius: 999px; }
Enter fullscreen mode Exit fullscreen mode
Both get clamped to their own half-height, which is exactly the concentric result you'd have calculated by hand.
Checklist
-
inner = outer − (border-width + padding), derived withcalc(), never two hardcoded tokens - Clamp with
max(0px, …)— unit on the zero, or the declaration is invalid - Different horizontal and vertical gaps → use the
horizontal / verticalslash form - Keep radii ≤ half the shortest side, otherwise the browser rescales all four and your derived value silently drifts
- Pills are the exception: set both to a big number instead of subtracting
Once the arc centres line up, nested cards stop looking subtly cheap and you stop nudging values by 1px hoping it'll click.
I keep a small free border radius generator around for when I want to eyeball per-corner and elliptical values before committing them to tokens.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.