CSS clip-path Animations I Actually Use For Scroll Reveals

How I use CSS clip-path for scroll reveals and hero transitions without ScrollMagic, GSAP, or a single animation library. Just CSS, a bit of IntersectionObserver, and some opinionated patterns.
CSS clip-path Animations I Actually Use For Scroll Reveals
Photo by ROBIN WORRALL / Unsplash

Why I keep reaching for clip-path

I have tried all the usual scroll-reveal suspects. ScrollMagic back in the day. GSAP ScrollTrigger. A random parade of micro-libraries everyone swore by for exactly six months.

I always came back to the same feeling. Too much abstraction for what I actually needed. I do not need a timeline editor to fade in three cards. I need a clean way to hide stuff and then reveal it with intent.

So I settled on a boring, repeatable pattern. CSS clip-path as my main reveal mechanic. One small IntersectionObserver. No animations in JavaScript. No huge scroll library that breaks on the next browser quirk.

This is the technique I keep reusing across projects. Scroll reveals, hero transitions, and section wipes. All built on the same core idea.

The core mental model

clip-path lets you define which part of an element is visible. The rest is just gone. Not moved. Not faded. Literally clipped.

Instead of thinking "animate opacity", I think "animate the visible area". That small shift changes the feel completely.

The simplest example looks like this:

.reveal-clip {
  clip-path: inset(100% 0 0 0);
  transition: clip-path 0.7s cubic-bezier(0.22, 0.61, 0.36, 1);
}

.reveal-clip.is-visible {
  clip-path: inset(0 0 0 0);
}

This is a top-to-bottom reveal. Initially the whole thing is clipped away from the top. When you add .is-visible, the inset goes to zero and the content slides into view, defined entirely by the clipping edges.

No transform, no opacity. Just the actual geometry of what is shown.

Why I prefer clip-path over the usual fade-and-slide

I have shipped the usual pattern a hundred times. opacity: 0, transform: translateY(20px), then transition both. It works. It is quick. It also looks like every other landing page ever.

Here is what I like better about clip-path based reveals.

  • Hard edges feel deliberate. A heading that wipes in with a straight edge looks like a design choice, not a leftover default.
  • Works great with background colors and gradients. You reveal a block, not an element floating over a static background.
  • No layout shift. The element is always in the same place. You just change its visible area.
  • Clean separation of logic. JavaScript only decides when something reveals. CSS decides how.

I think this makes scroll-reveal less fragile and easier to reason about during refactors. I do not want complicated scroll states intertwined with animation timelines.

Minimal scroll-trigger with IntersectionObserver

I promised no JavaScript libraries, not no JavaScript at all. I still want the timing to be scroll-based. I just refuse to let JavaScript orchestrate the animation values.

This is the snippet I keep reusing:

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add('is-visible');
      observer.unobserve(entry.target);
    }
  });
}, {
  threshold: 0.3
});

const revealEls = document.querySelectorAll('[data-reveal]');
revealEls.forEach((el) => observer.observe(el));

That is it. Anything with data-reveal gets observed. Once 30% of it is on screen, the class .is-visible is added and the element is unobserved.

No scroll position math. No rAF loops. The browser tells me when something is visible enough, and CSS takes it from there.

Baseline CSS pattern I keep reusing

I try to keep the scroll-reveal system tiny. One attribute. A couple of utility-like classes. No giant animation framework.

Here is my base setup.

[data-reveal] {
  opacity: 0;
}

[data-reveal].is-visible {
  opacity: 1;
}

[data-reveal="clip-up"] {
  clip-path: inset(100% 0 0 0);
  transition:
    clip-path 0.7s cubic-bezier(0.22, 0.61, 0.36, 1),
    opacity 0.3s ease-out;
}

[data-reveal="clip-up"].is-visible {
  clip-path: inset(0 0 0 0);
}

[data-reveal="clip-right"] {
  clip-path: inset(0 100% 0 0);
  transition:
    clip-path 0.7s cubic-bezier(0.22, 0.61, 0.36, 1),
    opacity 0.3s ease-out;
}

[data-reveal="clip-right"].is-visible {
  clip-path: inset(0 0 0 0);
}

I mostly use two directions: up and right. That already covers a lot of reveals. Cards sliding in from the bottom. Highlight panels wiping in from the side.

If you want staggered timing, do not write a custom observer. Just use transition-delay and plain CSS selectors.

.reveal-group > [data-reveal] {
  transition-delay: 0s;
}

.reveal-group > [data-reveal]:nth-child(2) {
  transition-delay: 0.07s;
}

.reveal-group > [data-reveal]:nth-child(3) {
  transition-delay: 0.14s;
}

That gets you a simple stagger for three items, no loop, no magic numbers in JavaScript. If I need more, I usually cap it and repeat the pattern instead of building a general system.

Hero transitions that feel designed, not hacked

The place where clip-path really shines for me is hero sections. Big transitions that can easily turn into a mess if you stack transforms, opacity, and random will-change declarations.

My current favorite composition is a hero with two layers.

  • A background panel that wipes in from the side.
  • Content that reveals with a vertical clip.

The structure looks like this:

<section class="hero">
  <div class="hero-bg" data-reveal="clip-right"></div>
  <div class="hero-inner">
    <h1 data-reveal="clip-up">Build weird stuff</h1>
    <p data-reveal="clip-up">Custom web experiences without the bloat.</p>
    <a class="hero-cta" data-reveal="clip-up">See the work</a>
  </div>
</section>

The background is just another element inside the hero, not a pseudo-element. I prefer that because it is easier to debug in DevTools. I am not trying to be clever, only explicit.

Then I give the hero-specific styling:

.hero {
  position: relative;
  overflow: hidden;
  padding: 6rem 1.5rem;
}

.hero-bg {
  position: absolute;
  inset: 10% 0 0 40%;
  background: linear-gradient(135deg, #111827, #1f2937);
  z-index: -1;
}

.hero-inner {
  position: relative;
  max-width: 60rem;
}

Because the hero has overflow: hidden, the background wipe feels like part of the section itself, not a rogue block sliding in from off-screen. Combine that with the vertical title reveal and you get a single cohesive movement when the page loads.

I usually trigger the hero reveal on load, not scroll. For that I just add a class from JavaScript once the DOM is ready.

window.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('[data-reveal]')
    .forEach((el) => el.classList.add('is-visible'));
});

On pages that also use scroll-reveal, I skip that and rely on the IntersectionObserver instead.

Diagonal and circular reveals for when you want to push it a bit

So far I stuck to inset(). That is the blunt instrument. It maps nicely to edges. If I want something more expressive, I go for polygon() or circle().

For a diagonal wipe you can do:

[data-reveal="diag-left"] {
  clip-path: polygon(0 0, 0 0, 0 100%, 0 100%);
  transition: clip-path 0.8s cubic-bezier(0.19, 1, 0.22, 1);
}

[data-reveal="diag-left"].is-visible {
  clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}

The starting polygon collapses the visible area into a vertical line on the left. When it expands, the right edge sweeps across the content.

Circular reveals are nice for avatars or product shots.

[data-reveal="circle"] {
  clip-path: circle(0% at 50% 50%);
  transition: clip-path 0.7s ease-out;
}

[data-reveal="circle"].is-visible {
  clip-path: circle(75% at 50% 50%);
}

You can attach this straight to the image wrapper and it will look like the content is expanding from the center. I mostly use this very sparingly, maybe once per page. Too many fancy shapes and the whole thing turns into animation soup.

Performance and gotchas I learned the hard way

clip-path is powerful, but it can bite you if you go wild with it. A few constraints I now enforce on myself.

1. Stick to a small set of shapes

I used to handcraft complex polygons per section. That became unmaintainable fast. Also harder to keep visually consistent across breakpoints.

Now I have a tight set.

  • Vertical inset() for headings and body text.
  • Horizontal inset() for panels and background blocks.
  • One diagonal polygon() for hero or section separators.
  • Optional circular reveal for one focal element.

If a design needs more than that, I usually question the design instead of the animation system.

2. Keep transforms and clip-path separated

Stacking transform and clip-path animations on the same element works, but you can end up chasing tiny glitches. Especially on lower-end devices.

What works better for me is layering.

  • Parent handles position and transform-based motions.
  • Child handles clip-path reveal.

That way the browser does not have to recalc the clip geometry while the element is also moving in 3D space. At least not as often.

3. Be honest about browser support

clip-path support for basic shapes is good enough now that I do not worry much. If you target anything slightly modern, it is fine.

Where I draw the line is relying on very complex polygon() animations in critical flows. For marketing pages and portfolio work it is acceptable. For core app UI transitions I stay more conservative and tend to use transforms instead.

A real scroll section I shipped recently

One of my recent projects had a classic three-step feature section. The first version used a scroll library. It felt heavy and a bit out of sync, especially on mobile.

I ripped the whole thing out and rebuilt it with the clip-path pattern.

<section class="steps">
  <div class="steps-inner reveal-group">
    <article class="step" data-reveal="clip-up">...</article>
    <article class="step" data-reveal="clip-up">...</article>
    <article class="step" data-reveal="clip-up">...</article>
  </div>
</section>

CSS:

.steps {
  padding: 6rem 1.5rem;
}

.steps-inner {
  display: grid;
  gap: 2rem;
}

.step {
  background: #111827;
  border-radius: 1rem;
  padding: 2rem;
}

.steps-inner > [data-reveal] {
  transition-delay: 0s;
}

.steps-inner > [data-reveal]:nth-child(2) {
  transition-delay: 0.08s;
}

.steps-inner > [data-reveal]:nth-child(3) {
  transition-delay: 0.16s;
}

JavaScript was the same tiny observer from earlier, shared across the whole page. No section-specific logic. No hidden magic numbers.

The section now behaves predictably. Scroll down, hit 30% visibility, reveal each card with a short stagger. The code footprint is small, and when I read it three months later, I still understand what is going on.

How I decide when to use clip-path

I do not use clip-path everywhere. That would get tiring fast. I treat it like a strong spice.

I usually ask three questions.

  • Is this reveal tied to a surface, not just text? If yes, clip-path is a good fit because it reveals the whole block, background included.
  • Does this section need clear motion direction? If the design calls for a left-to-right flow or top-to-bottom story, clipping along that axis makes the motion feel intentional.
  • Will this still read well if the animation does not run? If the layout collapses without the effect, I rethink the layout first.

If the answer to those is mostly yes, I reach for the same pattern again. Attribute to mark intent. Global observer to trigger state. Small set of clip-path utilities to define motion.

No scroll library. No timeline editor. Just CSS doing what it is good at: defining how things look and move, once you tell it when to start.

Subscribe to my newsletter

Subscribe to my newsletter to get the latest updates and news

Member discussion