The arc Command: Sectors, Donuts, and Rounded Edges

This article continues our deep dive into the shape() function, focusing exclusively on the arc command. As a reminder, at the time of writing (May 2025), shape() is supported in Chrome 137+ and Safari 18.4+. If you haven't yet, you should review the first part on lines and arcs first.

Building a Sector Shape

A useful and classic example is the sector shape, perfect for pie charts or progress indicators. Our goal is to create a filled portion of a circle controlled by a variable --v ranging from 0 to 100.

A series of three semi-circles.
Diagram showing the fixed and variable lengths of an arc shape.

To begin, we anchor our shape at the top of the element. From there, we draw an arc to a point we'll calculate, and then a line back to the center. Using keywords like top and center is fully valid here, similar to how they work in background-position. This keeps the code more readable than absolute numeric values alone.

.sector {
  --v: 35; /* [0 100]*/
  
  aspect-ratio: 1;
  clip-path: shape(from top, arc to X Y of R, line to center);
}

Since we're working with a square element and want the sector to fill it completely, the arc's radius must be 50%. For a square, this resolves correctly: 50% of the direction-agnostic size (the diagonal divided by sqrt(2)) is equal to 50% of the element's width.

The moving point on the arc is calculated with a bit of trigonometry based on our --v value:

X = 50% + 50% * sin(V * 3.6deg)
Y = 50% - 50% * cos(V * 3.6deg)
.sector {
  --v: 35; /* [0 100] */
  
  aspect-ratio: 1;
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--v) * 3.6deg)) of 50%,
    line to center);
}

You might notice the output isn't always correct. The problem isn't the math but the arc's size and direction arguments. If we try the combinations, we find that small cw works only when the value is less than 50. When the value surpasses 50, the arc flips, and we need large cw instead. This is because the two points of the arc change their relative position as the value changes.

The simplest fix is a rather elegant trick: give the element a border-radius: 50%. Even when the arc is wrong, it fills the extra area we want to clip, and the border-radius smoothly cuts away the excess. The box-shadow can still show the theoretical arc, but the main shape becomes correct.

There's one more edge case: when --v hits exactly 100, the two arc points converge, and the arc draws nothing. A practical workaround is to clamp the value at 99.99 so the arc never degenerates into a point.

.sector {
  --v: 35; /* [0 100]*/
  
  --_v: min(99.99, var(--v));
  aspect-ratio: 1;
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% large cw,
    line to center);
  border-radius: 50%;
}

Creating an Arc (Donut) Shape

Now we can create a hollow arc shape, similar to a donut or a progress ring. This builds directly on the sector shape, but we need to cut out the inner portion.

A series of three circular rings at various lengths.
Diagram showing the arc points of a semi-circle shape. There are two arcs, one on the outside and one on the inside. They are joined by straight lines.
.arc {
  --v: 35; 
  --b: 30px;
  
  --_v: min(99.99, var(--v));
  aspect-ratio: 1;
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw large,
    
    line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) 
            calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)),
    arc to 50% var(--b) of calc(50% - var(--b)) large
  );
  border-radius: 50%;
}

Instead of drawing a single line from the outer arc's endpoint to the center, we create a second, inner arc. We introduce a new variable --b to control the thickness. The logic is:

  1. Draw the outer arc (using cwd).
  2. Move to a point on the inner circle, which is offset by --b from the outer point.
  3. Draw an inner arc in the opposite direction (ccw) back to the top, reaching 50% var(--b).

The second arc's direction is inferred as ccw by default when not specified. But we again hit the same size/direction problem. Here, border-radius can't fix both arcs, so we need a conditional to choose between small and large based on --v. First, we normalize the value into a binary flag:

--_f: round(down, var(--_v), 50)

The round() function forces the value to a multiple of 50, resulting in either 0 (for values under 50) or 50 (for values above). This enables conditional logic in CSS. Since this article was written, CSS has introduced the if() function for inline conditionals, though support is early:

.arc {
  --v: 35;
  --b: 30px;
  
  --_v: min(99.99, var(--v));
  --_f: round(down,var(--_v), 50);
  --_c: if(style(--_f: 0): small; else: large);
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c),
    line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) 
            calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)),
    arc to 50% var(--b) of calc(50% - var(--b)) var(--_c)
  );
}

For better current support, we can use style queries. This requires a parent-child relationship, which is why the code uses a pseudo-element. Here, the default size is large, and we switch to small only when --_f is 0:

.arc {
  --v: 35;
  --b: 30px;
  
  --_v: min(99.99, var(--v));
  --_f: round(down, var(--_v), 50);
  aspect-ratio: 1;
  container-name: arc;
}
.arc:before {
  content: "";
  clip-path: shape(from top,
    arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
           calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c, large),
    line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) 
            calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)),
    arc to 50% var(--b) of calc(50% - var(--b)) var(--_c, large)
  );
  @container style(--_f: 0) { --_c: small }
}

Importantly, --_f must be registered with @property to work with both if() and style queries. This conditional logic fixes both arcs, so we can remove the border-radius workaround entirely.

Adding Rounded Edges

What if we want the ends of our arc to be rounded rather than blunt? This is a slight upgrade:

A series of three semi-circles with rounded edges at varying lengths.
clip-path: shape(from top,
  arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) 
         calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c, large),
  arc to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg))
         calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)) of 1% cw,
  arc to 50% var(--b) of calc(50% - var(--b)) var(--_c, large),
  arc to top of 1% cw
);

To make this work, you replace two of the line commands with arc commands. This turns the abrupt, flat ends into half-circles. There's a handy trick here: for these new arcs, we don't need to calculate an exact radius. We can give them a tiny arbitrary value like 1%, and the browser will automatically adjust the radius to the correct size to form a proper half-circle connecting the two points.

Drawing this out on paper helps visualize the four arcs involved: the outer large arc, the inner small arc, and the two half-circle caps that connect their endpoints.

Beyond the Basics

We've focused on circular arcs here—the trickiest part to master is managing the small/large and cw/ccw combinations as points shift. While it is mathematically possible to define elliptical arcs by specifying two distinct radii, this adds significant complexity. For most practical uses, circular arcs will serve perfectly well.