Skip to content

Rounding Polygon Corners

A UI principle for coding agents. Also covers rounded polygon, corner rounding, corner radius on paths, round svg path, tangent circle, bezier arc approximation, and 6 more.

Show all 12 aliases

rounded polygon, corner rounding, corner radius on paths, round svg path, tangent circle, bezier arc approximation, cubic bezier circle, control point distance, quadratic corner rounding, polygon smoothing, blob shapes, unit vector projection

Core Philosophy

  • SVG has no native rounding for polygon fill geometry. rx exists on <rect> only, and stroke-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 A arc command when you want an editable path or stylistic variants (hand-drawn wobble, softer non-circular rounding). A is fine when all you need is the plain circular look. svg-drawing-fundamentals owns the path command syntax; canvas-drawing explains why canvas arcTo disappoints 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 } };
}
  • mag is the edge length. unit is the same direction with length 1, so the point at distance t along the edge from from is { x: from.x + unit.x * t, y: from.y + unit.y * t }
  • Duplicate consecutive vertices make mag zero and the unit vector NaN. 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 r and corner angle α (the angle between the two edge rays leaving the vertex), the exact relation is d = r / tan(α / 2). At a right angle d equals r; sharper corners need a longer cut for the same radius, blunter corners a shorter one
  • Clamp d to 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 * d and T2 = B + u2 * d, where B is the vertex and u1, u2 are the unit vectors from B along 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°, but acos returns 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 distance d / 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 A flags 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 * k and C2 = T2 - u2 * k. The curve leaves T1 along edge one and arrives at T2 along 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 d string: use it in <path d={...} />, in a clip-path: path(...), or feed it to canvas through new 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 d exactly as the circular variant does (from the requested radius, clamped to half the shorter edge), then emit Q corner T2 with 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 tan on the drawing side, no acos, no arc formula. This is the same move as quadraticCurveTo on canvas
  • Circular rounding reads as engineered (consistent with border-radius elsewhere 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 produces NaN control points. Detect and emit a plain L, as the reference implementation does
  • Duplicate points. Zero-length edges make the unit vector NaN. The Number.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 r directly 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

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "rounding-polygon-corners" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems