Pure TailwindMIT licensed

Tailwind Liquid Fill Button with Animated Wave Crest

Liquid rises to fill the button on hover, its surface undulating while it sits there. Two motions, two mechanisms: a transition for the rise and a keyframe for the wave — and the port makes the split explicit, since the rise is a utility pair and the wave is hover:before:animate-tab06-wave.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="tab-06 w-full min-h-svh grid place-items-center bg-tab06-bg font-tab06-sans px-6 py-10">
  <div class="flex flex-col items-center gap-10">
    <p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab06-mut m-0">Hover to fill</p>
    <div class="flex flex-wrap justify-center gap-5">
      <button type="button" class="relative overflow-hidden min-h-11 px-9 text-base font-bold tracking-[0.03em] text-tab06-cyan-ink bg-transparent border-2 border-tab06-cyan-edge rounded-[10px] cursor-pointer transition-colors duration-[400ms] hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab06-cyan-edge focus-visible:text-white before:content-[''] before:absolute before:bottom-0 before:-left-1/4 before:w-[150%] before:h-[200%] before:bg-tab06-cyan before:[border-radius:45%_55%_0_0/25%_25%_0_0] before:translate-y-full before:transition-transform before:duration-[650ms] before:ease-[cubic-bezier(0.4,0,0.2,1)] hover:before:translate-y-[5%] hover:before:animate-tab06-wave focus-visible:before:translate-y-[5%] motion-reduce:before:transition-none motion-reduce:hover:before:animate-none"><span class="relative z-10">Explore</span></button>
      <button type="button" class="relative overflow-hidden min-h-11 px-9 text-base font-bold tracking-[0.03em] text-tab06-violet-ink bg-transparent border-2 border-tab06-violet-edge rounded-[10px] cursor-pointer transition-colors duration-[400ms] hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab06-violet-edge focus-visible:text-white before:content-[''] before:absolute before:bottom-0 before:-left-1/4 before:w-[150%] before:h-[200%] before:bg-tab06-violet before:[border-radius:45%_55%_0_0/25%_25%_0_0] before:translate-y-full before:transition-transform before:duration-[650ms] before:ease-[cubic-bezier(0.4,0,0.2,1)] hover:before:translate-y-[5%] hover:before:animate-tab06-wave focus-visible:before:translate-y-[5%] motion-reduce:before:transition-none motion-reduce:hover:before:animate-none"><span class="relative z-10">Subscribe</span></button>
      <button type="button" class="relative overflow-hidden min-h-11 px-9 text-base font-bold tracking-[0.03em] text-tab06-amber-ink bg-transparent border-2 border-tab06-amber-edge rounded-[10px] cursor-pointer transition-colors duration-[400ms] hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab06-amber-edge focus-visible:text-white before:content-[''] before:absolute before:bottom-0 before:-left-1/4 before:w-[150%] before:h-[200%] before:bg-tab06-amber before:[border-radius:45%_55%_0_0/25%_25%_0_0] before:translate-y-full before:transition-transform before:duration-[650ms] before:ease-[cubic-bezier(0.4,0,0.2,1)] hover:before:translate-y-[5%] hover:before:animate-tab06-wave focus-visible:before:translate-y-[5%] motion-reduce:before:transition-none motion-reduce:hover:before:animate-none"><span class="relative z-10">Upgrade</span></button>
    </div>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tab06-bg: #0f172a;
  --color-tab06-bg: oklch(0.21 0.04 258);
  --color-tab06-mut: #94a3b8;
  --color-tab06-mut: oklch(0.72 0.03 255);
  --color-tab06-cyan: #0e7490;
  --color-tab06-cyan: oklch(0.48 0.1 221);
  --color-tab06-cyan-edge: #22d3ee;
  --color-tab06-cyan-edge: oklch(0.79 0.14 202);
  --color-tab06-cyan-ink: #a5f3fc;
  --color-tab06-cyan-ink: oklch(0.9 0.08 202);
  --color-tab06-violet: #6d28d9;
  --color-tab06-violet: oklch(0.47 0.24 295);
  --color-tab06-violet-edge: #a78bfa;
  --color-tab06-violet-edge: oklch(0.74 0.15 292);
  --color-tab06-violet-ink: #ddd6fe;
  --color-tab06-violet-ink: oklch(0.89 0.06 292);
  --color-tab06-amber: #b45309;
  --color-tab06-amber: oklch(0.53 0.15 62);
  --color-tab06-amber-edge: #fbbf24;
  --color-tab06-amber-edge: oklch(0.84 0.16 84);
  --color-tab06-amber-ink: #fef3c7;
  --color-tab06-amber-ink: oklch(0.95 0.05 90);
  --font-tab06-sans: 'Space Grotesk', system-ui, sans-serif;
  --animate-tab06-wave: tab06-wave 2.5s ease-in-out infinite 0.65s;
}

@keyframes tab06-wave {
  0%,
    100% {
    border-radius: 45% 55% 0 0 / 25% 25% 0 0;
  }

  50% {
    border-radius: 55% 45% 0 0 / 20% 30% 0 0;
  }
}

.tab-06 {
  width: 100%;
  min-height: 100svh;
}

.tab-06 ::selection {
  background: var(--color-tab06-cyan-edge);
  color: #0b1220;
}

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-tab06-bg: #0f172a;
  --color-tab06-bg: oklch(0.21 0.04 258);
  --color-tab06-mut: #94a3b8;
  --color-tab06-mut: oklch(0.72 0.03 255);
  --color-tab06-cyan: #0e7490;
  --color-tab06-cyan: oklch(0.48 0.1 221);
  --color-tab06-cyan-edge: #22d3ee;
  --color-tab06-cyan-edge: oklch(0.79 0.14 202);
  --color-tab06-cyan-ink: #a5f3fc;
  --color-tab06-cyan-ink: oklch(0.9 0.08 202);
  --color-tab06-violet: #6d28d9;
  --color-tab06-violet: oklch(0.47 0.24 295);
  --color-tab06-violet-edge: #a78bfa;
  --color-tab06-violet-edge: oklch(0.74 0.15 292);
  --color-tab06-violet-ink: #ddd6fe;
  --color-tab06-violet-ink: oklch(0.89 0.06 292);
  --color-tab06-amber: #b45309;
  --color-tab06-amber: oklch(0.53 0.15 62);
  --color-tab06-amber-edge: #fbbf24;
  --color-tab06-amber-edge: oklch(0.84 0.16 84);
  --color-tab06-amber-ink: #fef3c7;
  --color-tab06-amber-ink: oklch(0.95 0.05 90);
  --font-tab06-sans: 'Space Grotesk', system-ui, sans-serif;
  --animate-tab06-wave: tab06-wave 2.5s ease-in-out infinite 0.65s;
}

@keyframes tab06-wave {
  0%,
    100% {
    border-radius: 45% 55% 0 0 / 25% 25% 0 0;
  }

  50% {
    border-radius: 55% 45% 0 0 / 20% 30% 0 0;
  }
}

.tab-06 {
  width: 100%;
  min-height: 100svh;
}

.tab-06 ::selection {
  background: var(--color-tab06-cyan-edge);
  color: #0b1220;
}
3Fonts used by this demooptional — skip if you have your own type stack
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • Rise stays a transition, wave stays a keyframe, and the class list names both. Collapsing them into one animation would have cost the interruptible, reverse-on-leave behaviour that makes the rise feel like liquid rather than a cutscene.
  • The animation's start delay lives in the @theme token, not the markup — the only place it can be guaranteed to match the rise duration.
  • UI change from the CSS original: darker liquid with lighter label text, which lifts the mid-fill contrast (the original passed at rest and dipped while filling), plus focus parity on every variant.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Liquid Fill Button with Animated Wave Crest
Source: https://codefronts.com/motion/tailwind-css-animated-buttons/tailwind-liquid-fill-button-animation/

Liquid rises to fill the button on hover, its surface undulating while it sits there. Two motions, two mechanisms: a transition for the rise and a keyframe for the wave &mdash; and the port makes the split explicit, since the rise is a utility pair and the wave is hover:before:animate-tab06-wave.
## HTML
```html
<section class="tab-06 w-full min-h-svh grid place-items-center bg-tab06-bg font-tab06-sans px-6 py-10">
  <div class="flex flex-col items-center gap-10">
    <p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab06-mut m-0">Hover to fill</p>
    <div class="flex flex-wrap justify-center gap-5">
      <button type="button" class="relative overflow-hidden min-h-11 px-9 text-base font-bold tracking-[0.03em] text-tab06-cyan-ink bg-transparent border-2 border-tab06-cyan-edge rounded-[10px] cursor-pointer transition-colors duration-[400ms] hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab06-cyan-edge focus-visible:text-white before:content-[''] before:absolute before:bottom-0 before:-left-1/4 before:w-[150%] before:h-[200%] before:bg-tab06-cyan before:[border-radius:45%_55%_0_0/25%_25%_0_0] before:translate-y-full before:transition-transform before:duration-[650ms] before:ease-[cubic-bezier(0.4,0,0.2,1)] hover:before:translate-y-[5%] hover:before:animate-tab06-wave focus-visible:before:translate-y-[5%] motion-reduce:before:transition-none motion-reduce:hover:before:animate-none"><span class="relative z-10">Explore</span></button>
      <button type="button" class="relative overflow-hidden min-h-11 px-9 text-base font-bold tracking-[0.03em] text-tab06-violet-ink bg-transparent border-2 border-tab06-violet-edge rounded-[10px] cursor-pointer transition-colors duration-[400ms] hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab06-violet-edge focus-visible:text-white before:content-[''] before:absolute before:bottom-0 before:-left-1/4 before:w-[150%] before:h-[200%] before:bg-tab06-violet before:[border-radius:45%_55%_0_0/25%_25%_0_0] before:translate-y-full before:transition-transform before:duration-[650ms] before:ease-[cubic-bezier(0.4,0,0.2,1)] hover:before:translate-y-[5%] hover:before:animate-tab06-wave focus-visible:before:translate-y-[5%] motion-reduce:before:transition-none motion-reduce:hover:before:animate-none"><span class="relative z-10">Subscribe</span></button>
      <button type="button" class="relative overflow-hidden min-h-11 px-9 text-base font-bold tracking-[0.03em] text-tab06-amber-ink bg-transparent border-2 border-tab06-amber-edge rounded-[10px] cursor-pointer transition-colors duration-[400ms] hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab06-amber-edge focus-visible:text-white before:content-[''] before:absolute before:bottom-0 before:-left-1/4 before:w-[150%] before:h-[200%] before:bg-tab06-amber before:[border-radius:45%_55%_0_0/25%_25%_0_0] before:translate-y-full before:transition-transform before:duration-[650ms] before:ease-[cubic-bezier(0.4,0,0.2,1)] hover:before:translate-y-[5%] hover:before:animate-tab06-wave focus-visible:before:translate-y-[5%] motion-reduce:before:transition-none motion-reduce:hover:before:animate-none"><span class="relative z-10">Upgrade</span></button>
    </div>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tab06-bg: #0f172a;
  --color-tab06-bg: oklch(0.21 0.04 258);
  --color-tab06-mut: #94a3b8;
  --color-tab06-mut: oklch(0.72 0.03 255);
  --color-tab06-cyan: #0e7490;
  --color-tab06-cyan: oklch(0.48 0.1 221);
  --color-tab06-cyan-edge: #22d3ee;
  --color-tab06-cyan-edge: oklch(0.79 0.14 202);
  --color-tab06-cyan-ink: #a5f3fc;
  --color-tab06-cyan-ink: oklch(0.9 0.08 202);
  --color-tab06-violet: #6d28d9;
  --color-tab06-violet: oklch(0.47 0.24 295);
  --color-tab06-violet-edge: #a78bfa;
  --color-tab06-violet-edge: oklch(0.74 0.15 292);
  --color-tab06-violet-ink: #ddd6fe;
  --color-tab06-violet-ink: oklch(0.89 0.06 292);
  --color-tab06-amber: #b45309;
  --color-tab06-amber: oklch(0.53 0.15 62);
  --color-tab06-amber-edge: #fbbf24;
  --color-tab06-amber-edge: oklch(0.84 0.16 84);
  --color-tab06-amber-ink: #fef3c7;
  --color-tab06-amber-ink: oklch(0.95 0.05 90);
  --font-tab06-sans: 'Space Grotesk', system-ui, sans-serif;
  --animate-tab06-wave: tab06-wave 2.5s ease-in-out infinite 0.65s;
}

@keyframes tab06-wave {
  0%,
    100% {
    border-radius: 45% 55% 0 0 / 25% 25% 0 0;
  }

  50% {
    border-radius: 55% 45% 0 0 / 20% 30% 0 0;
  }
}

.tab-06 {
  width: 100%;
  min-height: 100svh;
}

.tab-06 ::selection {
  background: var(--color-tab06-cyan-edge);
  color: #0b1220;
}
```

How this works

Rise and ripple are separate systems. The rise is transition-transform from translate-y-full to translate-y-[5%] — interruptible, reversible, compositor-only. The ripple is a keyframe on border-radius, which cannot be expressed as a transition between two hover states. The port keeps both and names each at the element.

The delay is in the token. --animate-tab06-wave: tab06-wave 2.5s ease-in-out infinite 0.65s carries the offset that matches the rise duration, so the class list stays one word and the two timings can never fall out of sync in the markup.

The crest is an arbitrary property, correctly. An eight-value elliptical border-radius is not a design-scale value; before:[border-radius:…] with underscore escapes is the idiomatic answer and carries the slash syntax through intact.

Make it yours

  • Gradient liquid: before:bg-[linear-gradient(180deg,#06b6d4,#0284c7)].
  • Slower, more dramatic fill: before:duration-[650ms] → before:duration-1000 (and raise the wave's delay to match).
  • Choppier surface: push the crest percentages further apart in both the class and the keyframe.

Gotchas — read before shipping

  • overflow-hidden clips the liquid and any outer shadow-*. Wrap the button if you want a glow as well.
  • The wave animates border-radius, which paints rather than composites. One button is free; forty in a scrolling list are not.
  • Firefox can collapse a pseudo-element at very high radius percentages unless its width and height are explicit — which is why the layer is sized 150% / 200% rather than inset-0.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Border-radius animation is universally supported; the floor is Tailwind v4's generated sheet.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…