Wave Animation in CSS: SVG, clip-path, and keyframe Variants
Three practical CSS wave animation techniques — SVG paths, clip-path, and keyframes — with real code you can drop into any React or Tailwind project today.
Why CSS Wave Animations Are Still Worth Hand-Rolling
Honestly, most wave animation tutorials online are either outdated jQuery nonsense or a 400-line canvas monstrosity that requires a PhD to modify. The reality is that in 2026 you can get a convincing, performant wave with maybe 30 lines of CSS and a handful of SVG path data. That's it.
This article covers three distinct approaches: inline SVG with animated <path> elements, clip-path polygon animations purely in CSS, and old-school @keyframes with border-radius warping. Each has tradeoffs. None is universally better. You'll pick the one that fits your constraints.
One thing worth stating upfront — all three techniques can run entirely on the GPU compositor when you stick to animating transform and opacity. The moment you animate width, height, or anything that triggers layout, you've handed the browser extra work for no reason. We won't do that here.
Technique 1 — SVG Path Wave Animation
SVG waves are the most flexible option. You define a <path> element that traces a sinusoidal curve, then animate the d attribute (or use a translateX on a wider SVG to create a scrolling effect). The scrolling-wider-SVG approach is universally supported and doesn't require SMIL.
Here's a working React component that produces a looping horizontal wave divider. Drop it between any two sections and it'll animate indefinitely without JavaScript timers.
// WaveDivider.tsx
export function WaveDivider({ color = '#6366f1' }: { color?: string }) {
return (
<div className="relative w-full overflow-hidden" style={{ height: 80 }}>
<svg
viewBox="0 24 150 28"
preserveAspectRatio="none"
className="absolute bottom-0 w-[200%] animate-wave"
style={{ height: '100%' }}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<path
id="wave"
d="M-160 44c30 0 58-18 88-18s58 18 88 18 58-18 88-18 58 18 88 18v44h-352z"
/>
</defs>
<use href="#wave" x="48" y="0" fill={color} fillOpacity="0.7" />
<use href="#wave" x="48" y="3" fill={color} fillOpacity="0.4" />
<use href="#wave" x="48" y="5" fill={color} fillOpacity="0.25" />
</svg>
</div>
)
}
```
```css
/* In your global CSS or Tailwind plugin */
@keyframes wave {
0% { transform: translateX(0); }
100% { transform: translateX(50%); }
}
.animate-wave {
animation: wave 6s linear infinite;
}The use elements with different y offsets and fillOpacity values give you layered wave depth without duplicating path data. The width is set to 200% so that when the animation translates it by 50%, you get a seamless loop — the wave tiles perfectly end-to-end.
Technique 2 — clip-path Polygon Wave
SVG is great but it adds markup. If you want a wave edge on a section background without extra DOM nodes, clip-path with polygon() or ellipse() is cleaner. You animate between two clip-path states with @keyframes and the browser handles the interpolation.
The catch: clip-path polygon animations look choppy on edges unless you're animating between polygons with the same number of points. Browsers can't interpolate between a 4-point polygon and an 8-point one, so you'll get a hard snap instead of a smooth morph. Always match point counts.
.wave-section {
background: rgba(99, 102, 241, 0.9); /* indigo with 0.9 opacity */
clip-path: polygon(
0% 0%, 100% 0%,
100% 75%, 90% 85%,
80% 75%, 70% 85%,
60% 75%, 50% 85%,
40% 75%, 30% 85%,
20% 75%, 10% 85%,
0% 75%
);
animation: clip-wave 4s ease-in-out infinite alternate;
}
@keyframes clip-wave {
to {
clip-path: polygon(
0% 0%, 100% 0%,
100% 85%, 90% 75%,
80% 85%, 70% 75%,
60% 85%, 50% 75%,
40% 85%, 30% 75%,
20% 85%, 10% 75%,
0% 85%
);
}
}Using alternate on the animation direction means you don't have to manually write the reverse keyframe — CSS handles it. The ease-in-out timing makes the wave feel like it's breathing rather than mechanically toggling.
Technique 3 — border-radius Morphing for Blob Waves
This one's different from the other two. Instead of a horizontal divider, you're creating an organic blob shape that pulses. It's heavily used in hero sections, avatar frames, and floating UI elements. The trick is that border-radius accepts up to eight values — four corners, each with independent horizontal and vertical radii.
Combine that with @keyframes cycling through radically different border-radius combos and you get something that looks like a slow liquid wave. The performance is excellent because border-radius changes are compositor-friendly when paired with transform.
.blob-wave {
width: 320px;
height: 320px;
background: linear-gradient(135deg, #6366f1, #a855f7);
border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%;
animation: blob-morph 7s ease-in-out infinite;
}
@keyframes blob-morph {
0% { border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; }
25% { border-radius: 30% 60% 70% 40% / 50% 60% 30% 60%; }
50% { border-radius: 50% 60% 30% 60% / 30% 40% 70% 60%; }
75% { border-radius: 60% 40% 50% 40% / 60% 50% 40% 50%; }
100% { border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; }
}If you're building a gradient background or ambient effect rather than a divider, this technique pairs naturally with something like aurora background effects, where you want organic motion without strict geometry. The blob morph also works well layered behind UI cards for a subtle depth effect.
Wiring Wave Animations Into Tailwind v4
Tailwind v4.0.2 changed how you register custom keyframes and utilities. You no longer drop things in tailwind.config.js theme extensions — you use the new @theme block and @keyframes directly in your CSS entry point. It's a cleaner DX once you adjust to it.
/* app/globals.css */
@import 'tailwindcss';
@theme {
--animate-wave: wave 6s linear infinite;
--animate-blob: blob-morph 7s ease-in-out infinite;
--animate-clip-wave: clip-wave 4s ease-in-out infinite alternate;
}
@keyframes wave {
from { transform: translateX(0); }
to { transform: translateX(50%); }
}
@keyframes blob-morph {
0% { border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; }
50% { border-radius: 50% 60% 30% 60% / 30% 40% 70% 60%; }
100% { border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; }
}
@keyframes clip-wave {
to {
clip-path: polygon(
0% 0%, 100% 0%,
100% 85%, 90% 75%, 80% 85%, 70% 75%,
60% 85%, 50% 75%, 40% 85%, 30% 75%,
20% 85%, 10% 75%, 0% 85%
);
}
}With those registered, you get classes like animate-wave, animate-blob, and animate-clip-wave available everywhere. No Tailwind plugin config, no extend block. Just CSS-first. This is also where you'd slot in a prefers-reduced-motion media query to disable these for accessibility — Tailwind won't do that automatically.
If you find yourself managing many animated background variants, it's worth checking out the range of free animated background components we've catalogued — some of them already ship with motion-safe handling baked in.
Layering Multiple Waves for Depth
A single wave looks flat. Two or three waves with different speeds, opacities, and rgba colors give you real depth. The trick is to stagger the animation duration — maybe 6s, 9s, and 12s — so they never sync up and the motion feels organic rather than mechanical.
For color, avoid fully opaque fills unless the wave is your final layer. Something like rgba(255,255,255,0.15) for an upper overlay wave against a dark background catches light naturally without wiping out the content behind it. For dark-mode waves over light backgrounds, rgba(0,0,0,0.06) is subtle enough to read as texture without looking muddy.
Does layering hurt performance? Not meaningfully, as long as each wave SVG is isolated in its own stacking context. Slap will-change: transform on each animated element and the browser will promote them to separate compositor layers. Just don't go wild with eight layers — three is usually the practical ceiling before you're spending GPU memory for diminishing visual returns.
Accessibility and Reduced Motion
Wave animations are decorative by definition. They convey no information. That means prefers-reduced-motion isn't optional — it's the right thing to do. Some people experience genuine vestibular discomfort from looping animations, especially horizontal motion like the scrolling SVG wave.
The fix is two lines. Wrap your animation declarations in a @media (prefers-reduced-motion: no-preference) block, which means the animation only runs when the user hasn't requested reduced motion. Alternatively, use @media (prefers-reduced-motion: reduce) to explicitly set animation: none as an override at the end of your stylesheet. Either works — the second is easier to bolt onto existing code without restructuring.
@media (prefers-reduced-motion: reduce) {
.animate-wave,
.animate-blob,
.animate-clip-wave {
animation: none;
}
}If you're building with Empire UI's component system, this pairs nicely with the theme toggle pattern — users who prefer dark mode often also prefer reduced motion, and handling both in a single user preferences hook keeps your component logic clean.
When to Use Which Wave Technique
SVG path waves are your default choice for section dividers. They scale to any viewport width without distortion, the markup is self-contained, and layering multiple <use> elements is trivial. Use these whenever the wave sits at a boundary between two content sections.
clip-path waves work best when you want a wave edge on an existing element — like a hero background or a card — without adding SVG to the DOM. The polygon approach is less flexible than SVG paths for complex wave shapes, but it keeps your JSX leaner. Worth considering if you're already dealing with glassmorphism-style cards where extra SVG nesting would complicate the stacking context.
Blob morphing with border-radius is its own category. It's not really a wave — it's an organic shape. Reach for it when you want ambient motion on a single element: a hero illustration background, a floating badge, an avatar frame. Mixing blob morphing with the other two techniques in the same layout tends to create visual chaos, so pick one as your primary motion language per page section.
All three techniques compose well with React's animation ecosystem. If you need scroll-triggered wave reveals, wrapping any of these in an Intersection Observer or a Framer Motion whileInView prop adds maybe 10 lines of JavaScript to what is otherwise pure CSS work.
FAQ
The from and to clip-path values must have the same number of polygon points. If they differ, browsers can't interpolate and you'll get a hard cut. Count your points carefully and make sure both keyframes have identical point counts — only the coordinate values should change.
Yes, but browser support for d attribute interpolation is limited. Chrome and Firefox support it, but Safari lagged for a long time. The translateX approach (doubling SVG width and scrolling it) is safer and works everywhere. If you only need Chrome/Firefox, animating d gives you more precise control over wave shape.
Three things: add will-change: transform to each animated element, make sure you're only animating transform and opacity (not width, height, or position), and wrap your wave SVG in a container with overflow: hidden so the animated element's overflow doesn't trigger scrollbars or repaints on the parent.
In Tailwind v4.0.2+, define your keyframes directly in your CSS entry file using standard @keyframes syntax, then register the animation shorthand inside the @theme block as --animate-wave: wave 6s linear infinite;. This generates the animate-wave class automatically without any JavaScript config.
It depends. When paired with transform: translateZ(0) or will-change: transform, the browser promotes the element to its own compositor layer. Without that hint, border-radius changes may trigger paint — not layout, but still more work than a pure transform. Add will-change: transform to be safe, and remove it once you've measured that it's actually helping.
Set animation-play-state: paused on the hovered element. In Tailwind, add hover:pause if you're on v4 with the pause utility, or write .wave-container:hover .animate-wave { animation-play-state: paused; } in your CSS. This works on any of the three wave techniques without touching the keyframe definitions.