Tailwind + JSMIT licensed

Material Ripple Tailwind Close Button

The Material ink ripple without a library: the script records the press point as --tcb18-rx/--tcb18-ry, the after: pseudo-element is positioned there with after:left-(--tcb18-rx), and data-rippling:after:animate-tcb18-ink blooms it. Keyboard presses ripple from the centre, so the feedback is identical for every input.

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="tcb-18 w-full min-h-svh grid place-items-center bg-tcb18-bg px-6 py-12 font-tcb18-sans" aria-label="Material ripple close button demo">
  <div class="grid w-full justify-items-center gap-7">
    <div class="flex w-full max-w-[22rem] items-center gap-4 rounded-3xl bg-white px-5 py-4 shadow-[0_2px_6px_-2px_var(--color-tcb18-shadow)]">
      <span aria-hidden="true" class="grid size-10 flex-none place-items-center rounded-full bg-tcb18-tonal font-semibold text-tcb18-acc">M</span>
      <div class="min-w-0 flex-1">
        <div class="text-[15px] font-medium text-tcb18-text">Backup complete</div>
        <div class="text-[13px] text-tcb18-mut">Tap the × anywhere</div>
      </div>
      <button id="tcb-18-btn" type="button" aria-label="Dismiss backup notification" class="relative grid size-12 flex-none cursor-pointer place-items-center overflow-hidden rounded-full bg-white shadow-[0_3px_10px_-2px_var(--color-tcb18-shadow),0_12px_28px_-14px_var(--color-tcb18-shadow)] transition-shadow duration-200 [--tcb18-rx:50%] [--tcb18-ry:50%] after:pointer-events-none after:absolute after:top-(--tcb18-ry) after:left-(--tcb18-rx) after:-ms-7 after:-mt-7 after:size-14 after:rounded-full after:bg-tcb18-ink after:opacity-0 hover:shadow-[0_5px_14px_-2px_var(--color-tcb18-shadow),0_16px_34px_-14px_var(--color-tcb18-shadow)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tcb18-acc data-rippling:after:animate-tcb18-ink motion-reduce:data-rippling:after:animate-none">
        <span aria-hidden="true" class="relative size-4.5 before:absolute before:top-1/2 before:left-1/2 before:h-0.5 before:w-4.5 before:-translate-1/2 before:rotate-45 before:rounded-full before:bg-tcb18-text after:absolute after:top-1/2 after:left-1/2 after:h-0.5 after:w-4.5 after:-translate-1/2 after:-rotate-45 after:rounded-full after:bg-tcb18-text"></span>
      </button>
    </div>
    <p aria-hidden="true" class="rounded-full bg-white/70 px-3.5 py-2 font-tcb18-mono text-[11.5px] text-tcb18-mut ring-1 ring-black/8">after:left-(--tcb18-rx) · data-rippling:after:animate-tcb18-ink</p>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tcb18-bg: #efedf8;
  --color-tcb18-bg: oklch(0.95 0.01 285);
  --color-tcb18-tonal: #e6defa;
  --color-tcb18-tonal: oklch(0.92 0.04 290);
  --color-tcb18-text: #3d3a4d;
  --color-tcb18-text: oklch(0.35 0.03 285);
  --color-tcb18-mut: #5e5a70;
  --color-tcb18-mut: oklch(0.47 0.03 285);
  --color-tcb18-acc: #6446d6;
  --color-tcb18-acc: oklch(0.52 0.2 285);
  --color-tcb18-ink: #7a55e059;
  --color-tcb18-ink: oklch(0.55 0.2 285 / 0.35);
  --color-tcb18-shadow: #4d466b73;
  --color-tcb18-shadow: oklch(0.4 0.06 285 / 0.45);
  --font-tcb18-sans: 'Roboto Flex', system-ui, sans-serif;
  --font-tcb18-mono: 'Geist Mono', ui-monospace, monospace;
  --animate-tcb18-ink: tcb18-ink 0.45s cubic-bezier(0.2,0.6,0.4,1) forwards;

  @keyframes tcb18-ink {
    0% {
      transform: scale(0);
      opacity: 1;
    }

    100% {
      transform: scale(2.5);
      opacity: 0;
    }
  }
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
   stage is sized before the browser build compiles. */

.tcb-18 {
  width: 100%;
  min-height: 100svh;
}
(() => {
  const btn = document.getElementById('tcb-18-btn');
  if (!btn || btn.dataset.wired) return;
  btn.dataset.wired = '1';
  const ripple = (x, y) => {
    if (x != null) {
      const r = btn.getBoundingClientRect();
      btn.style.setProperty('--tcb18-rx', (x - r.left) + 'px');
      btn.style.setProperty('--tcb18-ry', (y - r.top) + 'px');
    } else {
      btn.style.setProperty('--tcb18-rx', '50%');
      btn.style.setProperty('--tcb18-ry', '50%');
    }
    btn.removeAttribute('data-rippling');
    requestAnimationFrame(() => requestAnimationFrame(() => btn.setAttribute('data-rippling', '')));
  };
  btn.addEventListener('pointerdown', (e) => ripple(e.clientX, e.clientY));
  btn.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) ripple(); });
})();

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-tcb18-bg: #efedf8;
  --color-tcb18-bg: oklch(0.95 0.01 285);
  --color-tcb18-tonal: #e6defa;
  --color-tcb18-tonal: oklch(0.92 0.04 290);
  --color-tcb18-text: #3d3a4d;
  --color-tcb18-text: oklch(0.35 0.03 285);
  --color-tcb18-mut: #5e5a70;
  --color-tcb18-mut: oklch(0.47 0.03 285);
  --color-tcb18-acc: #6446d6;
  --color-tcb18-acc: oklch(0.52 0.2 285);
  --color-tcb18-ink: #7a55e059;
  --color-tcb18-ink: oklch(0.55 0.2 285 / 0.35);
  --color-tcb18-shadow: #4d466b73;
  --color-tcb18-shadow: oklch(0.4 0.06 285 / 0.45);
  --font-tcb18-sans: 'Roboto Flex', system-ui, sans-serif;
  --font-tcb18-mono: 'Geist Mono', ui-monospace, monospace;
  --animate-tcb18-ink: tcb18-ink 0.45s cubic-bezier(0.2,0.6,0.4,1) forwards;

  @keyframes tcb18-ink {
    0% {
      transform: scale(0);
      opacity: 1;
    }

    100% {
      transform: scale(2.5);
      opacity: 0;
    }
  }
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
   stage is sized before the browser build compiles. */

.tcb-18 {
  width: 100%;
  min-height: 100svh;
}
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=Roboto+Flex:opsz,wght@8..144,400..700&family=Geist+Mono:wght@400..600&display=swap">

What changed in the port

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

  • .is-rippling::after became data-rippling:after:animate-tcb18-ink; the script toggles an attribute, never a class.
  • UI upgrade from the CSS original: the ripple now lives in a Material-style notification card, so the × has context and a specific label ("Dismiss backup notification"); the button grew from 56px visual / no label to 48px with 44px+ target and a proper name.
  • UI upgrade: held keys no longer spam ripples (!e.repeat), and the helper copy rose from oklch(0.5) to 0.47 for 6.6:1.
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: Material Ripple Tailwind Close Button
Source: https://codefronts.com/components/tailwind-css-close-buttons/inkwell-ripple/

The Material ink ripple without a library: the script records the press point as --tcb18-rx/--tcb18-ry, the after: pseudo-element is positioned there with after:left-(--tcb18-rx), and data-rippling:after:animate-tcb18-ink blooms it. Keyboard presses ripple from the centre, so the feedback is identical for every input.
## HTML
```html
<section class="tcb-18 w-full min-h-svh grid place-items-center bg-tcb18-bg px-6 py-12 font-tcb18-sans" aria-label="Material ripple close button demo">
  <div class="grid w-full justify-items-center gap-7">
    <div class="flex w-full max-w-[22rem] items-center gap-4 rounded-3xl bg-white px-5 py-4 shadow-[0_2px_6px_-2px_var(--color-tcb18-shadow)]">
      <span aria-hidden="true" class="grid size-10 flex-none place-items-center rounded-full bg-tcb18-tonal font-semibold text-tcb18-acc">M</span>
      <div class="min-w-0 flex-1">
        <div class="text-[15px] font-medium text-tcb18-text">Backup complete</div>
        <div class="text-[13px] text-tcb18-mut">Tap the × anywhere</div>
      </div>
      <button id="tcb-18-btn" type="button" aria-label="Dismiss backup notification" class="relative grid size-12 flex-none cursor-pointer place-items-center overflow-hidden rounded-full bg-white shadow-[0_3px_10px_-2px_var(--color-tcb18-shadow),0_12px_28px_-14px_var(--color-tcb18-shadow)] transition-shadow duration-200 [--tcb18-rx:50%] [--tcb18-ry:50%] after:pointer-events-none after:absolute after:top-(--tcb18-ry) after:left-(--tcb18-rx) after:-ms-7 after:-mt-7 after:size-14 after:rounded-full after:bg-tcb18-ink after:opacity-0 hover:shadow-[0_5px_14px_-2px_var(--color-tcb18-shadow),0_16px_34px_-14px_var(--color-tcb18-shadow)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tcb18-acc data-rippling:after:animate-tcb18-ink motion-reduce:data-rippling:after:animate-none">
        <span aria-hidden="true" class="relative size-4.5 before:absolute before:top-1/2 before:left-1/2 before:h-0.5 before:w-4.5 before:-translate-1/2 before:rotate-45 before:rounded-full before:bg-tcb18-text after:absolute after:top-1/2 after:left-1/2 after:h-0.5 after:w-4.5 after:-translate-1/2 after:-rotate-45 after:rounded-full after:bg-tcb18-text"></span>
      </button>
    </div>
    <p aria-hidden="true" class="rounded-full bg-white/70 px-3.5 py-2 font-tcb18-mono text-[11.5px] text-tcb18-mut ring-1 ring-black/8">after:left-(--tcb18-rx) · data-rippling:after:animate-tcb18-ink</p>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tcb18-bg: #efedf8;
  --color-tcb18-bg: oklch(0.95 0.01 285);
  --color-tcb18-tonal: #e6defa;
  --color-tcb18-tonal: oklch(0.92 0.04 290);
  --color-tcb18-text: #3d3a4d;
  --color-tcb18-text: oklch(0.35 0.03 285);
  --color-tcb18-mut: #5e5a70;
  --color-tcb18-mut: oklch(0.47 0.03 285);
  --color-tcb18-acc: #6446d6;
  --color-tcb18-acc: oklch(0.52 0.2 285);
  --color-tcb18-ink: #7a55e059;
  --color-tcb18-ink: oklch(0.55 0.2 285 / 0.35);
  --color-tcb18-shadow: #4d466b73;
  --color-tcb18-shadow: oklch(0.4 0.06 285 / 0.45);
  --font-tcb18-sans: 'Roboto Flex', system-ui, sans-serif;
  --font-tcb18-mono: 'Geist Mono', ui-monospace, monospace;
  --animate-tcb18-ink: tcb18-ink 0.45s cubic-bezier(0.2,0.6,0.4,1) forwards;

  @keyframes tcb18-ink {
    0% {
      transform: scale(0);
      opacity: 1;
    }

    100% {
      transform: scale(2.5);
      opacity: 0;
    }
  }
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
   stage is sized before the browser build compiles. */

.tcb-18 {
  width: 100%;
  min-height: 100svh;
}
```

## JavaScript
```js
(() => {
  const btn = document.getElementById('tcb-18-btn');
  if (!btn || btn.dataset.wired) return;
  btn.dataset.wired = '1';
  const ripple = (x, y) => {
    if (x != null) {
      const r = btn.getBoundingClientRect();
      btn.style.setProperty('--tcb18-rx', (x - r.left) + 'px');
      btn.style.setProperty('--tcb18-ry', (y - r.top) + 'px');
    } else {
      btn.style.setProperty('--tcb18-rx', '50%');
      btn.style.setProperty('--tcb18-ry', '50%');
    }
    btn.removeAttribute('data-rippling');
    requestAnimationFrame(() => requestAnimationFrame(() => btn.setAttribute('data-rippling', '')));
  };
  btn.addEventListener('pointerdown', (e) => ripple(e.clientX, e.clientY));
  btn.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) ripple(); });
})();
```

How this works

Pointer maths in JS, drawing in CSS.

btn.style.setProperty('--tcb18-rx', (x - r.left) + 'px');
btn.removeAttribute('data-rippling');
requestAnimationFrame(() => requestAnimationFrame(() => btn.setAttribute('data-rippling', '')));

Removing and re-adding the attribute across two frames forces the browser to see a fresh animation; one frame is not always enough.

Make it yours

  • Ink colour: edit --color-tcb18-ink (keep it ~35% alpha).
  • Bigger bloom: change scale(2.5) in the keyframes to 3.5 for rectangular buttons.
  • Unbounded ripple: remove overflow-hidden.

Gotchas — read before shipping

  • Don't give the pseudo-element scale-0 — the keyframe animates transform, and v4's scale property would multiply it back to zero.
  • Set pointer-events-none on the ink or it can swallow the second click.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

The floor is Tailwind v4's generated sheet.

Techniques used in this demo

Theme tokensset in @theme, used by a utility

Plain CSSno Tailwind surface

HTML & JSno Tailwind involved

Search CodeFronts

Loading…