This article was translated from Japanese by AI and may contain inaccuracies. For the most accurate content, please refer to the original Japanese version.
グランドピアノのイラスト

Building Responsive Shapes with the CSS shape() Function

Shapes built with clip-path's path() are hard to make responsive because its coordinates are fixed pixels. shape() defines curves with percentages, CSS units, and custom properties. This article builds a resizable speech bubble and a motion path.

When you want to clip a card or an image into a shape such as a speech bubble, you can use the CSS clip-path property. For example, applying circle(50%) to a square image clips it into a circle centered on the image.

.avatar {
  inline-size: 160px;
  aspect-ratio: 1;
  object-fit: cover;
  clip-path: circle(50%);
}

As this shows, clip-path leaves the size of the element itself unchanged and simply stops anything outside the given shape from being painted. Circles and polygons can be expressed with circle() and polygon(), but complex outlines that combine rounded corners and curves used to require the path() function.

The coordinates inside path(), however, are implicitly treated as CSS pixels. That means you cannot use % or custom properties to make the outline follow the size of the element. Because the coordinates of the path stay the same when the element's width changes, responsive components end up with an outline that no longer matches the element.

The CSS shape() function lets you describe lines and curves much like an SVG path, but using CSS values such as %, rem, calc(), and custom properties. Instead of the fixed coordinates of path(), you get an outline that follows the size of the element. This article walks through the syntax of shape() and shows how to build a speech bubble that can be resized, as well as an organic shape made of several curves.

Clipping elements with clip-path

clip-path is a property that specifies which region of an element gets painted, using a clipping path. For example, the following code clips an element into a triangle.

.triangle {
  inline-size: 240px;
  block-size: 160px;
  background: #6750a4;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

Because the coordinates of polygon() accept %, the three vertices stay at the top center, the bottom right, and the bottom left even when the element is resized. On the other hand, polygon() can only draw straight lines, so it cannot handle shapes with rounded corners or curves. For outlines with arbitrary curves, path() — which takes SVG path data — has been the tool of choice.

The following is a speech bubble with rounded corners and a downward tail. M moves to the starting point, H and V draw horizontal and vertical lines, Q draws a quadratic Bézier curve, L draws a straight line, and Z closes the path — all standard SVG path commands.

.bubble {
  inline-size: 320px;
  block-size: 180px;
  background: #6750a4;
  clip-path: path(
    "M 20 0 H 300 Q 320 0 320 20 V 140 Q 320 160 300 160 H 190 L 170 180 L 150 160 H 20 Q 0 160 0 140 V 20 Q 0 0 20 0 Z"
  );
}

This path produces the intended bubble on a 320 × 180px element. Every coordinate in it is a fixed value, though, so the outline does not follow along when the element is resized.

path() does not follow the size of the element

Take the bubble above and change only the width of the element to 560px. The coordinate of the right edge stays at 320. The box of the element itself grows, but the painted outline stays locked inside its original 320px range. Make the element narrower than 320px and the opposite happens: the right side of the path spills outside the element. Since the part that spills out is never painted, the rounded corners on the right are lost and the shape ends up squared off.

The reason comes down to the value path() accepts. path() takes an SVG path data string. Per the CSS Shapes specification, the numbers inside that string are implicitly treated as px units. Ratios such as 50%, or expressions such as calc(100% - 20px), cannot be written at all.

The other limitation is that the whole path is a single string. You cannot drop a var() into the middle of that string, as shown below, to make just the corner radius configurable.

/* var() is not expanded as part of the path() string */
.bubble {
  --radius: 20px;
  clip-path: path("M var(--radius) 0 ...");
}

shape() solves this by assembling the path data out of CSS syntax. The specification describes each command as being converted into a path segment at paint time. In other words, shape() is not a fixed path but a recipe that produces the final path from the reference box and the CSS values around it.

Note

The CSSWG also discussed not introducing shape() at all, and instead adding CSS-flavored syntax to path(). The argument was that extending an existing function makes it easier to discover, and that it would be consistent with how CSS's circle() and polygon() relate to SVG elements. On the other hand, shape() generates its path in combination with the CSS environment: the reference box, custom properties, font size, and so on. Since the same declaration can yield a different path in a different environment, it plays a different role from path(), which stays close to SVG. In February 2025 the CSSWG resolved that shape() keeps its current name and design, while a more tightly scoped CSS-flavored syntax would be considered separately for path().

The syntax of the shape() function

shape() takes a starting point followed by one or more drawing commands separated by commas. A simple triangle can be written like this.

.triangle {
  clip-path: shape(from 50% 0, line to 100% 100%, line to 0 100%, close);
}

from is the first point. The line to that follows draws a straight line from the current point to the given coordinate. Coordinates after to are absolute positions, with the origin at the top-left corner of the reference box. Using by instead, as in line by 20px 10px, makes the coordinate relative to the current point. A % in a coordinate resolves against the width of the reference box horizontally and its height vertically.

The main commands are as follows.

Command Role
move Moves to another point without drawing a line
line Draws a straight line from the current point
hline / vline Draws a horizontal or vertical line
curve Draws a quadratic or cubic Bézier curve
smooth Draws a Bézier curve that continues smoothly from the previous one
arc Draws an elliptical arc
close Closes the current subpath

With curve, the control points are given by a with that follows the end point. A single control point produces a quadratic Bézier curve; two control points separated by / produce a cubic Bézier curve.

.quadratic {
  clip-path: shape(from 0 100%, curve to 100% 100% with 50% 0, close);
}
 
.cubic {
  clip-path: shape(from 0 100%, curve to 100% 100% with 25% 0 / 75% 0, close);
}

For the complete syntax, see Shape Commands in the specification.

Building a speech bubble that can be resized

Let's rebuild the speech bubble from the previous section with the shape() function. Where the path() version was stuck with fixed coordinates, the shape() version specifies the horizontal edges with % and calc(). The corner radius and the height of the tail stay at 20px.

styles.css
.bubble-shape {
  clip-path: shape(
    from 20px 0,
    hline to calc(100% - 20px),
    curve to 100% 20px with 100% 0,
    vline to calc(100% - 40px),
    curve to calc(100% - 20px) calc(100% - 20px)
      with 100% calc(100% - 20px),
    hline to 60%,
    line to calc(60% - 20px) 100%,
    line to calc(60% - 40px) calc(100% - 20px),
    hline to 20px,
    curve to 0 calc(100% - 40px) with 0 calc(100% - 20px),
    vline to 20px,
    curve to 20px 0 with 0 0,
    close
  );
}

The top-right corner and the right edge that follows it, for example, are built from these three commands.

  1. hline to calc(100% - 20px) draws a horizontal line up to 20px short of the right edge
  2. curve to 100% 20px with 100% 0 draws a quadratic Bézier curve whose control point is the top-right corner
  3. vline to calc(100% - 40px) draws the right edge downward

The tail of the bubble is positioned relative to 60%. Its horizontal position follows the width of the element, while the tail itself keeps a height of 20px and a base 40px wide. Being able to pick % for coordinates that should follow the element and px for dimensions that should stay constant is exactly what makes shape() useful.

No matter how you change the width, the outline of the bubble follows the width of the element while the rounded corners and the tail keep their size.

Combining several curves into one shape

As a more complex example than the bubble, let's build an organic card out of six connected cubic Bézier curves. Rather than trying to express the entire outline with a single curve, splitting the top, right, bottom, and left sides into several curves lets you tune each bulge independently.

Here is the outline defined with shape(). Each curve has an end point and two control points, with / separating the two control points.

styles.css
.organic-card {
  box-sizing: border-box;
  inline-size: var(--demo-width, 320px);
  block-size: 240px;
  padding: 4rem;
  color: #1d192b;
  background: #f2b8b5;
  clip-path: shape(
    from 8% 12%,
    curve to 45% 5% with 20% -5% / 32% 15%,
    curve to 92% 15% with 65% -2% / 78% 25%,
    curve to 95% 78% with 105% 35% / 86% 58%,
    curve to 55% 95% with 82% 108% / 68% 80%,
    curve to 10% 88% with 38% 110% / 22% 75%,
    curve to 8% 12% with -3% 65% / 18% 40%,
    close
  );
}

The outline goes around in the following order.

  1. from 8% 12% starts near the top-left corner
  2. The first two curve commands draw the top edge with a shallow dip in the middle
  3. The third bulges the right side outward while heading toward the bottom right
  4. The fourth and fifth draw the right and left halves of the bottom edge
  5. The sixth draws the left side and returns to the starting point
  6. close closes the outline and finalizes the clipped region

Control points can be placed outside the reference box as well. 105% 35%, for instance, pulls the right side outward, and -3% 65% does the same for the left side. A control point is not itself a point on the outline: it acts as a handle that determines the direction in which the curve enters and leaves its end points. Placing one outside the reference box is therefore what lets you create smooth curves that bulge past the edge of the element.

Every coordinate in this example is given in %. When you change the width in the demo, the horizontal coordinates of each end point and control point are recalculated as ratios of the reference box, so the whole six-curve outline follows the width of the element. This is the approach to take when you want an organic outline to stretch as a whole, rather than pinning down certain dimensions the way the speech bubble does.

Applying shape() to motion paths with offset-path

CSS Motion Path is the CSS mechanism for placing an element on a path — a line, a curve, and so on — and moving it along that path. It does not change the element's normal layout position; it applies an offset transform at paint time, so moving the element has no effect on the layout of its surroundings.

Motion paths are controlled mainly through a combination of these properties.

  • offset-path: defines the path the element travels along
  • offset-distance: specifies the position along the path as a length or a percentage
  • offset-rotate: rotates the element to match the direction of the path
  • offset-anchor: specifies which point of the element is placed on the path

Passing shape() to offset-path lets you define the path an element travels along using CSS coordinates.

In the example below, a red dot travels along a cubic Bézier curve. The path leaves 10px of room at its start and end points, and setting the horizontal coordinate of the end point to calc(100% - 10px) makes it follow the width of the container.

styles.css
.motion-stage {
  position: relative;
  inline-size: 320px;
  block-size: 10rem;
}
 
.traveler {
  position: absolute;
  inline-size: 1.25rem;
  block-size: 1.25rem;
  border-radius: 50%;
  background: #e64553;
  offset-path: shape(
    from 10px 80%,
    curve to calc(100% - 10px) 20% with 35% 0 / 65% 100%
  );
  animation: travel 3s ease-in-out infinite alternate;
}
 
@keyframes travel {
  from {
    offset-distance: 0%;
  }
  to {
    offset-distance: 100%;
  }
}

offset-distance represents where along the path the element sits. Animating it from 0% to 100% moves the red dot along the curve, and changing the width of the container keeps the start and end points 10px from either edge as they follow along.

To respect the user's OS settings, the animation is stopped when prefers-reduced-motion: reduce is set.

@media (prefers-reduced-motion: reduce) {
  .traveler {
    animation: none;
    offset-distance: 50%;
  }
}

Summary

  • clip-path with path() can express complex curves, but the coordinates inside the path string are implicitly px, which makes them hard to keep in sync with the element's size
  • shape() uses percentages, CSS units, math functions, and custom properties to generate a path from the reference box at paint time
  • Specifying % for coordinates that should follow along and px for corner radii and tails that should stay fixed gives you a speech bubble whose width stretches while its rounded corners and tail keep their size
  • Combining several curve commands with two control points each lets you describe complex organic shapes that bulge outside the element using nothing but CSS
  • shape() also works with offset-path, letting you define motion paths that follow the width of their container

References

Comprehension check

Answer the following questions to deepen your understanding of the article.

Which of the following describes `shape()` compared with `path()`, as explained in this article?

  • It takes an SVG path string as-is and implicitly treats every coordinate as `px`

    Try again

    That describes `path()`, which the article covers separately. `shape()` writes its drawing commands in CSS syntax.

  • It uses percentages, CSS units, and math functions to generate a path based on the reference box

    Correct!

    The article explains that `shape()` can use `%`, CSS units, `calc()`, and custom properties.

  • It can only draw straight lines, so any curve requires referencing an SVG file

    Try again

    `shape()` can describe curves with commands such as `curve`, `smooth`, and `arc`.

  • It reshapes the element's layout box itself to match the specified outline

    Try again

    `clip-path` changes only the painted area; the layout box remains rectangular.

Which statement correctly describes the difference between `to` and `by` in `shape()` drawing commands?

  • `to` is a position relative to the current point, while `by` is an absolute position based on the reference box

    Try again

    The two are the other way around. The article explains that `to` is absolute and `by` is relative to the current point.

  • `to` can only be used for curves, and `by` only for straight lines

    Try again

    Commands such as `line` and `curve` accept either `to` or `by` for their end point.

  • `to` is an absolute position based on the reference box, while `by` is a position relative to the current point

    Correct!

    As the article explains, `line to 100% 100%` is an absolute position, while `line by 20px 10px` is relative to the current point.

  • There is no difference between `to` and `by`; you can pick either one for readability

    Try again

    The two keywords use different origins for their coordinates, so swapping one for the other normally produces a different path.

If you pass two control points separated by `/` to the `with` of a `curve` command, what kind of curve do you get?

  • A quadratic Bézier curve

    Try again

    The article explains that a single control point produces a quadratic Bézier curve.

  • A cubic Bézier curve

    Correct!

    In the article's complex shape example, two control points separated by `/` create cubic Bézier curves.

  • An elliptical arc

    Try again

    Elliptical arcs are drawn with `arc`, not `curve`.

  • A horizontal line

    Try again

    Horizontal lines are drawn with `hline`, which plays a different role from `curve` and its control points.