Polyatic
All tools

CSS clip-path Explained

clip-path hides every pixel outside a shape you describe, so a plain rectangular <div> can render as a triangle, a hexagon or a circle without an extra image, an SVG mask or a single change to the markup. The syntax looks approachable — a list of numbers — which is exactly why it goes wrong: the numbers are not all measured against the same thing, and four different functions resolve a percentage four different ways. This guide is about what the numbers mean. Every claim below is arithmetic you can check on a real element with your browser's inspector open.

The coordinate space every shape lives in

Before any shape function makes sense you need the rectangle it is measured against. CSS calls it the reference box, and for clip-path on an HTML element it defaults to the border-box. Three consequences follow immediately:

  • The origin 0 0 is the top-left corner of that box, and the Y axis points down. 0% 0% is top-left; 100% 100% is bottom-right. This is the reverse of a maths graph and it is the mistake behind most upside-down triangles.
  • 100% spans padding and border, not just the content area. Add a 2px border to a clipped element and every percentage coordinate shifts a little, because the box they are measured against just grew by 4px in each dimension.
  • You can pick a different box by naming it alongside the shape: clip-path: circle(50%) content-box. padding-box, content-box and margin-box are available on HTML elements (fill-box, stroke-box and view-box exist for SVG). The keyword may sit before or after the function, and used on its own — with no shape at all — it simply clips to that box.

polygon(): a list of points, joined in the order you write them

A polygon() is a comma-separated list of vertices. Each vertex is two values — X then Y — separated by a space. There is no third value, no z, and no curve: the browser draws a straight edge from each point to the next and then closes the last point back to the first automatically. You never repeat the opening vertex; doing so just adds a zero-length edge.

clip-path: polygon(50% 0%, 100% 100%, 0% 100%); ^^^^^^ ^^^^^^^^^ ^^^^^^^ point 1 point 2 point 3 top- bottom- bottom- centre right left edges drawn: point 1 → point 2 → point 3 → back to point 1 (this closing edge is implicit)

Because the list is the path, order is not cosmetic — from four points upward. With exactly three points any order draws the same triangle; you only reverse which way round it is traced, and that is invisible. Add a fourth and it bites. A rectangle written polygon(0 0, 100% 0, 100% 100%, 0 100%) walks the corners in a ring; swap the last two into polygon(0 0, 100% 0, 0 100%, 100% 100%) and the same four corners now trace a bow-tie, because edges 2 and 4 have become crossing diagonals.

That is also where the one optional extra argument earns its keep: a fill rule written as the first item, polygon(evenodd, 50% 0%, …). The default is nonzero. The difference only shows where the outline wraps around a region twice, and the textbook case is a five-pointed star traced as a pentagram — five points, each edge skipping a vertex. Under nonzero the pentagon in the middle is filled and the star is solid; under evenodd that pentagon is punched out and you get a hollow outline star. Most shapes never wrap anything twice, which is why most people never meet the argument at all.

Three points is the minimum that encloses any area. There is no maximum, but every vertex is a number a human has to maintain, and a fourteen-point blob typed by hand is a fourteen-point blob nobody will ever edit again — this is the case for drawing it and copying the value out.

Worked example 1 — the same triangle in percent and in pixels

Take a concrete element: 400px wide, 200px tall, no border or padding, so the border-box is exactly 400 × 200. Apply the triangle above and multiply each percentage by the matching dimension:

box: 400px wide x 200px tall clip-path: polygon(50% 0%, 100% 100%, 0% 100%) point 1 x = 50% of width 400 = 200px y = 0% of height 200 = 0px point 2 x = 100% of width 400 = 400px y = 100% of height 200 = 200px point 3 x = 0% of width 400 = 0px y = 100% of height 200 = 200px resolved: (200, 0) -> (400, 200) -> (0, 200) -> back to (200, 0)

Base 400px along the bottom edge, apex 200px above it: area ½ × 400 × 200 = 40,000px², exactly half of the box's 80,000px². Give the element a background colour and measure the visible triangle — those are the numbers you will get.

Now the twin written in pixels, which at this one size is pixel-identical output:

clip-path: polygon(200px 0, 400px 200px, 0 200px); /* same rendering, 400x200 */

Here is the check that shows why the units are not interchangeable. Widen the element to 800 × 200 and re-resolve both:

ValueApex at 400px wideApex at 800px wideResult
50% 0%x = 200pxx = 400pxstays centred
200px 0x = 200pxx = 200pxdrifts to 25% across

Set the element to width:50vw and drag the window: the percentage version keeps a symmetric triangle at every width, the pixel version leans further left the wider it gets. That is the whole rule in one sentence — percentages make a shape responsive, lengths pin it to a size. Lengths are still the right answer when a dimension genuinely is fixed: a 24px notch cut into a card should stay 24px whatever the card's width, and calc() lets you mix the two in a single coordinate, as in polygon(0 0, 100% 0, 100% calc(100% - 24px), 0 100%).

What each percentage is actually measured against

This is the table worth keeping, because the answer changes per function and per argument slot:

Where the percentage sitsResolved against
polygon() X valuereference-box width
polygon() Y valuereference-box height
inset() top / bottomreference-box height
inset() left / rightreference-box width
ellipse() rx / rywidth / height respectively
circle() radius√(w² + h²) ÷ √2 — the diagonal ÷ √2

The last row is the odd one, and it is not an implementation quirk: a lone radius has no axis to belong to, so CSS resolves a percentage against a 2-dimensional box with that formula (the same one radial-gradient uses). On a square box it collapses to the side length, so circle(50%) on a square touches all four edges and everyone's intuition holds. On any non-square box it does not, and worked example 2 shows by exactly how much.

Lengths behave more simply everywhere: px, rem, em, vw and friends are measured from the top-left origin of the reference box and never rescale with the element.

inset(), circle() and ellipse(): which one is the right shape

Three of the four basic shapes are not polygons, and each earns its place:

FunctionWhat it describesReach for it when
inset() inset(top right bottom left round radius) — how far to crop inward from each edge, the opposite framing to a silhouette You want a rounded rectangle, a bevelled crop, or a reveal animation that slides an edge in. It is the only basic shape with corner radii.
circle() circle(radius at cx cy) — a true circle: one radius, one centre The element is square, or a perfect circle is the point (avatars, an expanding-circle page transition)
ellipse() ellipse(rx ry at cx cy) — independent horizontal and vertical radii Ovals, and any round mask on a non-square box — the two radii track width and height separately

inset() collapses its arguments exactly like margin: one value is all four edges, two is vertical then horizontal, three is top / horizontal / bottom, four is top-right-bottom-left. Its optional round argument takes full border-radius syntax, slash form included, so inset(0 round 2rem / 1rem) gives elliptical corners. Push the insets past each other — inset(0 60% 0 60%), where left plus right exceeds the width — and there is nothing left to paint; the element vanishes rather than erroring.

Both circle() and ellipse() default to at center and accept the whole CSS <position> grammar after at, including edge-relative offsets like at right 10px bottom 10px. They also accept two keywords in place of a numeric radius: closest-side (the default) reaches the nearest edge, farthest-side the furthest. When a round shape has to touch a specific edge, those keywords are more reliable than arithmetic.

Worked example 2 — why circle(50%) does not fill a 400 × 200 box

Same element as before: 400 × 200. First an inset(), where the arithmetic is pleasantly boring, because each side uses the dimension it faces:

clip-path: inset(10% 20% 30% 40%); /* top right bottom left */ top 10% of the height 200 = 20px right 20% of the width 400 = 80px bottom 30% of the height 200 = 60px left 40% of the width 400 = 160px visible rectangle: x from 160 to 320 (400 - 160 - 80 = 160px wide) y from 20 to 140 (200 - 20 - 60 = 120px tall)

Check it by adding outline: 1px solid red — the outline is drawn on the full box and gets clipped away with everything else, so what survives traces the 160 × 120 window precisely.

Now the same box with circle(50%), which most people expect to fill it edge to edge:

reference length = √(400² + 200²) ÷ √2 = √(160000 + 40000) ÷ 1.41421 = 447.214 ÷ 1.41421 = 316.23px radius = 50% of 316.23 = 158.11px centre = 50% 50% = (200, 100) horizontally: 200 - 158.11 = 41.9px trimmed off each side vertically: 158.11 > 100, so the circle overshoots the box by 58.1px top and bottom — nothing is clipped there at all

So circle(50%) on a 400 × 200 element produces a shape that trims about 42px from the left and right and leaves the top and bottom edges completely untouched — no visible curve where you most expected one. Three ways to get what you actually wanted:

ValueRadius on 400 × 200Visible result
circle(50%)158.11pxtrims 42px off each side; top/bottom untouched
circle(closest-side)100pxa 200px circle touching top and bottom, 100px trimmed off each side
ellipse(50% 50%)rx 200px, ry 100pxtouches all four edges — the "fill the box" answer

That last row is the practical takeaway: on a non-square element, reach for ellipse(), not circle(). And if the element must stay a real circle, give it aspect-ratio: 1 and the confusion disappears with the non-squareness.

Drag the shape instead of guessing the numbers

Pick a polygon, circle, ellipse or inset preset, drag the vertices and handles on a live preview, nudge any coordinate as a number, and copy the exact clip-path value. Runs entirely in your browser — nothing is uploaded.

Open the CSS clip-path Generator →

Browser support, honestly

The four basic shapes are the safe part. polygon(), inset(), circle() and ellipse() work in every evergreen browser and have for years — Chrome and Firefox shipped them in 2016 and 2017, and Safari has supported the unprefixed property since Safari 13.1. If you support older Safari or old Android WebView, add -webkit-clip-path with the identical value above the standard one; it is one extra line and it does no harm anywhere else.

Three honest caveats:

  • path() is younger. Passing an SVG path string — clip-path: path("M 0 0 …") — buys you curves the basic shapes cannot express, but it landed years after them and it only takes user units, so it does not scale with the element. Check the compatibility table before shipping it to a broad audience, and note the newer shape() function, which fixes the responsive-units problem, is still rolling out and is not a safe bet yet.
  • Animation interpolates only between matching shapes. A transition or keyframe pair blends smoothly only when both ends use the same function with the same number of vertices. Two 6-point polygons cross-fade beautifully; a 3-point polygon to a 6-point one, or a polygon to a circle, does not blend at all — the browser flips discretely at the halfway mark. The trick is to pad the simpler shape with duplicate points so both sides have the same count.
  • Version numbers age faster than prose. Everything in this paragraph was true when it was written and will drift. For an exact answer about a specific browser version, read the compatibility table on MDN, not this page.

Three things clip-path does not do

Most clip-path bugs are not syntax errors, they are surprises about scope:

  • It does not change layout. The element keeps its full, unclipped box in the flow. Text nearby wraps around the original rectangle, not the silhouette, and the space you cut away stays reserved. The property that makes text follow a shape is shape-outside, which is separate and only applies to floats.
  • It does change hit-testing. Pointer events only land on the region that is still painted, so the invisible corners of a clipped button stop catching clicks. That is usually what you want, and occasionally the reason a control mysteriously stops responding near its edges.
  • It clips shadows, outlines and descendants. box-shadow and outline are painted as part of the element, so a clip removes them along with everything else outside the shape, and children that overflowed the box are cut too. The standard fix for a lost shadow is to wrap the clipped element and put filter: drop-shadow(0 2px 6px rgba(0,0,0,.3)) on the wrapper — drop-shadow traces the clipped silhouette, which is what you wanted in the first place.

Where to go instead of here: MDN and Clippy

Two resources own this topic, and it is worth being clear about what each does better than this page.

MDN's clip-path reference is the documentation, and nothing here replaces it. It carries the complete formal grammar — every value, including path(), rect(), xywh() and the reference-box keywords — and its browser-compatibility table is generated from real test data and updated with every browser release, which no hand-written prose can match. If you need the exhaustive syntax or a current support matrix, go to MDN. This guide is the narrower thing: an explanation of why the numbers resolve the way they do, with the arithmetic written out.

Clippy is the visual clip-path maker most people have used at least once, and a great many of the polygon values circulating in blog posts and CodePens came out of it. Its preset gallery is larger and more playful than ours, and it lets you drop your own image behind the shape, which is genuinely useful when you are clipping a photo rather than a coloured block. It is worth opening for exactly those reasons.

Polyatic's clip-path generator is deliberately a smaller thing built to a different brief: every coordinate is computed in your own browser and nothing you draw is uploaded, the layout is built for a phone as well as a desktop, values are rounded to whole percentages so the copied string stays short enough to read, and each of the four basic shapes gets draggable handles and a numeric field per coordinate, so you can nudge a vertex to an exact number rather than fighting a drag. Use whichever fits — the CSS is identical either way, and the value in this guide is understanding it well enough to fix by hand.

Putting it together

A clip-path basic shape is measured against the border-box by default, with the origin at the top-left and Y pointing down. polygon() is a list of X-then-Y points joined in the order you wrote them and closed implicitly, with an optional evenodd fill rule that only matters where the outline wraps a region twice. Percentages make a shape responsive and lengths pin it to a size, but what a percentage is measured against changes per slot — X against width, Y against height, inset() per facing edge, ellipse() radii per axis, and circle()'s lone radius against the diagonal divided by √2, which is why circle(50%) misses the edges of any non-square box. Choose inset() when you are cropping in and need corner radii, ellipse() for round shapes on non-square boxes, circle() only on squares, and polygon() when the shape has corners. Then remember what does not change: the layout box, which is why nearby text still wraps around a rectangle.

Open the CSS clip-path Generator → to drag a shape and copy the value, or browse all Polyatic tools.