Skip to main content

Harshal V. LADHE

The CSS shape() Function: Responsive clip-path Shapes

Cut responsive curved shapes with CSS shape() and clip-path.
Published at:
Last updated:
Estimated reading time:10 min read
A speech bubble with rounded corners and a curved tail, clipped with the CSS shape() function and resizing with its box.(opens full-size image in a new tab)
A speech bubble whose curved tail is drawn with CSS shape().

Introduction

clip-path cuts an element down to a shape: everything inside the shape stays visible, everything outside it disappears. For straight-edged shapes there's polygon(), and for anything with curves there's path(), which takes the same path data an SVG <path> uses. The catch is that path() only understands pixels. A speech bubble drawn for a 320px-wide box stays 320px wide when the box grows or shrinks, so curved clip-paths have never been responsive.

shape() fixes that. It has the same drawing commands as SVG paths (lines, curves and arcs), but written as CSS, so every coordinate can be a percentage, a rem, a calc() or a custom property. This tutorial starts with the basics of drawing a shape, works through every command, and ends with animation, fallbacks and the pitfalls to watch for.

How clip-path Coordinates Work

Every clip-path shape is drawn on the element's own box. 0% 0% is the top-left corner, 100% 0% the top-right and 100% 100% the bottom-right. Percentages are measured against the border box by default, so 50% across is always the middle, whatever the element's size.

/* A triangle pointing up: top centre, bottom right, bottom left */
.triangle {
  clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
}

polygon() joins a list of points with straight lines. That's perfect for triangles, arrows and chevrons, but it can't draw a curve. That's the gap shape() fills.

Basic Usage

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

This draws the same triangle, one command at a time:

  • from 50% 0% places the pen at the starting point, without drawing anything.
  • line to 100% 100% draws a straight line from where the pen is to a new point.
  • close draws a line back to the starting point and closes the outline.

Commands are separated by commas, and each one starts where the previous one ended. If you've written SVG path data before, this is M50 0 L100 100 L0 100 Z spelt out in words.

The Commands

CommandWhat it drawsSVG equivalent
move to x yLifts the pen and moves it without drawing, to start a new sub-shapeM
line to x yA straight line to a pointL
hline to xA horizontal line to an x coordinateH
vline to yA vertical line to a y coordinateV
curve to x y with cx cyA quadratic Bézier curve with one control pointQ
curve to x y with c1x c1y / c2x c2yA cubic Bézier curve with two control pointsC
smooth to x yA curve that continues smoothly from the previous oneT / S
arc to x y of rAn elliptical arc with radius rA
closeA straight line back to the startZ

Every command except close also has a by form, which measures from the pen's current position instead of from the top-left corner. line by 2rem 0 draws a line 2rem to the right of wherever the pen is, which is handy for fixed-size details such as a notch or tab.

Curves

The point after with is a control point. The curve doesn't pass through it; it bends towards it, like a magnet pulling the line:

/* A banner with a curved bottom edge that bulges downwards */
.banner {
  clip-path: shape(
    from 0% 0%,
    hline to 100%,
    vline to 80%,
    curve to 0% 80% with 50% 100%,
    close
  );
}

Because the control point is at 50% 100%, the bottom edge dips towards the middle of the bottom edge. Change the element's width and the curve stretches with it.

Arcs

arc to draws part of a circle or ellipse, which makes it the natural way to round a corner:

/* top edge, then a 1rem rounded top-right corner */
hline to calc(100% - 1rem),
arc to 100% 1rem of 1rem cw,

of 1rem is the radius (give two values for an ellipse). Between any two points there are always two possible arcs of a given radius, and each can go either way round, so two optional keywords pick one:

  • cw or ccw sets the direction: clockwise or anticlockwise. The default is ccw.
  • small or large picks the shorter or longer of the two arcs. The default is small.

A quick rule: when you trace an outline clockwise, its outward-bulging corners are cw arcs.

What shape() Can Do That path() Can't

Here's a speech bubble with rounded corners and a curved tail, first in path():

.bubble {
  clip-path: path("M16 0 H304 A16 16 0 0 1 320 16 V104 A16 16 0 0 1 304 120 H180 Q164 120 160 144 Q156 120 140 120 H16 A16 16 0 0 1 0 104 V16 A16 16 0 0 1 16 0 Z");
}

Every number in that string is a pixel. It only fits a box that's exactly 320 × 144px, and moving the tail means working out a new string, usually in JavaScript. Here's the same outline as a shape():

.bubble {
  --tail: 50%;
  clip-path: shape(
    from 1rem 0,
    hline to calc(100% - 1rem),
    arc to 100% 1rem of 1rem cw,
    vline to calc(100% - 2.5rem),
    arc to calc(100% - 1rem) calc(100% - 1.5rem) of 1rem cw,
    hline to calc(var(--tail) + 1.25rem),
    curve to var(--tail) 100% with calc(var(--tail) + 0.25rem) calc(100% - 1.5rem),
    curve to calc(var(--tail) - 1.25rem) calc(100% - 1.5rem) with calc(var(--tail) - 0.25rem) calc(100% - 1.5rem),
    hline to 1rem,
    arc to 0 calc(100% - 2.5rem) of 1rem cw,
    vline to 1rem,
    arc to 1rem 0 of 1rem cw,
    close
  );
}

It's longer, but it reads like a description of the shape, and it does three things the path() version can't:

  • It's responsive. The edges are drawn at 100% and calc(100% - 1rem), so the bubble fits whatever size the element is.
  • It mixes units. The corners stay a fixed 1rem while the edges stretch, something no single unit could do.
  • It's driven by a custom property. The tail's three points all come from --tail, so moving it is one line of CSS: .bubble--left { --tail: 20%; }.

Try It Live

Both bubbles below have the same outline. The top one uses shape(); the bottom one is the path() version, drawn for a 320 × 144px box. Drag Width and only the shape() bubble resizes its outline; the path() one stays frozen and gets cut off or leaves a gap. Drag Tail position and only the shape() bubble's tail moves, because path() has no way to read --tail. Tick Animate the tail to sweep --tail from side to side with @keyframes, which works because --tail is registered with @property (more on that below). It stays still if your system has reduced motion turned on.

Responsive Speech Bubble with shape()

A speech bubble with rounded corners and a curved tail drawn with shape(), resizing and moving its tail with two sliders, beside a pixel-based path() that can't.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>shape() Playground</title>
  </head>
  <body>
    <main class="playground">
      <header class="playground-header">
        <h1>A Responsive Speech Bubble with shape()</h1>
        <p>Both bubbles use the same outline: rounded corners drawn with <code>arc</code> and a curved tail drawn with <code>curve</code>. The top one is a <code>shape()</code> written in <code>%</code>, <code>rem</code> and <code>calc(var(--tail) …)</code>. The bottom one is the same outline as a <code>path()</code>, which only understands pixels.</p>
      </header>

      <p class="support-note" id="support-note" hidden>This browser doesn't support <code>shape()</code> yet, so the top bubble falls back to a straight-edged <code>polygon()</code> with square corners and a pointed tail.</p>

      <fieldset class="control-group">
        <legend>Bubble</legend>
        <div class="fields-grid">
          <div class="field">
            <label for="width-input">Width</label>
            <div class="range-row">
              <span class="range-edge">200px</span>
              <div class="range-track">
                <input type="range" id="width-input" min="200" max="440" step="10" value="320">
                <output class="range-bubble" id="width-value" for="width-input">320px</output>
              </div>
              <span class="range-edge">440px</span>
            </div>
          </div>

          <div class="field">
            <label for="tail-input">Tail position</label>
            <div class="range-row">
              <span class="range-edge">15%</span>
              <div class="range-track">
                <input type="range" id="tail-input" min="15" max="85" step="1" value="50">
                <output class="range-bubble" id="tail-value" for="tail-input">50%</output>
              </div>
              <span class="range-edge">85%</span>
            </div>
          </div>
        </div>

        <label class="checkbox-field">
          <input type="checkbox" id="animate-toggle">
          Animate the tail with <code>@property --tail</code>
        </label>
      </fieldset>

      <p class="tip motion-tip">Reduced motion is turned on in your system settings, so the tail stays still when animation is ticked. Use the <strong>Tail position</strong> slider to move it instead.</p>

      <section class="demo-area stage" id="stage" aria-label="Speech bubble preview">
        <figure class="example">
          <figcaption><code>clip-path: shape(…)</code></figcaption>
          <div class="bubble bubble--shape">
            <p>Resize me or move my tail. The whole outline follows.</p>
          </div>
        </figure>

        <figure class="example">
          <figcaption><code>clip-path: path("…")</code>, drawn for 320 × 144px</figcaption>
          <div class="bubble bubble--path">
            <p>My outline is frozen at the size it was drawn for.</p>
          </div>
        </figure>
      </section>

      <output class="readout" id="readout" aria-live="polite"></output>
    </main>

    <script src="./index.js"></script>
  </body>
</html>

Read-only
Ln –, Col –HTML3.2 KBUTF-8

Starting sandbox…

No console output yet.

shape() or polygon()?

/* polygon(): a list of points joined by straight lines */
clip-path: polygon(50% 0%, 100% 100%, 0% 100%);

/* shape(): explicit commands, so curves and arcs are possible too */
clip-path: shape(from 50% 0%, line to 100% 100%, line to 0% 100%, close);

polygon() already accepts percentages, calc() and custom properties, so for a straight-edged shape it's the shorter choice and works in older browsers. Reach for shape() when the outline needs a curve or an arc, or when relative by commands make it easier to describe.

polygon()path()shape()
Straight lines✅✅✅
Curves and arcs❌✅✅
%, rem, calc(), var()✅❌ (pixels only)✅
Scales with the element✅❌✅

For straight-edged shapes in SVG itself, SVG Polygon vs Polyline covers the differences, and SVG Essentials: Mastering the Path Element explains the path commands shape() is based on.

Animating a shape()

Two shape() values animate smoothly as long as they have the same number of commands, of the same types, in the same order. The browser then moves each point from its old position to its new one:

.bubble {
  clip-path: shape(from 0% 0%, hline to 100%, vline to 80%, curve to 0% 80% with 50% 80%, close);
  transition: clip-path 0.4s ease;
}

.bubble:hover {
  clip-path: shape(from 0% 0%, hline to 100%, vline to 80%, curve to 0% 80% with 50% 100%, close);
}

On hover, the flat bottom edge bends into a curve. If the two shapes have different commands, the change snaps instead of animating, so draw both states from the same command list.

To animate a custom property used inside the shape, such as --tail, register it with @property as a <percentage>. Unregistered custom properties can't be interpolated, so a keyframe animation on them jumps from value to value. As with any decorative motion, wrap it in a prefers-reduced-motion: no-preference query.

Browser Support and Fallbacks

shape() arrived in Safari 18.4 in March 2025, Chrome and Edge 135 in April 2025, and Firefox 148 in 2026, so it works in every current browser. Check MDN for the latest status. For readers on older versions, give them a simpler shape:

.bubble {
  clip-path: polygon(
    0 0, 100% 0, 100% 85%,
    calc(var(--tail) + 1.25rem) 85%, var(--tail) 100%, calc(var(--tail) - 1.25rem) 85%,
    0 85%
  );
}

@supports (clip-path: shape(from 0 0, line to 100% 0)) {
  .bubble {
    clip-path: shape(/* the full rounded outline */);
  }
}

Older browsers get a straight-edged bubble with a pointed tail, which still reads as a speech bubble. Keep the @supports block even though the fallback comes first; the next section explains why.

Common Pitfalls

1. Relying on the Cascade for Fallbacks

Normally a browser skips a value it doesn't understand and keeps the declaration before it. That doesn't work once the value contains var(). The browser can't check the value until the variable is filled in, so it accepts the declaration at first and only finds the problem later. By then the earlier polygon() has already been thrown away, and the element ends up with no clip-path at all. Wrap a shape() that uses custom properties in @supports.

2. Content Hiding Under the Cut

clip-path hides anything outside the shape, including text. The tail of a speech bubble is cut out of the bottom of the box, so give the element enough bottom padding to keep its content above it. The demo uses 2.5rem.

3. Expecting a Border or Shadow

clip-path only decides what's visible; it doesn't draw along the edge. A border gets cut off with everything else, and a box-shadow is clipped away entirely. For a shadow, wrap the element and put filter: drop-shadow(...) on the wrapper, which follows the clipped outline.

4. Arcs Going the Wrong Way

If a corner bites into the shape instead of rounding it, the arc is bending the wrong way. Swap cw for ccw (or the other way round). If it draws a large loop instead, you need small rather than large.

5. Clicks Outside the Shape

The clipped-away area doesn't respond to the pointer. A click on the empty corner of a clipped button falls through to whatever is behind it. That's usually what you want, but keep small clickable shapes generous enough to hit.

Where Should You Use This?

  • Speech bubbles, tooltips and callouts whose tail position comes from a custom property
  • Section dividers and banners with curved or wavy edges that stretch to any width
  • Cards, tabs and buttons that need rounded corners combined with notches or cut-outs
  • Converting an existing pixel-based path() clip-path into one that scales with the element

If all you need is a different shape of corner, corner-shape is simpler and keeps borders and shadows intact.

Frequently Asked Questions

Key Takeaway

shape() gives clip-path the curves and arcs of SVG paths without being stuck in pixels. Write the outline with percentages, rem and calc() and it fits the element at any size; put a custom property in a coordinate and one line of CSS moves part of the shape. Use polygon() for straight edges, shape() once you need a curve, and wrap any shape() that uses var() in @supports so older browsers get a proper fallback.

Changelog

  • — Initial publication.
This tutorial is licensed under CC BY 4.0 by the author.

Share this tutorial

Spotted a mistake or something outdated in this tutorial? Suggest a correction.

  • HTML Attributes as Real CSS Values with Typed attr()

    Read HTML attributes as CSS colours, lengths and numbers.
    Typed attr() reads an HTML attribute as a real CSS value — type(<color>), type(<integer>), a unit like % or px, or raw-string — for any property on any element, not just text in content. Covers fallbacks for missing or invalid values, patterns for layout, transitions and view transitions, safe fallbacks in browsers without support, and when a custom property is the better choice.
    Published at:
  • CSS Custom Functions with @function: Reusable Logic Without Sass

    Native, reusable CSS functions with parameters — no Sass needed.
    Define native CSS custom functions with @function: typed parameters, defaults, fallbacks for older browsers, how they differ from Sass, and when to use them.
    Published at:
  • Conditional CSS Values with the if() Function

    Swap CSS values by media, supports, or style conditions.
    The if() function picks between several values inside one CSS declaration based on a media(), supports(), or style() condition — collapsing what used to require duplicating an entire rule under @media/@supports, or a JS class toggle, into one line, with a fallback for browsers that don't support it yet.
    Published at: