24 / 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
Hold to Confirm Tailwind Close Button
Destructive-action friction: a conic progress ring fills while you press and hold, completing after 900ms; release early and it drains back. The fill is a typed @property percentage transitioned via data-holding:[--tcb24-p:100%]. The port fixes a silent bug in the original — the property was registered inherits:false, so the ring pseudo-element never saw the value — and announces completion to screen readers.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tcb-24 w-full min-h-svh grid place-items-center bg-[linear-gradient(180deg,var(--color-tcb24-bg),var(--color-tcb24-bg2))] px-6 py-12 font-tcb24-sans" aria-label="Hold to confirm close button demo">
<div class="grid w-full justify-items-center gap-7">
<div class="flex flex-col items-center gap-5">
<button id="tcb-24-btn" type="button" aria-label="Hold to close" aria-describedby="tcb-24-hint" class="relative grid size-19 cursor-pointer touch-none place-items-center rounded-full bg-tcb24-btn shadow-[0_18px_40px_-20px_rgb(0_0_0/0.9)] transition-[--tcb24-p,scale,background-color] duration-300 ease-out select-none [--tcb24-p:0%] before:absolute before:-inset-1.5 before:rounded-full before:bg-[conic-gradient(var(--color-tcb24-acc)_var(--tcb24-p),var(--color-tcb24-track)_0)] before:[mask:radial-gradient(circle,transparent_66%,#000_67%)] focus-visible:outline-3 focus-visible:outline-offset-5 focus-visible:outline-tcb24-acc data-holding:scale-96 data-holding:duration-900 data-holding:ease-linear data-holding:[--tcb24-p:100%] data-done:bg-tcb24-acc data-done:[--tcb24-p:100%] motion-reduce:transition-none">
<span aria-hidden="true" class="relative size-6 before:absolute before:top-1/2 before:left-1/2 before:h-[3px] before:w-6 before:-translate-1/2 before:rotate-45 before:rounded-[2px] before:bg-tcb24-x after:absolute after:top-1/2 after:left-1/2 after:h-[3px] after:w-6 after:-translate-1/2 after:-rotate-45 after:rounded-[2px] after:bg-tcb24-x"></span>
</button>
<span id="tcb-24-hint" role="status" class="text-[13px] font-medium tracking-[0.03em] text-tcb24-mut">Press & hold to close</span>
</div>
<p aria-hidden="true" class="rounded-full px-3.5 py-2 font-tcb24-mono text-[11.5px] text-tcb24-mut ring-1 ring-white/12">@property --tcb24-p · data-holding:[--tcb24-p:100%] · conic ring</p>
</div>
</section><section class="tcb-24 w-full min-h-svh grid place-items-center bg-[linear-gradient(180deg,var(--color-tcb24-bg),var(--color-tcb24-bg2))] px-6 py-12 font-tcb24-sans" aria-label="Hold to confirm close button demo">
<div class="grid w-full justify-items-center gap-7">
<div class="flex flex-col items-center gap-5">
<button id="tcb-24-btn" type="button" aria-label="Hold to close" aria-describedby="tcb-24-hint" class="relative grid size-19 cursor-pointer touch-none place-items-center rounded-full bg-tcb24-btn shadow-[0_18px_40px_-20px_rgb(0_0_0/0.9)] transition-[--tcb24-p,scale,background-color] duration-300 ease-out select-none [--tcb24-p:0%] before:absolute before:-inset-1.5 before:rounded-full before:bg-[conic-gradient(var(--color-tcb24-acc)_var(--tcb24-p),var(--color-tcb24-track)_0)] before:[mask:radial-gradient(circle,transparent_66%,#000_67%)] focus-visible:outline-3 focus-visible:outline-offset-5 focus-visible:outline-tcb24-acc data-holding:scale-96 data-holding:duration-900 data-holding:ease-linear data-holding:[--tcb24-p:100%] data-done:bg-tcb24-acc data-done:[--tcb24-p:100%] motion-reduce:transition-none">
<span aria-hidden="true" class="relative size-6 before:absolute before:top-1/2 before:left-1/2 before:h-[3px] before:w-6 before:-translate-1/2 before:rotate-45 before:rounded-[2px] before:bg-tcb24-x after:absolute after:top-1/2 after:left-1/2 after:h-[3px] after:w-6 after:-translate-1/2 after:-rotate-45 after:rounded-[2px] after:bg-tcb24-x"></span>
</button>
<span id="tcb-24-hint" role="status" class="text-[13px] font-medium tracking-[0.03em] text-tcb24-mut">Press & hold to close</span>
</div>
<p aria-hidden="true" class="rounded-full px-3.5 py-2 font-tcb24-mono text-[11.5px] text-tcb24-mut ring-1 ring-white/12">@property --tcb24-p · data-holding:[--tcb24-p:100%] · conic ring</p>
</div>
</section>@import "tailwindcss";
/* The one rule utilities cannot express: a typed, inheritable custom property the ring's ::before can read and the browser can interpolate. */
@property --tcb24-p {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@theme {
--color-tcb24-bg: #2a1c1b;
--color-tcb24-bg: oklch(0.2 0.02 25);
--color-tcb24-bg2: #1a1211;
--color-tcb24-bg2: oklch(0.14 0.015 20);
--color-tcb24-btn: #3a2826;
--color-tcb24-btn: oklch(0.25 0.03 25);
--color-tcb24-acc: #ef4f43;
--color-tcb24-acc: oklch(0.64 0.2 27);
--color-tcb24-track: #54403e80;
--color-tcb24-track: oklch(0.35 0.03 25 / 0.5);
--color-tcb24-x: #fbeeec;
--color-tcb24-x: oklch(0.95 0.02 25);
--color-tcb24-mut: #c6aaa6;
--color-tcb24-mut: oklch(0.77 0.04 25);
--font-tcb24-sans: 'Geist', system-ui, sans-serif;
--font-tcb24-mono: 'Geist Mono', ui-monospace, monospace;
}
/* 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-24 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
/* The one rule utilities cannot express: a typed, inheritable custom property the ring's ::before can read and the browser can interpolate. */
@property --tcb24-p {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@theme {
--color-tcb24-bg: #2a1c1b;
--color-tcb24-bg: oklch(0.2 0.02 25);
--color-tcb24-bg2: #1a1211;
--color-tcb24-bg2: oklch(0.14 0.015 20);
--color-tcb24-btn: #3a2826;
--color-tcb24-btn: oklch(0.25 0.03 25);
--color-tcb24-acc: #ef4f43;
--color-tcb24-acc: oklch(0.64 0.2 27);
--color-tcb24-track: #54403e80;
--color-tcb24-track: oklch(0.35 0.03 25 / 0.5);
--color-tcb24-x: #fbeeec;
--color-tcb24-x: oklch(0.95 0.02 25);
--color-tcb24-mut: #c6aaa6;
--color-tcb24-mut: oklch(0.77 0.04 25);
--font-tcb24-sans: 'Geist', system-ui, sans-serif;
--font-tcb24-mono: 'Geist Mono', ui-monospace, monospace;
}
/* 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-24 {
width: 100%;
min-height: 100svh;
}(() => {
const btn = document.getElementById('tcb-24-btn');
if (!btn || btn.dataset.wired) return;
btn.dataset.wired = '1';
const hint = document.getElementById('tcb-24-hint');
const HOLD = 900;
let timer = 0;
const done = () => btn.hasAttribute('data-done');
const start = () => {
if (done()) return;
btn.setAttribute('data-holding', '');
hint.textContent = 'Keep holding\u2026';
timer = setTimeout(complete, HOLD);
};
const cancel = () => {
if (!btn.hasAttribute('data-holding')) return;
clearTimeout(timer);
btn.removeAttribute('data-holding');
hint.textContent = 'Press & hold to close';
};
const complete = () => {
btn.removeAttribute('data-holding');
btn.setAttribute('data-done', '');
btn.setAttribute('aria-label', 'Closed. Press to reset');
hint.textContent = 'Closed \u2014 click to reset';
navigator.vibrate?.(10);
};
btn.addEventListener('pointerdown', start);
['pointerup', 'pointerleave', 'pointercancel'].forEach((ev) => btn.addEventListener(ev, cancel));
btn.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) { e.preventDefault(); start(); } });
btn.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') cancel(); });
btn.addEventListener('click', () => {
if (!done()) return;
btn.removeAttribute('data-done');
btn.setAttribute('aria-label', 'Hold to close');
hint.textContent = 'Press & hold to close';
});
})();(() => {
const btn = document.getElementById('tcb-24-btn');
if (!btn || btn.dataset.wired) return;
btn.dataset.wired = '1';
const hint = document.getElementById('tcb-24-hint');
const HOLD = 900;
let timer = 0;
const done = () => btn.hasAttribute('data-done');
const start = () => {
if (done()) return;
btn.setAttribute('data-holding', '');
hint.textContent = 'Keep holding\u2026';
timer = setTimeout(complete, HOLD);
};
const cancel = () => {
if (!btn.hasAttribute('data-holding')) return;
clearTimeout(timer);
btn.removeAttribute('data-holding');
hint.textContent = 'Press & hold to close';
};
const complete = () => {
btn.removeAttribute('data-holding');
btn.setAttribute('data-done', '');
btn.setAttribute('aria-label', 'Closed. Press to reset');
hint.textContent = 'Closed \u2014 click to reset';
navigator.vibrate?.(10);
};
btn.addEventListener('pointerdown', start);
['pointerup', 'pointerleave', 'pointercancel'].forEach((ev) => btn.addEventListener(ev, cancel));
btn.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) { e.preventDefault(); start(); } });
btn.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') cancel(); });
btn.addEventListener('click', () => {
if (!done()) return;
btn.removeAttribute('data-done');
btn.setAttribute('aria-label', 'Hold to close');
hint.textContent = 'Press & hold to close';
});
})();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";
/* The one rule utilities cannot express: a typed, inheritable custom property the ring's ::before can read and the browser can interpolate. */
@property --tcb24-p {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@theme {
--color-tcb24-bg: #2a1c1b;
--color-tcb24-bg: oklch(0.2 0.02 25);
--color-tcb24-bg2: #1a1211;
--color-tcb24-bg2: oklch(0.14 0.015 20);
--color-tcb24-btn: #3a2826;
--color-tcb24-btn: oklch(0.25 0.03 25);
--color-tcb24-acc: #ef4f43;
--color-tcb24-acc: oklch(0.64 0.2 27);
--color-tcb24-track: #54403e80;
--color-tcb24-track: oklch(0.35 0.03 25 / 0.5);
--color-tcb24-x: #fbeeec;
--color-tcb24-x: oklch(0.95 0.02 25);
--color-tcb24-mut: #c6aaa6;
--color-tcb24-mut: oklch(0.77 0.04 25);
--font-tcb24-sans: 'Geist', system-ui, sans-serif;
--font-tcb24-mono: 'Geist Mono', ui-monospace, monospace;
}
/* 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-24 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
/* The one rule utilities cannot express: a typed, inheritable custom property the ring's ::before can read and the browser can interpolate. */
@property --tcb24-p {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@theme {
--color-tcb24-bg: #2a1c1b;
--color-tcb24-bg: oklch(0.2 0.02 25);
--color-tcb24-bg2: #1a1211;
--color-tcb24-bg2: oklch(0.14 0.015 20);
--color-tcb24-btn: #3a2826;
--color-tcb24-btn: oklch(0.25 0.03 25);
--color-tcb24-acc: #ef4f43;
--color-tcb24-acc: oklch(0.64 0.2 27);
--color-tcb24-track: #54403e80;
--color-tcb24-track: oklch(0.35 0.03 25 / 0.5);
--color-tcb24-x: #fbeeec;
--color-tcb24-x: oklch(0.95 0.02 25);
--color-tcb24-mut: #c6aaa6;
--color-tcb24-mut: oklch(0.77 0.04 25);
--font-tcb24-sans: 'Geist', system-ui, sans-serif;
--font-tcb24-mono: 'Geist Mono', ui-monospace, monospace;
}
/* 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-24 {
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=Geist:wght@400..800&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=Geist:wght@400..800&family=Geist+Mono:wght@400..600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
.is-holding/.is-donebecamedata-holding:/data-done:variants, including the custom-property value itself (data-holding:[--tcb24-p:100%]).- Bug fix: the CSS original registered
--ccb-24-pwithinherits:false, so its::beforering always read0%and never filled. The port registers itinherits:true. - UI upgrade from the CSS original: the hint is a
role="status"live region wired witharia-describedby("Keep holding…", "Closed"),pointercancelandtouch-nonestop scroll gestures from stranding a half-filled ring, and the hint rose fromoklch(0.65)to0.77for 7: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: Hold to Confirm Tailwind Close Button
Source: https://codefronts.com/components/tailwind-css-close-buttons/hold-to-confirm/
Destructive-action friction: a conic progress ring fills while you press and hold, completing after 900ms; release early and it drains back. The fill is a typed @property percentage transitioned via data-holding:[--tcb24-p:100%]. The port fixes a silent bug in the original — the property was registered inherits:false, so the ring pseudo-element never saw the value — and announces completion to screen readers.
## HTML
```html
<section class="tcb-24 w-full min-h-svh grid place-items-center bg-[linear-gradient(180deg,var(--color-tcb24-bg),var(--color-tcb24-bg2))] px-6 py-12 font-tcb24-sans" aria-label="Hold to confirm close button demo">
<div class="grid w-full justify-items-center gap-7">
<div class="flex flex-col items-center gap-5">
<button id="tcb-24-btn" type="button" aria-label="Hold to close" aria-describedby="tcb-24-hint" class="relative grid size-19 cursor-pointer touch-none place-items-center rounded-full bg-tcb24-btn shadow-[0_18px_40px_-20px_rgb(0_0_0/0.9)] transition-[--tcb24-p,scale,background-color] duration-300 ease-out select-none [--tcb24-p:0%] before:absolute before:-inset-1.5 before:rounded-full before:bg-[conic-gradient(var(--color-tcb24-acc)_var(--tcb24-p),var(--color-tcb24-track)_0)] before:[mask:radial-gradient(circle,transparent_66%,#000_67%)] focus-visible:outline-3 focus-visible:outline-offset-5 focus-visible:outline-tcb24-acc data-holding:scale-96 data-holding:duration-900 data-holding:ease-linear data-holding:[--tcb24-p:100%] data-done:bg-tcb24-acc data-done:[--tcb24-p:100%] motion-reduce:transition-none">
<span aria-hidden="true" class="relative size-6 before:absolute before:top-1/2 before:left-1/2 before:h-[3px] before:w-6 before:-translate-1/2 before:rotate-45 before:rounded-[2px] before:bg-tcb24-x after:absolute after:top-1/2 after:left-1/2 after:h-[3px] after:w-6 after:-translate-1/2 after:-rotate-45 after:rounded-[2px] after:bg-tcb24-x"></span>
</button>
<span id="tcb-24-hint" role="status" class="text-[13px] font-medium tracking-[0.03em] text-tcb24-mut">Press & hold to close</span>
</div>
<p aria-hidden="true" class="rounded-full px-3.5 py-2 font-tcb24-mono text-[11.5px] text-tcb24-mut ring-1 ring-white/12">@property --tcb24-p · data-holding:[--tcb24-p:100%] · conic ring</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
/* The one rule utilities cannot express: a typed, inheritable custom property the ring's ::before can read and the browser can interpolate. */
@property --tcb24-p {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@theme {
--color-tcb24-bg: #2a1c1b;
--color-tcb24-bg: oklch(0.2 0.02 25);
--color-tcb24-bg2: #1a1211;
--color-tcb24-bg2: oklch(0.14 0.015 20);
--color-tcb24-btn: #3a2826;
--color-tcb24-btn: oklch(0.25 0.03 25);
--color-tcb24-acc: #ef4f43;
--color-tcb24-acc: oklch(0.64 0.2 27);
--color-tcb24-track: #54403e80;
--color-tcb24-track: oklch(0.35 0.03 25 / 0.5);
--color-tcb24-x: #fbeeec;
--color-tcb24-x: oklch(0.95 0.02 25);
--color-tcb24-mut: #c6aaa6;
--color-tcb24-mut: oklch(0.77 0.04 25);
--font-tcb24-sans: 'Geist', system-ui, sans-serif;
--font-tcb24-mono: 'Geist Mono', ui-monospace, monospace;
}
/* 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-24 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
const btn = document.getElementById('tcb-24-btn');
if (!btn || btn.dataset.wired) return;
btn.dataset.wired = '1';
const hint = document.getElementById('tcb-24-hint');
const HOLD = 900;
let timer = 0;
const done = () => btn.hasAttribute('data-done');
const start = () => {
if (done()) return;
btn.setAttribute('data-holding', '');
hint.textContent = 'Keep holding\u2026';
timer = setTimeout(complete, HOLD);
};
const cancel = () => {
if (!btn.hasAttribute('data-holding')) return;
clearTimeout(timer);
btn.removeAttribute('data-holding');
hint.textContent = 'Press & hold to close';
};
const complete = () => {
btn.removeAttribute('data-holding');
btn.setAttribute('data-done', '');
btn.setAttribute('aria-label', 'Closed. Press to reset');
hint.textContent = 'Closed \u2014 click to reset';
navigator.vibrate?.(10);
};
btn.addEventListener('pointerdown', start);
['pointerup', 'pointerleave', 'pointercancel'].forEach((ev) => btn.addEventListener(ev, cancel));
btn.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) { e.preventDefault(); start(); } });
btn.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') cancel(); });
btn.addEventListener('click', () => {
if (!done()) return;
btn.removeAttribute('data-done');
btn.setAttribute('aria-label', 'Hold to close');
hint.textContent = 'Press & hold to close';
});
})();
```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: Hold to Confirm Tailwind Close Button
Source: https://codefronts.com/components/tailwind-css-close-buttons/hold-to-confirm/
Destructive-action friction: a conic progress ring fills while you press and hold, completing after 900ms; release early and it drains back. The fill is a typed @property percentage transitioned via data-holding:[--tcb24-p:100%]. The port fixes a silent bug in the original — the property was registered inherits:false, so the ring pseudo-element never saw the value — and announces completion to screen readers.
## HTML
```html
<section class="tcb-24 w-full min-h-svh grid place-items-center bg-[linear-gradient(180deg,var(--color-tcb24-bg),var(--color-tcb24-bg2))] px-6 py-12 font-tcb24-sans" aria-label="Hold to confirm close button demo">
<div class="grid w-full justify-items-center gap-7">
<div class="flex flex-col items-center gap-5">
<button id="tcb-24-btn" type="button" aria-label="Hold to close" aria-describedby="tcb-24-hint" class="relative grid size-19 cursor-pointer touch-none place-items-center rounded-full bg-tcb24-btn shadow-[0_18px_40px_-20px_rgb(0_0_0/0.9)] transition-[--tcb24-p,scale,background-color] duration-300 ease-out select-none [--tcb24-p:0%] before:absolute before:-inset-1.5 before:rounded-full before:bg-[conic-gradient(var(--color-tcb24-acc)_var(--tcb24-p),var(--color-tcb24-track)_0)] before:[mask:radial-gradient(circle,transparent_66%,#000_67%)] focus-visible:outline-3 focus-visible:outline-offset-5 focus-visible:outline-tcb24-acc data-holding:scale-96 data-holding:duration-900 data-holding:ease-linear data-holding:[--tcb24-p:100%] data-done:bg-tcb24-acc data-done:[--tcb24-p:100%] motion-reduce:transition-none">
<span aria-hidden="true" class="relative size-6 before:absolute before:top-1/2 before:left-1/2 before:h-[3px] before:w-6 before:-translate-1/2 before:rotate-45 before:rounded-[2px] before:bg-tcb24-x after:absolute after:top-1/2 after:left-1/2 after:h-[3px] after:w-6 after:-translate-1/2 after:-rotate-45 after:rounded-[2px] after:bg-tcb24-x"></span>
</button>
<span id="tcb-24-hint" role="status" class="text-[13px] font-medium tracking-[0.03em] text-tcb24-mut">Press & hold to close</span>
</div>
<p aria-hidden="true" class="rounded-full px-3.5 py-2 font-tcb24-mono text-[11.5px] text-tcb24-mut ring-1 ring-white/12">@property --tcb24-p · data-holding:[--tcb24-p:100%] · conic ring</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
/* The one rule utilities cannot express: a typed, inheritable custom property the ring's ::before can read and the browser can interpolate. */
@property --tcb24-p {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@theme {
--color-tcb24-bg: #2a1c1b;
--color-tcb24-bg: oklch(0.2 0.02 25);
--color-tcb24-bg2: #1a1211;
--color-tcb24-bg2: oklch(0.14 0.015 20);
--color-tcb24-btn: #3a2826;
--color-tcb24-btn: oklch(0.25 0.03 25);
--color-tcb24-acc: #ef4f43;
--color-tcb24-acc: oklch(0.64 0.2 27);
--color-tcb24-track: #54403e80;
--color-tcb24-track: oklch(0.35 0.03 25 / 0.5);
--color-tcb24-x: #fbeeec;
--color-tcb24-x: oklch(0.95 0.02 25);
--color-tcb24-mut: #c6aaa6;
--color-tcb24-mut: oklch(0.77 0.04 25);
--font-tcb24-sans: 'Geist', system-ui, sans-serif;
--font-tcb24-mono: 'Geist Mono', ui-monospace, monospace;
}
/* 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-24 {
width: 100%;
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
const btn = document.getElementById('tcb-24-btn');
if (!btn || btn.dataset.wired) return;
btn.dataset.wired = '1';
const hint = document.getElementById('tcb-24-hint');
const HOLD = 900;
let timer = 0;
const done = () => btn.hasAttribute('data-done');
const start = () => {
if (done()) return;
btn.setAttribute('data-holding', '');
hint.textContent = 'Keep holding\u2026';
timer = setTimeout(complete, HOLD);
};
const cancel = () => {
if (!btn.hasAttribute('data-holding')) return;
clearTimeout(timer);
btn.removeAttribute('data-holding');
hint.textContent = 'Press & hold to close';
};
const complete = () => {
btn.removeAttribute('data-holding');
btn.setAttribute('data-done', '');
btn.setAttribute('aria-label', 'Closed. Press to reset');
hint.textContent = 'Closed \u2014 click to reset';
navigator.vibrate?.(10);
};
btn.addEventListener('pointerdown', start);
['pointerup', 'pointerleave', 'pointercancel'].forEach((ev) => btn.addEventListener(ev, cancel));
btn.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) { e.preventDefault(); start(); } });
btn.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') cancel(); });
btn.addEventListener('click', () => {
if (!done()) return;
btn.removeAttribute('data-done');
btn.setAttribute('aria-label', 'Hold to close');
hint.textContent = 'Press & hold to close';
});
})();
```How this works
Two speeds, one property. At rest the fill transitions back over 300ms ease-out; while holding, data-holding: swaps in a 900ms linear fill:
[--tcb24-p:0%] transition-[--tcb24-p,scale] duration-300 ease-out
data-holding:[--tcb24-p:100%] data-holding:duration-900 data-holding:ease-linearWhy inherits must be true. The ring is a ::before; pseudo-elements only see a registered custom property from their host if it inherits. With inherits:false the ring reads the initial 0% forever.
Completion is timer-driven, not transitionend, so it cannot be missed.
Make it yours
- Hold length: change
HOLDin the script anddata-holding:duration-900. - Safer colour: swap the red tokens for amber on non-destructive confirms.
- Haptics:
navigator.vibrate?.(10)already fires on supported phones.
Gotchas — read before shipping
- Keyboard users need a hold path too — the script starts on Enter/Space keydown and cancels on keyup.
- Tell people it's a hold: an unexplained long-press is undiscoverable; keep the visible hint.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Transitioning a registered custom property needs @property (Firefox 128+, exactly Tailwind v4's floor).