18 / 28
All 28 in 28 Tailwind CSS Close Buttons
- 01Tailwind Modal Close Button (Top-Right Corner X)
- 02Accessible Tailwind Close Button with Focus Ring (WCAG)
- 03iOS-Style Tailwind Tab Close Button
- 04Tailwind Floating Chat Widget Close Button
- 05Tailwind Cookie Consent Banner Dismiss Button
- 06Tailwind Mobile Ad Banner Close Button (48px Tap Target)
- 07Neumorphic Tailwind Close Button (Soft UI)
- 08Tailwind Hamburger to X Menu Button Animation
- 09Tailwind Video Player Overlay Close Button
- 10Tailwind Drawer Close Button with Native Dialog
- 11Origami Fold Tailwind Close Button
- 12Lock Tumbler Tailwind Close Button (@property)
- 13Rotate on Hover Tailwind Close Button
- 14Magnetic Cursor Tailwind Close Button
- 15Liquid Goo Tailwind Close Button (SVG Filter)
- 16Glitch Effect Tailwind Close Button
- 17Trash Compactor Tailwind Close Button
- 18Material Ripple Tailwind Close Button
- 19Particle Burst Tailwind Close Button
- 20Spring Snap Tailwind Close Button
- 21Neon Glow Tailwind Close Button
- 22Corner Ribbon Tailwind Close Button
- 233D Fold Away Tailwind Close Button
- 24Hold to Confirm Tailwind Close Button
- 25Tailwind Toast Notification Dismiss Button
- 26Tailwind Lightbox Image Close Button
- 27Brutalist Tailwind Close Button
- 28Galaxy Vortex Tailwind Close Button
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.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
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><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;
}@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(); });
})();(() => {
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.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<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.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@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;
}@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;
}<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">
<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::afterbecamedata-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 fromoklch(0.5)to0.47for 6.6:1.
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(); });
})();
```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 to3.5for rectangular buttons. - Unbounded ripple: remove
overflow-hidden.
Gotchas — read before shipping
- Don't give the pseudo-element
scale-0— the keyframe animatestransform, and v4'sscaleproperty would multiply it back to zero. - Set
pointer-events-noneon the ink or it can swallow the second click.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
The floor is Tailwind v4's generated sheet.