EmpireUI
Get Pro
← Blog9 min read#wave animation#css#svg

CSS Wave Animation: SVG, clip-path and Keyframe Techniques

Master CSS wave animations with three battle-tested techniques — SVG paths, clip-path keyframes, and pseudo-elements — plus real code you can ship today.

Flowing blue ocean wave captured in slow motion photography

Three Ways to Build a Wave — Pick Your Weapon

Wave animations are one of those effects that look deceptively simple on Dribbble and turn into a three-hour rabbit hole the first time you try to build them yourself. There are really three distinct approaches — inline SVG with a moving <path>, a clip-path polygon that morphs between keyframes, and a pseudo-element trick using border-radius — and they each have wildly different trade-offs in browser compatibility, performance, and how much control you actually get over the wave shape.

Inline SVG is the most expressive. You define the exact sine-curve path and translate it horizontally — you get pixel-perfect control and the wave stays mathematically smooth at every viewport width. That said, the markup gets verbose fast.

The clip-path approach is the CSS-purist choice. No extra DOM nodes, just a polygon or path() value that you animate between two states. It's lean and composable, but path() inside clip-path only shipped in Chrome 88 (early 2021) and has been in Firefox since version 97, so always check your actual user analytics before committing to it.

Finally there's the pseudo-element rotation trick — a ::before or ::after with a massive border-radius that you spin in place. It's been working since CSS3 landed, it's performant, and honestly it looks great for simple hero dividers. Just don't expect to sculpt the exact sine curve you saw in a design mockup.

In practice, 90% of real projects just need a nice wave divider between sections or a subtle animated background. You don't need all three techniques — pick the one that matches your fidelity requirements and move on.

Technique 1: SVG Wave with a Translating Path

The SVG approach works by embedding a <path> whose d attribute traces a sine curve wider than the viewport (typically 200% width), then translating it left by 50% in a looping keyframe animation. The seam becomes invisible because the right half of the path is an exact copy of the left half.

Here's the full pattern. Set your SVG to viewBox="0 0 1440 120" and draw a wave path that starts and ends at the same Y value. The key is the width being 200% and the animation shifting by exactly -50% — that's what makes the loop seamless: ``html <div class="wave-container"> <svg class="wave-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 120" preserveAspectRatio="none" > <path class="wave-path" d="M0,60 C240,120 480,0 720,60 C960,120 1200,0 1440,60 L1440,120 L0,120 Z" fill="#6366f1" /> </svg> </div> ` `css .wave-container { position: relative; width: 100%; overflow: hidden; height: 120px; } .wave-svg { position: absolute; bottom: 0; width: 200%; height: 100%; animation: wave-scroll 6s linear infinite; } @keyframes wave-scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } } ``

The C commands in the d attribute are cubic Bezier curves. Those four numbers after each C — the two control points and the end point — are where you tune the wave's personality. Pull the control points further above or below the baseline to get sharper crests. Bring them closer together for flatter, calmer waves. Worth noting: preserveAspectRatio="none" lets the SVG stretch to fill whatever container you give it, which is what you want for responsive dividers.

To stack multiple waves with a staggered phase shift, duplicate the <path> inside the same SVG with a different fill-opacity and a different animation duration. Try 8s on the first wave and 12s on the second — the interference pattern that emerges looks genuinely organic without any JavaScript.

Quick aside: if you're running this on a page with a lot of composited layers, check Chrome DevTools' Layers panel. Each animation: transform creates its own compositor layer, which is good for performance, but 6 stacked SVG waves on one page will chew through memory on budget Android devices.

Technique 2: clip-path Morphing Waves

If you want the wave to actually change shape — cresting and troughing — rather than just scrolling horizontally, clip-path morphing is the right call. You define two or more polygon() or path() values and animate between them. The result is a wave that breathes, which works beautifully on hero sections.

The polygon() version works everywhere clip-path is supported (basically all modern browsers since 2017). The trick is matching vertex counts between keyframe states exactly — if your from polygon has 8 points, your to polygon needs exactly 8 points too, or the browser won't interpolate: ``css .wave-hero { background: linear-gradient(135deg, #818cf8 0%, #c084fc 100%); clip-path: polygon( 0% 80%, 5% 75%, 15% 85%, 30% 70%, 50% 80%, 70% 65%, 85% 78%, 95% 68%, 100% 75%, 100% 100%, 0% 100% ); animation: wave-morph 5s ease-in-out infinite alternate; } @keyframes wave-morph { from { clip-path: polygon( 0% 80%, 5% 75%, 15% 85%, 30% 70%, 50% 80%, 70% 65%, 85% 78%, 95% 68%, 100% 75%, 100% 100%, 0% 100% ); } to { clip-path: polygon( 0% 70%, 5% 80%, 15% 72%, 30% 85%, 50% 68%, 70% 80%, 85% 65%, 95% 78%, 100% 68%, 100% 100%, 0% 100% ); } } ``

The alternate direction on the animation means it plays forward to the to state then reverses back — you only need two keyframe states to get a convincing wave motion. Using ease-in-out gives you that natural slow-at-peak, fast-through-middle feel that actually resembles water. Hard-coding linear here looks mechanical.

If you need a proper sine curve rather than a polygon approximation, the path() function inside clip-path takes an SVG path string directly. This gives you the exact Bezier control from Technique 1 but keeps everything in CSS. The catch: animating between two path() values requires the path string to have identical command sequences — same number of C, L, M commands in the same order.

One more thing — clip-path animations don't trigger layout, only composite. That makes them GPU-friendly by default. Pairing a clip-path wave with a background gradient (instead of a separate colored element underneath) keeps the layer count down and performance tight.

Technique 3: The Pseudo-Element Rotation Trick

This one's the old faithful. You create a square or rectangular ::before pseudo-element, give it an extreme border-radius on one pair of corners to make it roughly oval, position it so only the curved top edge is visible, and then rotate it in a continuous loop. The visible slice of that rotating oval looks like a wave. No SVG. No clip-path. Pure CSS.

.wave-section {
  position: relative;
  background: #0f172a;
  overflow: hidden;
  min-height: 300px;
}

.wave-section::before {
  content: '';
  position: absolute;
  /* Make it larger than the container so the curved edge
     fills the full width at any rotation angle */
  width: 150%;
  height: 300px;
  top: -200px;
  left: -25%;
  background: #6366f1;
  border-radius: 0 0 50% 50% / 0 0 80px 80px;
  animation: oval-wave 8s ease-in-out infinite alternate;
  transform-origin: center top;
}

@keyframes oval-wave {
  from { transform: rotate(-3deg) translateY(0); }
  to   { transform: rotate(3deg) translateY(20px); }
}

The border-radius shorthand here is using the / separator for horizontal and vertical radii independently. That 50% 50% / 80px 80px on the bottom corners is what creates the asymmetric oval shape. Adjust those 80px values to get a flatter or tighter wave crest — try values from 40px to 200px and see what works for your layout.

Honestly, this technique has a slightly retro, organic quality to it that the SVG path version can't quite replicate. It's imprecise, but that imprecision works in its favour for backgrounds. Use it for hero sections, CTA dividers, or the wavy bottom edge of a glassmorphism card container.

The downside is you get limited control over wave frequency — you're stuck with one crest per pseudo-element. Stacking two pseudo-elements (::before and ::after) with opposite rotation directions gets you two crests, but that's your limit unless you add real DOM elements.

Performance: What Actually Runs on the GPU

All three techniques animate different properties, and that matters a lot for frame budget. The SVG translate approach animates transform: translateX() — that's compositor-only, no layout or paint. The browser ships it entirely to the GPU. Same story for the pseudo-element rotation: transform: rotate() is also compositor-only. These are your safest options on mobile.

The clip-path polygon animation is trickier. In Chromium, animating clip-path between two polygon() values is promoted to the compositor when the element has will-change: clip-path set (or has a stable composite reason already). Without that hint, it paints on the CPU each frame. Always add will-change: clip-path to your morphing wave elements and verify it's actually promoted using the Layers panel in Chrome DevTools — look for the green "composited" label next to the layer.

/* Force compositor promotion for clip-path animation */
.wave-hero {
  will-change: clip-path;
  /* GPU hint without triggering stacking-context surprises */
  backface-visibility: hidden;
}

For pages with multiple animated waves, throttle the animation on battery-sensitive devices using @media (prefers-reduced-motion). Don't just strip the animation entirely — a static wave looks far better than a blank rectangle: ``css @media (prefers-reduced-motion: reduce) { .wave-svg, .wave-hero, .wave-section::before { animation: none; } } ``

Worth noting: running these animations at 60fps in a Next.js dev build with React StrictMode will occasionally show double frame times in the profiler. That's a dev-only artifact — StrictMode renders components twice. Check perf in a production build (next build && next start) before you draw any conclusions. In production, all three techniques run comfortably within a 16ms frame budget on a mid-2022 Android device.

Combining Waves with Empire UI Backgrounds

Wave animations pair brilliantly with animated backgrounds — the wave acts as a shaped container edge while the background fills in motion and color. If you're already using Empire UI, you've got a head start. The aurora background component generates shifting color blobs that look stunning when clipped by a sine-curve wave edge. You're essentially getting a lava-lamp hero for free.

The setup is straightforward. Wrap the aurora component in a relative overflow-hidden div, place your SVG wave as an absolutely-positioned child with bottom: 0, and let the aurora animation run behind the wave. The wave becomes a reveal mask: ``tsx import { AuroraBackground } from '@empire-ui/backgrounds'; export function WaveHero() { return ( <div className="relative h-[500px] overflow-hidden"> <AuroraBackground className="absolute inset-0" /> <svg className="absolute bottom-0 w-[200%] animate-wave-scroll" viewBox="0 0 1440 120" preserveAspectRatio="none" > <path d="M0,60 C240,120 480,0 720,60 C960,120 1200,0 1440,60 L1440,120 L0,120 Z" fill="white" /> </svg> </div> ); } ``

For standalone CSS effects without a component library, the gradient generator helps you find background gradient values that give your wave enough visual contrast to read properly. Waves disappear when the wave fill color and the page background are too close in luminosity — it's a common mistake. Keep at least a 30% luminance difference between the wave fill and the section it flows into.

Look, the wave is just a shape. The interesting work is what you put inside it or behind it. Empire UI's canvas animations guide covers particle systems and WebGL backgrounds that work with the same wrapper pattern shown above — worth reading if you want to push the visual further. And if you're building section dividers across an entire site, the box shadow generator can help you add subtle depth to the panels the wave connects.

Putting It Together: A Reusable Wave Divider Component

Rather than copy-pasting raw HTML and CSS everywhere, wrap the SVG technique in a small React component with props for color, height, direction, and animation speed. This keeps your wave consistent across sections and makes it trivial to flip orientation with a CSS scaleY(-1) for section-exit waves.

// components/WaveDivider.tsx
interface WaveDividerProps {
  fill?: string;
  height?: number;     // px
  duration?: number;   // seconds
  flip?: boolean;
  className?: string;
}

export function WaveDivider({
  fill = '#ffffff',
  height = 80,
  duration = 7,
  flip = false,
  className = '',
}: WaveDividerProps) {
  return (
    <div
      className={`relative w-full overflow-hidden ${className}`}
      style={{ height }}
      aria-hidden="true"
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 1440 80"
        preserveAspectRatio="none"
        style={{
          position: 'absolute',
          bottom: 0,
          width: '200%',
          height: '100%',
          transform: flip ? 'scaleY(-1)' : undefined,
          animation: `wave-scroll ${duration}s linear infinite`,
        }}
      >
        <path
          d="M0,40 C240,80 480,0 720,40 C960,80 1200,0 1440,40 L1440,80 L0,80 Z"
          fill={fill}
        />
      </svg>
      <style>{`
        @keyframes wave-scroll {
          from { transform: translateX(0) ${flip ? 'scaleY(-1)' : ''}; }
          to   { transform: translateX(-50%) ${flip ? 'scaleY(-1)' : ''}; }
        }
        @media (prefers-reduced-motion: reduce) {
          svg[style*="wave-scroll"] { animation: none; }
        }
      `}</style>
    </div>
  );
}

The aria-hidden="true" on the container is important. Wave dividers are purely decorative — screen readers don't need to announce an unlabelled SVG path. Skipping that attribute causes some screen readers to announce "image" or the raw path data, which is confusing and noisy.

Drop this between any two sections and you're done: <WaveDivider fill="#0f172a" height={100} duration={9} />. Pass flip={true} for the exit wave at the bottom of a dark section flowing into a light one. The duration prop lets you slow down waves that feel too mechanical — 9s to 14s tends to feel natural; anything under 5s reads as frantic.

One more thing — the inline <style> tag inside the component keeps the keyframe scoped to the module without requiring CSS Modules or a separate stylesheet. It's a minor convenience in a Vite or Next.js project, but it means the component is truly self-contained and you can drop it into any project without hunting for a corresponding .css file.

FAQ

Which CSS wave technique is best for performance?

Animating transform: translateX() on an SVG or transform: rotate() on a pseudo-element are both compositor-only operations — they run on the GPU and don't trigger layout. The clip-path morph technique needs will-change: clip-path to get promoted and should be verified in Chrome DevTools' Layers panel.

Can I animate SVG wave paths without JavaScript?

Yes. A CSS @keyframes animation on transform: translateX(-50%) applied to an SVG element wider than its container creates a seamless looping wave with zero JavaScript. Just make sure the SVG path is exactly twice the viewport width so the repeat point is invisible.

Why does my clip-path wave animation look choppy on mobile?

Add will-change: clip-path and backface-visibility: hidden to the animated element — this hints the browser to promote it to its own compositor layer. Also check that you're testing a production build, not a dev build, since StrictMode double-renders can skew profiler numbers significantly.

How do I make a wave divider between two sections in Next.js?

Use the reusable WaveDivider component pattern from the final section above — pass the fill color matching the destination section's background and set height to your desired divider depth. For the exit wave, pass flip={true} to invert the curve direction.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Shape Morphing: clip-path, offset-path and SVG Morph TechniquesWave Animation in CSS: SVG, clip-path, and keyframe VariantsCSS clip-path Generator: Polygon, Circle and Inset ShapesOrganic Shapes in CSS: Blob Buttons, Fluid Dividers, Amorphous Cards