Back to notes

CSS shape(): responsive shapes without SVG

22 October 2026·
cssclippathshapeanimation
Live demoOpen full screen

You can now draw a star, a blob or a speech bubble in clip-path with percentages, px, calc() and custom properties, and morph between shapes with a single transition: the CSS shape() function has been Baseline since February 2026.

For years, any clip-path more interesting than circle() or polygon() meant path(), and path() takes an SVG path string where every number is a pixel. That's fine for a fixed-size icon, but it breaks the moment the element resizes: the shape doesn't scale, and you end up recalculating the string in JavaScript. shape() uses the same drawing model as SVG paths, but written in CSS syntax, so each coordinate can be whatever CSS value makes sense.

What shape() is

shape() is a <basic-shape> function, and MDN lists it for clip-path, offset-path and the newer border-shape. It takes a starting point and a comma-separated list of commands:

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

The commands mirror SVG path commands, with words instead of letters:

  • move, line, hline and vline for jumps and straight lines.
  • curve ... with ... for Bézier curves: one control point gives a quadratic curve, two (separated by /) a cubic one.
  • smooth for a curve that continues the previous one, arc for elliptical arcs, and close to go back to the start.
  • to means coordinates measured from the top-left corner of the reference box; by means relative to where the previous command ended.

Percentages are relative to the width and height of the reference box. And according to MDN, this is the real difference from path(): path() is a single string limited to px, while shape() accepts percentages, rem, em and math functions such as calc(), max() or abs().

Mixing units: the responsive bubble

The second tab in the demo above is a speech bubble. The trick is deciding what scales and what doesn't: the corners and the tail are fixed in px, while the box and the tail's position are percentages. Because shape() is just CSS, the values come straight from custom properties:

.bubble {
  --r: 18px; --arrow: 30%; --tail: 16px;
  clip-path: shape(from var(--r) 0,
    hline to calc(100% - var(--r)),
    curve to 100% var(--r) with 100% 0,
    vline to calc(100% - var(--tail) - var(--r)),
    curve to calc(100% - var(--r)) calc(100% - var(--tail)) with 100% calc(100% - var(--tail)),
    hline to calc(var(--arrow) + var(--tail)),
    line to var(--arrow) 100%,
    line to calc(var(--arrow) - var(--tail)) calc(100% - var(--tail)),
    hline to var(--r),
    curve to 0 calc(100% - var(--tail) - var(--r)) with 0 calc(100% - var(--tail)),
    vline to var(--r),
    curve to var(--r) 0 with 0 0,
    close);
}

Read it as a walk around the edge: top edge, a quadratic curve for the top-right corner (the control point is the corner itself), down the right side, the bottom-right corner, along the bottom to the tail, down to the tip at 100% and back up, and the same on the left. Drag the width slider in the demo and the corners stay round instead of turning into ellipses, because their radius is in px. The spec itself uses a speech bubble like this one to explain why mixing units matters.

The sliders don't rebuild the string: they only change --r, --arrow and --tail with style.setProperty(). The browser redraws the shape.

Morphing for free

The first tab is the part that catches the eye. The specification says that two shape() values (or a shape() and a path()) can be interpolated if their command lists are the same length and have the same commands in order. A command counts as "the same" if it uses the same keyword and the same by/to, and for curve and smooth the same number of control points.

So if you design every shape with the same skeleton, a transition does the morph:

.badge {
  clip-path: shape(from 0% 0%, line to 100% 0%, line to 100% 100%, line to 0% 100%, close);
  transition: clip-path .6s ease;
}
.badge:hover {
  clip-path: shape(from 50% 0%, line to 100% 50%, line to 50% 100%, line to 0% 50%, close);
}

In the demo, the circle, the star, the flower and the random blob are all twelve curve to X Y with CX CY commands generated from polar coordinates. The star simply puts each control point halfway along the segment, so the curve looks straight. Since the skeleton never changes, any shape morphs into any other.

I checked the flip side in Chromium too: if I replace one of the line commands in the hover state with a curve, the transition stops interpolating and the shape jumps straight to the final value.

Support and gotchas

According to MDN's compatibility data, shape() is supported in Chrome and Edge 135, Firefox 148 and Safari 18.4. It became Baseline newly available on 24 February 2026, so it's not yet "widely available". For older browsers, feature detection is straightforward:

@supports not (clip-path: shape(from 0 0, line to 100% 0, line to 0 100%, close)) {
  .badge { clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); }
}

The demo uses the same check in JavaScript with CSS.supports(). If it fails, it falls back to path() with pixel values computed from the element's size and recalculated with a ResizeObserver. That's exactly the work shape() saves you. You can see the fallback by adding ?fallback to the demo's URL.

Things to keep in mind:

  • clip-path only clips. Shadows and borders are clipped too, so the glow in the demo is a filter: drop-shadow() on the parent, which follows the clipped shape.
  • Computed values keep the units you wrote: getComputedStyle() returns something like shape(from 0px 0px, hline to calc(100% - 20px), ...), which helps when you're debugging.

What to keep an eye on

  • border-shape: it accepts shape() too, but in MDN's compatibility data it only appears in Chrome 147. It's the piece that would let you shape the border as well, not just clip the element.
  • Relative control points: the spec defines from start, from end and from origin for control points. MDN's examples use them behind their own @supports check, so test them in every browser before relying on them.
  • Converting existing paths: if you already have an SVG d string, the translation to shape() is mechanical (M → from/move, L → line, Q/C → curve, Z → close), and that's when you can swap pixels for percentages.

Sources