Core Philosophy
- SVG has no native rounding for polygon fill geometry.
rxexists on<rect>only, andstroke-linejoin="round"softens just the painted stroke join, never the fill outline underneath. Rounding an arbitrary polygon is something you compute, not something you toggle - The whole problem reduces to two skills: placing a point on a line at a chosen distance from another point (unit vectors), and drawing a circular arc with cubic Bézier curves. Master those two and every corner of every polygon follows the same recipe
- The rounded corner is the circle tangent to both edges meeting at the vertex. The radius the caller asks for is a request; the geometry of each corner decides the maximum it can honour
- Keep the sharp polygon as the data model and round at render time. Same principle as approximating a circle with Béziers: the approximation is for the eye, computations stay on the pure shape
- Emit cubic Bézier curves rather than the
Aarc command when you want an editable path or stylistic variants (hand-drawn wobble, softer non-circular rounding).Ais fine when all you need is the plain circular look. svg-drawing-fundamentals owns the path command syntax; canvas-drawing explains why canvasarcTodisappoints at larger radii. This doc owns the geometry both of them lean on
Unit vectors do all the positioning
Every construction below is "start at this point, walk along this edge by that distance". Unit vectors make that a multiply and an add, with no ratio bookkeeping:
type Point = { x: number; y: number };
function vec(from: Point, to: Point) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const mag = Math.hypot(dx, dy);
return { dx, dy, mag, unit: { x: dx / mag, y: dy / mag } };
}
magis the edge length.unitis the same direction with length 1, so the point at distancetalong the edge fromfromis{ x: from.x + unit.x * t, y: from.y + unit.y * t }- Duplicate consecutive vertices make
magzero and the unit vectorNaN. Dedupe the point list before rounding, or every downstream number silently rots - The perpendicular (normal) is
{ x: -unit.y, y: unit.x }. Corner rounding itself never needs it, but it is the same one-liner that positions arrowheads, offsets and outlines
Clamp the cut distance, then derive the radius
At each vertex the arc replaces the corner between two tangent points, one on each edge. Call the distance from the vertex to each tangent point the cut distance d.
- For a desired radius
rand corner angleα(the angle between the two edge rays leaving the vertex), the exact relation isd = r / tan(α / 2). At a right angledequalsr; sharper corners need a longer cut for the same radius, blunter corners a shorter one - Clamp
dto half of the shortest adjacent edge:d = min(r / tan(α / 2), edge1 / 2, edge2 / 2). Half, because the neighbouring corner is entitled to round its own end of the same edge. Without this clamp, two generous radii on one short edge overlap and the path folds over itself - After clamping, derive the radius the corner actually gets:
r = d * tan(α / 2). Clamp the distance, not the radius; the distance is what collides with neighbours - This is why "radius 80" on a small polygon quietly renders smaller arcs on its short edges: each corner honours as much of the request as its edges allow, independently
The tangent circle
With d settled, everything about the arc is known:
- Tangent points:
T1 = B + u1 * dandT2 = B + u2 * d, whereBis the vertex andu1,u2are the unit vectors fromBalong each edge - Corner angle:
α = acos(u1 · u2), the dot product of the two unit vectors. At a reflex (concave) vertex the polygon's interior angle exceeds 180°, butacosreturns the smaller angle between the rays, which is exactly the one the fillet needs; the formulas never see a reflex value - Radius:
r = d * tan(α / 2). The centre sits on the angle bisector at distanced / cos(α / 2)from the vertex, though drawing with Béziers never needs the centre explicitly - Because the whole construction is anchored on the vertex and its two edge directions, concave (reflex) vertices need no special case and no sweep-direction bookkeeping. The tangent points and control points land on the correct side automatically, one concrete reason Béziers are less fiddly than
Aflags here
The arc as a cubic Bézier
A circular arc cannot be represented exactly by Bézier curves, but the standard approximation is far below visible error for corner-sized arcs:
- The arc spans
θ = π - α(the exterior angle: a right-angle corner needs a 90° arc) - Control point distance:
k = (4 / 3) * tan(θ / 4) * r. This is the generic form of the "circle from n curves" formula(4 / 3) * tan(π / (2n)) * r, with one curve covering angleθ - Each control point sits on its own edge, pulled from the tangent point back toward the vertex:
C1 = T1 - u1 * kandC2 = T2 - u2 * k. The curve leavesT1along edge one and arrives atT2along edge two, which is exactly what tangency means - One cubic per corner is enough. Two curves stay visually faithful up to roughly a 300° arc, and four make a full circle; a corner arc never exceeds 180°, so a single curve is comfortably inside the accurate range
Reference implementation
Walk the vertices, emit a line to each corner's first tangent point, then the cubic across the corner. Close with Z:
export function roundPolygonCorners(points: Point[], radius: number): string {
const n = points.length;
if (n < 3) return "";
// NaN and negative radii have no geometric meaning: treat them as 0, which
// falls through to sharp corners below. Infinity is fine as it stands; the
// half-edge clamp turns it into "round as much as possible".
const target = Number.isNaN(radius) ? 0 : Math.max(0, radius);
const path: string[] = [];
for (let i = 0; i < n; i++) {
const corner = points[i];
const e1 = vec(corner, points[(i + n - 1) % n]);
const e2 = vec(corner, points[(i + 1) % n]);
const angle = Math.acos(
Math.min(1, Math.max(-1, e1.unit.x * e2.unit.x + e1.unit.y * e2.unit.y)),
);
// Straight-through or degenerate vertex: keep it sharp. The NaN check
// catches duplicate points (zero-length edge, NaN unit vector), which
// would otherwise slip past both comparisons and poison the path string.
if (Number.isNaN(angle) || angle < 1e-4 || Math.PI - angle < 1e-4) {
path.push(`${i === 0 ? "M" : "L"} ${corner.x} ${corner.y}`);
continue;
}
const cut = Math.min(target / Math.tan(angle / 2), e1.mag / 2, e2.mag / 2);
if (cut < 1e-9) {
path.push(`${i === 0 ? "M" : "L"} ${corner.x} ${corner.y}`);
continue;
}
const r = cut * Math.tan(angle / 2);
const k = (4 / 3) * Math.tan((Math.PI - angle) / 4) * r;
const t1 = { x: corner.x + e1.unit.x * cut, y: corner.y + e1.unit.y * cut };
const t2 = { x: corner.x + e2.unit.x * cut, y: corner.y + e2.unit.y * cut };
const c1 = { x: t1.x - e1.unit.x * k, y: t1.y - e1.unit.y * k };
const c2 = { x: t2.x - e2.unit.x * k, y: t2.y - e2.unit.y * k };
path.push(`${i === 0 ? "M" : "L"} ${t1.x} ${t1.y}`);
path.push(`C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${t2.x} ${t2.y}`);
}
return `${path.join(" ")} Z`;
}
- The implementation above emits full float precision to keep the listing short. In production, round coordinates to two or three decimals before building the string; full precision doubles the path length for zero visual gain
- The output is a plain
dstring: use it in<path d={...} />, in aclip-path: path(...), or feed it to canvas throughnew Path2D(d)
The cheap variant: quadratic rounding
When the corner just needs to look soft and nobody will measure the radius, skip the trigonometry:
- Derive the cut distance
dexactly as the circular variant does (from the requested radius, clamped to half the shorter edge), then emitQ corner T2with the original vertex as the lone control point - The result is a parabolic corner, slightly tighter than the circular arc for the same cut, so the requested radius is a size hint here, not a measured radius. No
tanon the drawing side, noacos, no arc formula. This is the same move asquadraticCurveToon canvas - Circular rounding reads as engineered (consistent with
border-radiuselsewhere in the UI); quadratic rounding reads as slightly organic. Pick one per shape system and stay with it
Pitfalls
- Collinear vertices. An interior angle within epsilon of 180° sends
tan(α / 2)toward infinity and producesNaNcontrol points. Detect and emit a plainL, as the reference implementation does - Duplicate points. Zero-length edges make the unit vector
NaN. TheNumber.isNaN(angle)guard keeps the path valid by leaving such corners sharp, but both vertices sharing the position stay sharp too, since their shared edge has no length to cut into. Dedupe first if those corners should still round - Clamping the radius instead of the cut. Two corners sharing a short edge each reason about their own end. The half-edge clamp on the cut distance is what keeps them from colliding; clamping
rdirectly does not compose across corners - Rounding already-rounded output. The function takes the sharp polygon. Feeding its own output back in treats tangent points as vertices and shrinks the shape every pass
- Animating the radius. Recomputing the path per frame is O(number of vertices) of cheap arithmetic and is fine for interaction-driven morphs. Keep the vertex count stable while animating so the path commands stay compatible for interpolation