
CSS clip-path: Creating Custom Shapes and Reveals
The web is built out of rectangles. Every element, whether it's a heading, an image, or a button, is laid out as a box. But the way an element looks doesn't have to follow the box. The clip-path property lets you define a region, and only the parts of the element inside that region are painted. Everything else is hidden, as if you'd cut it out with scissors.
That makes clip-path the go-to tool for angled section dividers, hexagonal avatars, diagonal hero images, and some of the smoothest reveal animations you can build in CSS. In this guide, we'll cover every shape function, show how to animate between shapes, and build a few real components.
How clip-path Works
clip-path takes a shape. Anything inside the shape is visible, and anything outside is clipped away.
.avatar {
clip-path: circle(50%);
}
Three important facts:
- Layout doesn't change. The element still takes up its full rectangular box. Surrounding content doesn't flow into the clipped area. (For that, look at
shape-outside.) - Clipped areas don't receive pointer events. Clicking the invisible corner of a clipped element passes through to whatever is behind it. That's usually what you want.
- Borders, shadows, and outlines are clipped too. A
box-shadowoutside the clip region is cut off, which catches many people out.
The Basic Shape Functions
inset()
Clips to a rectangle inset from each edge, with optional rounded corners.
.crop {
/* top right bottom left, like margin */
clip-path: inset(10px 20px 30px 40px);
}
.rounded-crop {
clip-path: inset(0 round 24px);
}
inset() is especially useful for animations because it's easy to reason about: inset(0 100% 0 0) hides everything from the right, and inset(0) shows everything.
circle()
A circle with a radius and an optional center position.
.spotlight {
clip-path: circle(40% at 50% 50%);
}
The radius can be a length, a percentage, or the keywords closest-side and farthest-side. A percentage radius is resolved against a normalized diagonal of the box, so circle(50%) on a non-square element isn't necessarily touching the edges. Use closest-side when you need a circle that fits inside.
ellipse()
Like circle(), but with separate horizontal and vertical radii.
.oval {
clip-path: ellipse(50% 35% at 50% 50%);
}
polygon()
The most flexible basic shape. You list the vertices as x and y coordinate pairs:
.triangle {
clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
}
.hexagon {
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
}
.arrow-right {
clip-path: polygon(
0% 20%,
60% 20%,
60% 0%,
100% 50%,
60% 100%,
60% 80%,
0% 80%
);
}
Coordinates are relative to the element's box, starting at the top-left corner. Percentages keep the shape responsive, while lengths let you create fixed-size notches.
You can mix units, which is handy for things like a card with a fixed-size cut corner:
.ticket {
clip-path: polygon(0 0, calc(100% - 24px) 0, 100% 24px, 100% 100%, 0 100%);
}
path()
path() accepts an SVG path string for curves that polygons can't represent:
.blob {
clip-path: path(
"M 100 0 C 160 0 200 40 200 100 C 200 170 150 200 100 200 C 40 200 0 160 0 100 C 0 40 40 0 100 0 Z"
);
}
The catch is that path() coordinates are in pixels, not percentages, so the shape doesn't scale with the element. For responsive curves, use an SVG clipPath element with clipPathUnits="objectBoundingBox" (covered below), or look at the newer shape() function.
shape()
shape() is a newer function designed to fix the limitations of path(). It uses a CSS-friendly syntax with commands like line to, curve to, and arc to, and it accepts percentages and calc():
.wave-bottom {
clip-path: polygon(0 0, 100% 0, 100% 85%, 0 100%);
}
@supports (clip-path: shape(from 0 0, line to 100% 0)) {
.wave-bottom {
clip-path: shape(
from 0 0,
line to 100% 0,
line to 100% 85%,
curve to 0 85% with 50% 110%,
close
);
}
}
Support for shape() is still rolling out across browsers, so treat it as an enhancement with a polygon() fallback like the one above, and check caniuse before depending on it.
Using SVG Clip Paths
You can reference an SVG clipPath element by ID with url():
<svg width="0" height="0" aria-hidden="true" style="position: absolute">
<clipPath id="clip-wave" clipPathUnits="objectBoundingBox">
<path d="M0,0 H1 V0.8 C0.75,1 0.25,0.6 0,0.85 Z" />
</clipPath>
</svg>
<img
class="wave-image"
src="/images/beach.jpg"
alt="Waves rolling onto a sandy beach"
/>
.wave-image {
width: 100%;
clip-path: url(#clip-wave);
}
With clipPathUnits="objectBoundingBox", the path coordinates run from 0 to 1 relative to the element, so the curve scales responsively. It's the most compatible way to get responsive curved clips today.
Reference Boxes
By default, shapes are positioned relative to the element's border box. You can choose a different reference box by adding a keyword:
.content-clip {
clip-path: circle(50%) content-box;
}
Valid boxes include margin-box, border-box, padding-box, and content-box. You can also use a box alone, such as clip-path: content-box, which clips to that box. With border-radius on the element, that box respects the rounding.
Animating clip-path
Here's where clip-path really shines. The browser can interpolate between two shapes of the same type with the same number of points. That means:
circle()tocircle(): smooth.inset()toinset(): smooth.polygon()with 4 points topolygon()with 4 points: smooth.polygon()with 4 points topolygon()with 6 points: instant jump.
If you need to morph between polygons with different point counts, add duplicate points to the simpler one so both have the same number.
A Circular Reveal on Hover
<a class="reveal-card" href="/work/harbor">
<img src="/images/harbor.jpg" alt="Harbor at dawn" />
<span class="reveal-card__overlay">View project</span>
</a>
.reveal-card {
position: relative;
display: block;
overflow: hidden;
border-radius: 16px;
}
.reveal-card img {
display: block;
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
}
.reveal-card__overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff;
font-weight: 700;
font-size: 1.25rem;
background: rgb(79 70 229 / 0.85);
clip-path: circle(0% at 50% 50%);
transition: clip-path 0.5s cubic-bezier(0.65, 0, 0.35, 1);
}
.reveal-card:hover .reveal-card__overlay,
.reveal-card:focus-visible .reveal-card__overlay {
clip-path: circle(75% at 50% 50%);
}
The overlay starts as a zero-size circle and grows until it covers the card. A radius of 75% is enough to reach the corners of most aspect ratios.
A Wipe Reveal for Headings
inset() makes a clean left-to-right wipe:
.wipe-in {
animation: wipe 0.8s cubic-bezier(0.77, 0, 0.18, 1) both;
}
@keyframes wipe {
from {
clip-path: inset(0 100% 0 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
@media (prefers-reduced-motion: reduce) {
.wipe-in {
animation: none;
}
}
Scroll-Driven Reveals
Combine clip-path with scroll-driven animations and images can reveal themselves as they enter the viewport, without JavaScript:
@supports (animation-timeline: view()) {
.scroll-reveal {
animation: wipe-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes wipe-up {
from {
clip-path: inset(100% 0 0 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
}
Scroll-driven animations are supported in Chromium-based browsers and have been arriving in other engines, so the @supports wrapper matters. Without support, images simply appear normally.
Real-World Patterns
Angled Section Dividers
.section--angled {
padding: 6rem 1.5rem;
background: #0f172a;
color: #e2e8f0;
clip-path: polygon(0 0, 100% 4vw, 100% 100%, 0 calc(100% - 4vw));
}
Using vw for the offset keeps the angle consistent across screen sizes. Remember to add enough padding so content never sits in the clipped corners.
Hexagon Avatars
.hex-avatar {
width: 120px;
aspect-ratio: 1 / 1.1547;
object-fit: cover;
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
The aspect ratio of 1 to 1.1547 (that's 2 divided by the square root of 3) produces a regular hexagon.
Before and After Slider
clip-path with a custom property creates a comparison slider with minimal JavaScript:
<div class="compare" style="--pos: 50%">
<img src="/images/room-before.jpg" alt="Living room before renovation" />
<img
class="compare__after"
src="/images/room-after.jpg"
alt="Living room after renovation"
/>
<input
class="compare__range"
type="range"
min="0"
max="100"
value="50"
aria-label="Comparison position"
/>
</div>
.compare {
position: relative;
display: grid;
}
.compare img,
.compare__range {
grid-area: 1 / 1;
width: 100%;
}
.compare__after {
clip-path: inset(0 0 0 var(--pos));
}
.compare__range {
align-self: center;
margin: 0;
opacity: 0;
cursor: ew-resize;
}
const compare = document.querySelector(".compare");
const range = compare.querySelector(".compare__range");
range.addEventListener("input", () => {
compare.style.setProperty("--pos", `${range.value}%`);
});
The "after" image is clipped from the left according to the slider's value, revealing the "before" image underneath. In a production version, you'd also show a visible divider line and handle so sighted users can see the control, since the range input itself is transparent.
Common Pitfalls
- Shadows disappear. Because
box-shadowis clipped, a clipped element can't show its own shadow. Wrap it in a parent and putfilter: drop-shadow()on the parent, which follows the clipped shape. - Borders get cut. Borders on the clipped edge vanish. Simulate them with a slightly larger clipped pseudo-element behind the main one.
- Text in corners. Clipping doesn't reflow text. Add padding so content stays inside the visible area.
- Mismatched point counts. Polygon animations only interpolate when both shapes have the same number of vertices.
- Focus outlines. A clipped element's outline is clipped too. Put focus styles on an unclipped wrapper, or use an inset outline.
Browser Support
The basic shape functions (inset(), circle(), ellipse(), polygon()), reference boxes, and url() references are supported in all modern browsers without prefixes. path() is also widely supported. The newer shape() function is still gaining support, so pair it with a fallback. For very old Safari versions, you may still see -webkit-clip-path in older codebases, though it's no longer needed for current browsers.
Conclusion
clip-path lets you break out of the rectangle without breaking your layout. Use inset(), circle(), and ellipse() for simple crops and smooth reveals, polygon() for angles and geometric shapes, and SVG clip paths or shape() when you need responsive curves. Because shapes of the same type interpolate, clip-path also powers some of the smoothest hover and scroll reveals on the web.
Keep the pitfalls in mind, especially clipped shadows and outlines, and you'll be able to add angled sections, custom avatars, and polished reveal effects with just a few lines of CSS.


