12 / 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
Lock Tumbler Tailwind Close Button (@property)
A brass combination dial: the × ratchets through hold-heavy keyframes like tumbler clicks while a masked tick ring counter-spins, its angle driven by a typed @property. Both motions are @theme animations — --animate-tcb12-tumble and --animate-tcb12-ring — applied with group-hover:animate-tcb12-*. The @property registration is the one thing that must stay CSS.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tcb-12 w-full min-h-svh grid place-items-center bg-[radial-gradient(120%_120%_at_50%_0%,var(--color-tcb12-bg),var(--color-tcb12-bg2))] px-6 py-12 font-tcb12-sans" aria-label="Lock tumbler close button demo">
<div class="grid w-full justify-items-center gap-7">
<button type="button" aria-label="Close" class="group relative size-21 cursor-pointer rounded-full bg-[conic-gradient(from_210deg,var(--color-tcb12-ba),var(--color-tcb12-bb)_30%,var(--color-tcb12-ba)_52%,var(--color-tcb12-bb)_78%,var(--color-tcb12-ba))] shadow-[0_18px_40px_-16px_rgb(0_0_0/0.75),inset_0_2px_3px_rgb(255_255_255/0.45),inset_0_-3px_6px_rgb(0_0_0/0.4)] before:absolute before:inset-2.5 before:rounded-full before:bg-[radial-gradient(circle_at_32%_28%,var(--color-tcb12-face1),var(--color-tcb12-face2)_70%)] before:shadow-[inset_0_2px_6px_rgb(0_0_0/0.6)] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-tcb12-ba active:scale-94">
<span aria-hidden="true" class="absolute inset-0.5 rounded-full bg-[repeating-conic-gradient(from_var(--tcb12-a),transparent_0_8deg,var(--color-tcb12-tick)_8deg_10deg)] [mask:radial-gradient(circle,transparent_62%,#000_63%,#000_74%,transparent_75%)] group-hover:animate-tcb12-ring group-focus-visible:animate-tcb12-ring motion-reduce:animate-none!"></span>
<span aria-hidden="true" class="absolute inset-0 block before:absolute before:top-1/2 before:left-1/2 before:h-[3.5px] before:w-6.5 before:-translate-1/2 before:rotate-45 before:rounded-[2px] before:bg-linear-to-b before:from-tcb12-x1 before:to-tcb12-x2 before:shadow-[0_1px_2px_rgb(0_0_0/0.5)] after:absolute after:top-1/2 after:left-1/2 after:h-[3.5px] after:w-6.5 after:-translate-1/2 after:-rotate-45 after:rounded-[2px] after:bg-linear-to-b after:from-tcb12-x1 after:to-tcb12-x2 after:shadow-[0_1px_2px_rgb(0_0_0/0.5)] group-hover:animate-tcb12-tumble group-focus-visible:animate-tcb12-tumble motion-reduce:animate-none!"></span>
</button>
<p aria-hidden="true" class="rounded-full px-3.5 py-2 font-tcb12-mono text-[11.5px] text-tcb12-mut ring-1 ring-white/12">@property --tcb12-a · group-hover:animate-tcb12-ring · [mask:…]</p>
</div>
</section><section class="tcb-12 w-full min-h-svh grid place-items-center bg-[radial-gradient(120%_120%_at_50%_0%,var(--color-tcb12-bg),var(--color-tcb12-bg2))] px-6 py-12 font-tcb12-sans" aria-label="Lock tumbler close button demo">
<div class="grid w-full justify-items-center gap-7">
<button type="button" aria-label="Close" class="group relative size-21 cursor-pointer rounded-full bg-[conic-gradient(from_210deg,var(--color-tcb12-ba),var(--color-tcb12-bb)_30%,var(--color-tcb12-ba)_52%,var(--color-tcb12-bb)_78%,var(--color-tcb12-ba))] shadow-[0_18px_40px_-16px_rgb(0_0_0/0.75),inset_0_2px_3px_rgb(255_255_255/0.45),inset_0_-3px_6px_rgb(0_0_0/0.4)] before:absolute before:inset-2.5 before:rounded-full before:bg-[radial-gradient(circle_at_32%_28%,var(--color-tcb12-face1),var(--color-tcb12-face2)_70%)] before:shadow-[inset_0_2px_6px_rgb(0_0_0/0.6)] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-tcb12-ba active:scale-94">
<span aria-hidden="true" class="absolute inset-0.5 rounded-full bg-[repeating-conic-gradient(from_var(--tcb12-a),transparent_0_8deg,var(--color-tcb12-tick)_8deg_10deg)] [mask:radial-gradient(circle,transparent_62%,#000_63%,#000_74%,transparent_75%)] group-hover:animate-tcb12-ring group-focus-visible:animate-tcb12-ring motion-reduce:animate-none!"></span>
<span aria-hidden="true" class="absolute inset-0 block before:absolute before:top-1/2 before:left-1/2 before:h-[3.5px] before:w-6.5 before:-translate-1/2 before:rotate-45 before:rounded-[2px] before:bg-linear-to-b before:from-tcb12-x1 before:to-tcb12-x2 before:shadow-[0_1px_2px_rgb(0_0_0/0.5)] after:absolute after:top-1/2 after:left-1/2 after:h-[3.5px] after:w-6.5 after:-translate-1/2 after:-rotate-45 after:rounded-[2px] after:bg-linear-to-b after:from-tcb12-x1 after:to-tcb12-x2 after:shadow-[0_1px_2px_rgb(0_0_0/0.5)] group-hover:animate-tcb12-tumble group-focus-visible:animate-tcb12-tumble motion-reduce:animate-none!"></span>
</button>
<p aria-hidden="true" class="rounded-full px-3.5 py-2 font-tcb12-mono text-[11.5px] text-tcb12-mut ring-1 ring-white/12">@property --tcb12-a · group-hover:animate-tcb12-ring · [mask:…]</p>
</div>
</section>@import "tailwindcss";
/* The one rule utilities cannot express: a typed custom property, so keyframes can animate a gradient angle. */
@property --tcb12-a {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@theme {
--color-tcb12-bg: #3a2d1f;
--color-tcb12-bg: oklch(0.32 0.04 60);
--color-tcb12-bg2: #221a12;
--color-tcb12-bg2: oklch(0.2 0.02 50);
--color-tcb12-ba: #d8b56a;
--color-tcb12-ba: oklch(0.78 0.1 85);
--color-tcb12-bb: #8b6d37;
--color-tcb12-bb: oklch(0.55 0.09 75);
--color-tcb12-face1: #574530;
--color-tcb12-face1: oklch(0.42 0.05 70);
--color-tcb12-face2: #2e2419;
--color-tcb12-face2: oklch(0.26 0.03 60);
--color-tcb12-tick: #332818e6;
--color-tcb12-tick: oklch(0.3 0.03 60 / 0.9);
--color-tcb12-x1: #f4e1ae;
--color-tcb12-x1: oklch(0.92 0.06 85);
--color-tcb12-x2: #b79a5e;
--color-tcb12-x2: oklch(0.7 0.08 80);
--color-tcb12-mut: #cdb89a;
--color-tcb12-mut: oklch(0.79 0.04 75);
--font-tcb12-sans: 'Geist', system-ui, sans-serif;
--font-tcb12-mono: 'Geist Mono', ui-monospace, monospace;
--animate-tcb12-tumble: tcb12-tumble 1.1s cubic-bezier(0.3,0,0.2,1) 1 both;
--animate-tcb12-ring: tcb12-ring 2.4s linear infinite;
@keyframes tcb12-tumble {
0% {
transform: rotate(0);
}
14%,
26% {
transform: rotate(-20deg);
}
40%,
52% {
transform: rotate(-38deg);
}
68%,
78% {
transform: rotate(18deg);
}
100% {
transform: rotate(0);
}
}
@keyframes tcb12-ring {
to {
--tcb12-a: -360deg;
}
}
}
/* 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-12 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
/* The one rule utilities cannot express: a typed custom property, so keyframes can animate a gradient angle. */
@property --tcb12-a {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@theme {
--color-tcb12-bg: #3a2d1f;
--color-tcb12-bg: oklch(0.32 0.04 60);
--color-tcb12-bg2: #221a12;
--color-tcb12-bg2: oklch(0.2 0.02 50);
--color-tcb12-ba: #d8b56a;
--color-tcb12-ba: oklch(0.78 0.1 85);
--color-tcb12-bb: #8b6d37;
--color-tcb12-bb: oklch(0.55 0.09 75);
--color-tcb12-face1: #574530;
--color-tcb12-face1: oklch(0.42 0.05 70);
--color-tcb12-face2: #2e2419;
--color-tcb12-face2: oklch(0.26 0.03 60);
--color-tcb12-tick: #332818e6;
--color-tcb12-tick: oklch(0.3 0.03 60 / 0.9);
--color-tcb12-x1: #f4e1ae;
--color-tcb12-x1: oklch(0.92 0.06 85);
--color-tcb12-x2: #b79a5e;
--color-tcb12-x2: oklch(0.7 0.08 80);
--color-tcb12-mut: #cdb89a;
--color-tcb12-mut: oklch(0.79 0.04 75);
--font-tcb12-sans: 'Geist', system-ui, sans-serif;
--font-tcb12-mono: 'Geist Mono', ui-monospace, monospace;
--animate-tcb12-tumble: tcb12-tumble 1.1s cubic-bezier(0.3,0,0.2,1) 1 both;
--animate-tcb12-ring: tcb12-ring 2.4s linear infinite;
@keyframes tcb12-tumble {
0% {
transform: rotate(0);
}
14%,
26% {
transform: rotate(-20deg);
}
40%,
52% {
transform: rotate(-38deg);
}
68%,
78% {
transform: rotate(18deg);
}
100% {
transform: rotate(0);
}
}
@keyframes tcb12-ring {
to {
--tcb12-a: -360deg;
}
}
}
/* 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-12 {
width: 100%;
min-height: 100svh;
}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 custom property, so keyframes can animate a gradient angle. */
@property --tcb12-a {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@theme {
--color-tcb12-bg: #3a2d1f;
--color-tcb12-bg: oklch(0.32 0.04 60);
--color-tcb12-bg2: #221a12;
--color-tcb12-bg2: oklch(0.2 0.02 50);
--color-tcb12-ba: #d8b56a;
--color-tcb12-ba: oklch(0.78 0.1 85);
--color-tcb12-bb: #8b6d37;
--color-tcb12-bb: oklch(0.55 0.09 75);
--color-tcb12-face1: #574530;
--color-tcb12-face1: oklch(0.42 0.05 70);
--color-tcb12-face2: #2e2419;
--color-tcb12-face2: oklch(0.26 0.03 60);
--color-tcb12-tick: #332818e6;
--color-tcb12-tick: oklch(0.3 0.03 60 / 0.9);
--color-tcb12-x1: #f4e1ae;
--color-tcb12-x1: oklch(0.92 0.06 85);
--color-tcb12-x2: #b79a5e;
--color-tcb12-x2: oklch(0.7 0.08 80);
--color-tcb12-mut: #cdb89a;
--color-tcb12-mut: oklch(0.79 0.04 75);
--font-tcb12-sans: 'Geist', system-ui, sans-serif;
--font-tcb12-mono: 'Geist Mono', ui-monospace, monospace;
--animate-tcb12-tumble: tcb12-tumble 1.1s cubic-bezier(0.3,0,0.2,1) 1 both;
--animate-tcb12-ring: tcb12-ring 2.4s linear infinite;
@keyframes tcb12-tumble {
0% {
transform: rotate(0);
}
14%,
26% {
transform: rotate(-20deg);
}
40%,
52% {
transform: rotate(-38deg);
}
68%,
78% {
transform: rotate(18deg);
}
100% {
transform: rotate(0);
}
}
@keyframes tcb12-ring {
to {
--tcb12-a: -360deg;
}
}
}
/* 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-12 {
width: 100%;
min-height: 100svh;
}@import "tailwindcss";
/* The one rule utilities cannot express: a typed custom property, so keyframes can animate a gradient angle. */
@property --tcb12-a {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@theme {
--color-tcb12-bg: #3a2d1f;
--color-tcb12-bg: oklch(0.32 0.04 60);
--color-tcb12-bg2: #221a12;
--color-tcb12-bg2: oklch(0.2 0.02 50);
--color-tcb12-ba: #d8b56a;
--color-tcb12-ba: oklch(0.78 0.1 85);
--color-tcb12-bb: #8b6d37;
--color-tcb12-bb: oklch(0.55 0.09 75);
--color-tcb12-face1: #574530;
--color-tcb12-face1: oklch(0.42 0.05 70);
--color-tcb12-face2: #2e2419;
--color-tcb12-face2: oklch(0.26 0.03 60);
--color-tcb12-tick: #332818e6;
--color-tcb12-tick: oklch(0.3 0.03 60 / 0.9);
--color-tcb12-x1: #f4e1ae;
--color-tcb12-x1: oklch(0.92 0.06 85);
--color-tcb12-x2: #b79a5e;
--color-tcb12-x2: oklch(0.7 0.08 80);
--color-tcb12-mut: #cdb89a;
--color-tcb12-mut: oklch(0.79 0.04 75);
--font-tcb12-sans: 'Geist', system-ui, sans-serif;
--font-tcb12-mono: 'Geist Mono', ui-monospace, monospace;
--animate-tcb12-tumble: tcb12-tumble 1.1s cubic-bezier(0.3,0,0.2,1) 1 both;
--animate-tcb12-ring: tcb12-ring 2.4s linear infinite;
@keyframes tcb12-tumble {
0% {
transform: rotate(0);
}
14%,
26% {
transform: rotate(-20deg);
}
40%,
52% {
transform: rotate(-38deg);
}
68%,
78% {
transform: rotate(18deg);
}
100% {
transform: rotate(0);
}
}
@keyframes tcb12-ring {
to {
--tcb12-a: -360deg;
}
}
}
/* 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-12 {
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.
- Both
@keyframesmoved into@themeas--animate-tcb12-*, so they are applied withgroup-hover:animate-tcb12-tumble; only the@propertyregistration stays CSS. - The prefixed
-webkit-maskpair became one[mask:…]arbitrary property — every browser on v4's floor supports unprefixedmask. - UI upgrade from the CSS original: focusing the dial with the keyboard now plays the same tumble and ring spin, and the reduced-motion override uses
animate-none!so it wins over both hover and focus.
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: Lock Tumbler Tailwind Close Button (@property)
Source: https://codefronts.com/components/tailwind-css-close-buttons/lock-tumble/
A brass combination dial: the × ratchets through hold-heavy keyframes like tumbler clicks while a masked tick ring counter-spins, its angle driven by a typed @property. Both motions are @theme animations — --animate-tcb12-tumble and --animate-tcb12-ring — applied with group-hover:animate-tcb12-*. The @property registration is the one thing that must stay CSS.
## HTML
```html
<section class="tcb-12 w-full min-h-svh grid place-items-center bg-[radial-gradient(120%_120%_at_50%_0%,var(--color-tcb12-bg),var(--color-tcb12-bg2))] px-6 py-12 font-tcb12-sans" aria-label="Lock tumbler close button demo">
<div class="grid w-full justify-items-center gap-7">
<button type="button" aria-label="Close" class="group relative size-21 cursor-pointer rounded-full bg-[conic-gradient(from_210deg,var(--color-tcb12-ba),var(--color-tcb12-bb)_30%,var(--color-tcb12-ba)_52%,var(--color-tcb12-bb)_78%,var(--color-tcb12-ba))] shadow-[0_18px_40px_-16px_rgb(0_0_0/0.75),inset_0_2px_3px_rgb(255_255_255/0.45),inset_0_-3px_6px_rgb(0_0_0/0.4)] before:absolute before:inset-2.5 before:rounded-full before:bg-[radial-gradient(circle_at_32%_28%,var(--color-tcb12-face1),var(--color-tcb12-face2)_70%)] before:shadow-[inset_0_2px_6px_rgb(0_0_0/0.6)] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-tcb12-ba active:scale-94">
<span aria-hidden="true" class="absolute inset-0.5 rounded-full bg-[repeating-conic-gradient(from_var(--tcb12-a),transparent_0_8deg,var(--color-tcb12-tick)_8deg_10deg)] [mask:radial-gradient(circle,transparent_62%,#000_63%,#000_74%,transparent_75%)] group-hover:animate-tcb12-ring group-focus-visible:animate-tcb12-ring motion-reduce:animate-none!"></span>
<span aria-hidden="true" class="absolute inset-0 block before:absolute before:top-1/2 before:left-1/2 before:h-[3.5px] before:w-6.5 before:-translate-1/2 before:rotate-45 before:rounded-[2px] before:bg-linear-to-b before:from-tcb12-x1 before:to-tcb12-x2 before:shadow-[0_1px_2px_rgb(0_0_0/0.5)] after:absolute after:top-1/2 after:left-1/2 after:h-[3.5px] after:w-6.5 after:-translate-1/2 after:-rotate-45 after:rounded-[2px] after:bg-linear-to-b after:from-tcb12-x1 after:to-tcb12-x2 after:shadow-[0_1px_2px_rgb(0_0_0/0.5)] group-hover:animate-tcb12-tumble group-focus-visible:animate-tcb12-tumble motion-reduce:animate-none!"></span>
</button>
<p aria-hidden="true" class="rounded-full px-3.5 py-2 font-tcb12-mono text-[11.5px] text-tcb12-mut ring-1 ring-white/12">@property --tcb12-a · group-hover:animate-tcb12-ring · [mask:…]</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
/* The one rule utilities cannot express: a typed custom property, so keyframes can animate a gradient angle. */
@property --tcb12-a {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@theme {
--color-tcb12-bg: #3a2d1f;
--color-tcb12-bg: oklch(0.32 0.04 60);
--color-tcb12-bg2: #221a12;
--color-tcb12-bg2: oklch(0.2 0.02 50);
--color-tcb12-ba: #d8b56a;
--color-tcb12-ba: oklch(0.78 0.1 85);
--color-tcb12-bb: #8b6d37;
--color-tcb12-bb: oklch(0.55 0.09 75);
--color-tcb12-face1: #574530;
--color-tcb12-face1: oklch(0.42 0.05 70);
--color-tcb12-face2: #2e2419;
--color-tcb12-face2: oklch(0.26 0.03 60);
--color-tcb12-tick: #332818e6;
--color-tcb12-tick: oklch(0.3 0.03 60 / 0.9);
--color-tcb12-x1: #f4e1ae;
--color-tcb12-x1: oklch(0.92 0.06 85);
--color-tcb12-x2: #b79a5e;
--color-tcb12-x2: oklch(0.7 0.08 80);
--color-tcb12-mut: #cdb89a;
--color-tcb12-mut: oklch(0.79 0.04 75);
--font-tcb12-sans: 'Geist', system-ui, sans-serif;
--font-tcb12-mono: 'Geist Mono', ui-monospace, monospace;
--animate-tcb12-tumble: tcb12-tumble 1.1s cubic-bezier(0.3,0,0.2,1) 1 both;
--animate-tcb12-ring: tcb12-ring 2.4s linear infinite;
@keyframes tcb12-tumble {
0% {
transform: rotate(0);
}
14%,
26% {
transform: rotate(-20deg);
}
40%,
52% {
transform: rotate(-38deg);
}
68%,
78% {
transform: rotate(18deg);
}
100% {
transform: rotate(0);
}
}
@keyframes tcb12-ring {
to {
--tcb12-a: -360deg;
}
}
}
/* 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-12 {
width: 100%;
min-height: 100svh;
}
```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: Lock Tumbler Tailwind Close Button (@property)
Source: https://codefronts.com/components/tailwind-css-close-buttons/lock-tumble/
A brass combination dial: the × ratchets through hold-heavy keyframes like tumbler clicks while a masked tick ring counter-spins, its angle driven by a typed @property. Both motions are @theme animations — --animate-tcb12-tumble and --animate-tcb12-ring — applied with group-hover:animate-tcb12-*. The @property registration is the one thing that must stay CSS.
## HTML
```html
<section class="tcb-12 w-full min-h-svh grid place-items-center bg-[radial-gradient(120%_120%_at_50%_0%,var(--color-tcb12-bg),var(--color-tcb12-bg2))] px-6 py-12 font-tcb12-sans" aria-label="Lock tumbler close button demo">
<div class="grid w-full justify-items-center gap-7">
<button type="button" aria-label="Close" class="group relative size-21 cursor-pointer rounded-full bg-[conic-gradient(from_210deg,var(--color-tcb12-ba),var(--color-tcb12-bb)_30%,var(--color-tcb12-ba)_52%,var(--color-tcb12-bb)_78%,var(--color-tcb12-ba))] shadow-[0_18px_40px_-16px_rgb(0_0_0/0.75),inset_0_2px_3px_rgb(255_255_255/0.45),inset_0_-3px_6px_rgb(0_0_0/0.4)] before:absolute before:inset-2.5 before:rounded-full before:bg-[radial-gradient(circle_at_32%_28%,var(--color-tcb12-face1),var(--color-tcb12-face2)_70%)] before:shadow-[inset_0_2px_6px_rgb(0_0_0/0.6)] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-tcb12-ba active:scale-94">
<span aria-hidden="true" class="absolute inset-0.5 rounded-full bg-[repeating-conic-gradient(from_var(--tcb12-a),transparent_0_8deg,var(--color-tcb12-tick)_8deg_10deg)] [mask:radial-gradient(circle,transparent_62%,#000_63%,#000_74%,transparent_75%)] group-hover:animate-tcb12-ring group-focus-visible:animate-tcb12-ring motion-reduce:animate-none!"></span>
<span aria-hidden="true" class="absolute inset-0 block before:absolute before:top-1/2 before:left-1/2 before:h-[3.5px] before:w-6.5 before:-translate-1/2 before:rotate-45 before:rounded-[2px] before:bg-linear-to-b before:from-tcb12-x1 before:to-tcb12-x2 before:shadow-[0_1px_2px_rgb(0_0_0/0.5)] after:absolute after:top-1/2 after:left-1/2 after:h-[3.5px] after:w-6.5 after:-translate-1/2 after:-rotate-45 after:rounded-[2px] after:bg-linear-to-b after:from-tcb12-x1 after:to-tcb12-x2 after:shadow-[0_1px_2px_rgb(0_0_0/0.5)] group-hover:animate-tcb12-tumble group-focus-visible:animate-tcb12-tumble motion-reduce:animate-none!"></span>
</button>
<p aria-hidden="true" class="rounded-full px-3.5 py-2 font-tcb12-mono text-[11.5px] text-tcb12-mut ring-1 ring-white/12">@property --tcb12-a · group-hover:animate-tcb12-ring · [mask:…]</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
/* The one rule utilities cannot express: a typed custom property, so keyframes can animate a gradient angle. */
@property --tcb12-a {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@theme {
--color-tcb12-bg: #3a2d1f;
--color-tcb12-bg: oklch(0.32 0.04 60);
--color-tcb12-bg2: #221a12;
--color-tcb12-bg2: oklch(0.2 0.02 50);
--color-tcb12-ba: #d8b56a;
--color-tcb12-ba: oklch(0.78 0.1 85);
--color-tcb12-bb: #8b6d37;
--color-tcb12-bb: oklch(0.55 0.09 75);
--color-tcb12-face1: #574530;
--color-tcb12-face1: oklch(0.42 0.05 70);
--color-tcb12-face2: #2e2419;
--color-tcb12-face2: oklch(0.26 0.03 60);
--color-tcb12-tick: #332818e6;
--color-tcb12-tick: oklch(0.3 0.03 60 / 0.9);
--color-tcb12-x1: #f4e1ae;
--color-tcb12-x1: oklch(0.92 0.06 85);
--color-tcb12-x2: #b79a5e;
--color-tcb12-x2: oklch(0.7 0.08 80);
--color-tcb12-mut: #cdb89a;
--color-tcb12-mut: oklch(0.79 0.04 75);
--font-tcb12-sans: 'Geist', system-ui, sans-serif;
--font-tcb12-mono: 'Geist Mono', ui-monospace, monospace;
--animate-tcb12-tumble: tcb12-tumble 1.1s cubic-bezier(0.3,0,0.2,1) 1 both;
--animate-tcb12-ring: tcb12-ring 2.4s linear infinite;
@keyframes tcb12-tumble {
0% {
transform: rotate(0);
}
14%,
26% {
transform: rotate(-20deg);
}
40%,
52% {
transform: rotate(-38deg);
}
68%,
78% {
transform: rotate(18deg);
}
100% {
transform: rotate(0);
}
}
@keyframes tcb12-ring {
to {
--tcb12-a: -360deg;
}
}
}
/* 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-12 {
width: 100%;
min-height: 100svh;
}
```How this works
Why @property. A gradient is an image; browsers cannot interpolate it. Registering --tcb12-a as an <angle> makes the variable animatable, and the gradient re-renders from it every frame:
@property --tcb12-a { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
@keyframes tcb12-ring { to { --tcb12-a: -360deg; } }The tumble uses plateaus (14%,26%) so the × pauses at each "click", the way a real dial catches.
Make it yours
- Faster ring: change
2.4sin--animate-tcb12-ring. - Chrome finish: swap the brass tokens for
oklch(0.85 0.01 250)/oklch(0.55 0.01 250). - Tick density:
8deg/10degstops →5deg/6deg.
Gotchas — read before shipping
- Without the
@propertyblock the ring jumps instead of spinning — the rest still works. @keyframesmust live inside@theme(or be referenced by a theme animation) for v4 to emit them.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
@property landed in Firefox 128, which is exactly Tailwind v4's floor.