31 / 47
All 47 in 47 Tailwind CSS Toggles & Switches
- 01Tailwind iOS-Style Toggle Switch with appearance-none
- 02Tailwind Dark Mode Sun / Moon Theme Switcher
- 03Tailwind Neumorphic Soft-UI Pill Toggle
- 04Tailwind Gooey Liquid Morphing Switch with SVG Filter
- 05Tailwind Bouncy Elastic Physics Toggle (Overshoot Cubic-Bezier)
- 06Tailwind ON / OFF Text Switch with Labels Inside the Track
- 07Tailwind SaaS Pricing Toggle — Monthly / Annually Segmented Pill
- 08Tailwind Mechanical Keycap Toggles with Haptic Feedback
- 09Tailwind Waveform Mute Toggle with Animated EQ Bars
- 10Tailwind Timed Auto-Off Toggle with Conic Countdown Ring
- 11Tailwind Press-and-Hold to Confirm Toggle (Fingerprint Scan)
- 12Tailwind Split-Flap Departure Board Toggle (3D Flip)
- 13Tailwind Permission Toggle Group with :has() Live Status Badge
- 14Tailwind Material Design 3 Switch with State Layer
- 15Tailwind Skeuomorphic Wall Light Switch with Room Relight
- 16Tailwind Neon Cyberpunk Toggle with Clipped Corners
- 17Tailwind Brutalist Snap Toggle with Hard Offset Shadow
- 18Tailwind Day / Night Landscape Scene Switch
- 19Tailwind 3D Flip Card Toggle (rotateY Switch)
- 20Tailwind Sliding Pill Toggle with Label Inside the Thumb
- 21Tailwind Glassmorphism Frosted Toggle with backdrop-blur
- 22Tailwind Rotary Knob Toggle with Brushed-Metal Conic Gradient
- 23Tailwind iOS Settings List with Grouped Toggle Rows
- 24Tailwind Minimal Outline Toggle Drawn in currentColor
- 25Tailwind Squash-and-Stretch Pebble Toggle
- 26Tailwind Industrial Rocker I/O Power Switch
- 27Tailwind Accessible role=switch Toggle with forced-colors Support
- 28Tailwind Macro-Pad Toggle Grid with RGB Underglow
- 29Tailwind Radial VU Meter Mute Toggle
- 30Tailwind Segmented Pip Countdown Auto-Off Toggle
- 31Tailwind Slide-to-Confirm Toggle Rail (Drag or Arrow Keys)
- 32Tailwind Odometer Roll ON / OFF Toggle
- 33Tailwind Master Toggle with Indeterminate Tri-State
- 34Tailwind iOS Control Centre Tile Toggles
- 35Tailwind Material 3 Connected Icon Toggle Buttons
- 36Tailwind Two-Gang Wall Plate with Cumulative Room Light
- 37Tailwind Neon Toggle with Animated Conic Border Light
- 38Tailwind Brutalist Rubber-Stamp Toggle
- 39Tailwind Aurora Night Sky Toggle Switch
- 40Tailwind 3D Rotating Cube Toggle
- 41Tailwind Three-Position Off / Auto / On Selector
- 42Tailwind Liquid Glass Toggle with Specular Highlight
- 43Tailwind Three-Detent Rotary Selector Knob
- 44Tailwind Hardware Slide Switch with Knurled Grip
- 45Tailwind Battery Saver Toggle with Live Fill Meter
- 46Tailwind Morphing Sun-to-Moon Icon Toggle (SVG Stroke)
- 47Tailwind Guarded Kill Switch Under a Flip-Up Safety Cover
Tailwind Slide-to-Confirm Toggle Rail (Drag or Arrow Keys)
The hold-to-confirm guard rebuilt as a gesture: drag the handle the full width of the rail to arm, let go early and it springs back. A shimmer sweeps the hint text while it waits, and the whole thing is operable with arrow keys.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="ttg-31 w-full min-h-svh grid place-items-center px-5 py-14 bg-ttg31-bg font-ttg31-sans" aria-label="Slide to confirm toggle">
<div class="w-[min(380px,100%)] flex flex-col gap-5 px-7 py-6 rounded-[22px] bg-ttg31-card border border-ttg31-edge">
<span class="flex flex-col gap-0.5">
<b class="text-[16px] font-bold text-ttg31-ink">Wipe staging database</b>
<span class="text-[12.5px] text-ttg31-mute">Irreversible · slide the handle to confirm</span>
</span>
<div id="ttg-31-rail" class="relative h-14 rounded-2xl overflow-hidden bg-ttg31-rail border border-ttg31-edge data-armed:bg-ttg31-okbg data-armed:border-ttg31-ok data-dragging:[&_.ttg31-hint]:animate-none">
<span aria-hidden="true" class="ttg31-hint absolute inset-0 grid place-items-center text-[13px] font-semibold tracking-[0.14em] uppercase text-ttg31-hint animate-ttg31-shimmer motion-reduce:animate-none">Slide to confirm</span>
<span aria-hidden="true" class="ttg31-done absolute inset-0 grid place-items-center text-[13px] font-bold tracking-[0.14em] uppercase text-ttg31-ok opacity-0 transition-opacity duration-200">Confirmed</span>
<button type="button" id="ttg-31-handle" role="switch" aria-checked="false" aria-label="Slide to confirm wiping the staging database" class="absolute top-1 left-1 h-12 w-12 grid place-items-center rounded-xl border-0 cursor-grab select-none [touch-action:none] bg-ttg31-handle text-ttg31-handleink shadow-[0_4px_12px_-2px_oklch(0_0_0/0.55)] translate-x-[calc(var(--p,0)*1%)] active:cursor-grabbing focus-visible:outline-3 focus-visible:outline-ttg31-focus focus-visible:outline-offset-3">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h13M13 6l6 6-6 6"/></svg>
</button>
</div>
<p id="ttg-31-live" aria-live="polite" class="font-ttg31-mono text-[12px] text-ttg31-mute">Locked</p>
</div>
</section><section class="ttg-31 w-full min-h-svh grid place-items-center px-5 py-14 bg-ttg31-bg font-ttg31-sans" aria-label="Slide to confirm toggle">
<div class="w-[min(380px,100%)] flex flex-col gap-5 px-7 py-6 rounded-[22px] bg-ttg31-card border border-ttg31-edge">
<span class="flex flex-col gap-0.5">
<b class="text-[16px] font-bold text-ttg31-ink">Wipe staging database</b>
<span class="text-[12.5px] text-ttg31-mute">Irreversible · slide the handle to confirm</span>
</span>
<div id="ttg-31-rail" class="relative h-14 rounded-2xl overflow-hidden bg-ttg31-rail border border-ttg31-edge data-armed:bg-ttg31-okbg data-armed:border-ttg31-ok data-dragging:[&_.ttg31-hint]:animate-none">
<span aria-hidden="true" class="ttg31-hint absolute inset-0 grid place-items-center text-[13px] font-semibold tracking-[0.14em] uppercase text-ttg31-hint animate-ttg31-shimmer motion-reduce:animate-none">Slide to confirm</span>
<span aria-hidden="true" class="ttg31-done absolute inset-0 grid place-items-center text-[13px] font-bold tracking-[0.14em] uppercase text-ttg31-ok opacity-0 transition-opacity duration-200">Confirmed</span>
<button type="button" id="ttg-31-handle" role="switch" aria-checked="false" aria-label="Slide to confirm wiping the staging database" class="absolute top-1 left-1 h-12 w-12 grid place-items-center rounded-xl border-0 cursor-grab select-none [touch-action:none] bg-ttg31-handle text-ttg31-handleink shadow-[0_4px_12px_-2px_oklch(0_0_0/0.55)] translate-x-[calc(var(--p,0)*1%)] active:cursor-grabbing focus-visible:outline-3 focus-visible:outline-ttg31-focus focus-visible:outline-offset-3">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h13M13 6l6 6-6 6"/></svg>
</button>
</div>
<p id="ttg-31-live" aria-live="polite" class="font-ttg31-mono text-[12px] text-ttg31-mute">Locked</p>
</div>
</section>@import "tailwindcss";
@theme {
--color-ttg31-bg: #111319;
--color-ttg31-bg: oklch(0.19 0.008 265);
--color-ttg31-card: #1a1d25;
--color-ttg31-card: oklch(0.24 0.01 265);
--color-ttg31-edge: #2a2f3b;
--color-ttg31-edge: oklch(0.31 0.012 265);
--color-ttg31-rail: #202530;
--color-ttg31-rail: oklch(0.27 0.012 265);
--color-ttg31-handle: #e9edf5;
--color-ttg31-handle: oklch(0.94 0.006 265);
--color-ttg31-handleink: #1d2230;
--color-ttg31-handleink: oklch(0.25 0.015 265);
--color-ttg31-hint: #8d95a6;
--color-ttg31-hint: oklch(0.66 0.015 265);
--color-ttg31-ok: #4fd398;
--color-ttg31-ok: oklch(0.79 0.15 160);
--color-ttg31-okbg: #17352c;
--color-ttg31-okbg: oklch(0.3 0.06 165);
--color-ttg31-ink: #e8ebf3;
--color-ttg31-ink: oklch(0.93 0.008 265);
--color-ttg31-mute: #939bad;
--color-ttg31-mute: oklch(0.68 0.015 265);
--color-ttg31-focus: #6cb6f0;
--color-ttg31-focus: oklch(0.74 0.14 235);
--font-ttg31-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttg31-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--animate-ttg31-shimmer: ttg31-shimmer 2.4s ease-in-out infinite;
}
@keyframes ttg31-shimmer {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.ttg-31 {
width: 100%;
min-height: 100svh;
}
/* The handle's travel is a live pointer value, so the percentage lives on the rail as a
custom property; only the release animates. */
#ttg-31-handle {
transition: transform 0s;
}
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 250ms cubic-bezier(0.3, 1, 0.35, 1.05);
}
#ttg-31-rail[data-armed] .ttg31-hint {
animation: none;
opacity: 0;
transition: opacity 150ms linear;
}
#ttg-31-rail[data-armed] .ttg31-done {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 80ms linear;
}
}@import "tailwindcss";
@theme {
--color-ttg31-bg: #111319;
--color-ttg31-bg: oklch(0.19 0.008 265);
--color-ttg31-card: #1a1d25;
--color-ttg31-card: oklch(0.24 0.01 265);
--color-ttg31-edge: #2a2f3b;
--color-ttg31-edge: oklch(0.31 0.012 265);
--color-ttg31-rail: #202530;
--color-ttg31-rail: oklch(0.27 0.012 265);
--color-ttg31-handle: #e9edf5;
--color-ttg31-handle: oklch(0.94 0.006 265);
--color-ttg31-handleink: #1d2230;
--color-ttg31-handleink: oklch(0.25 0.015 265);
--color-ttg31-hint: #8d95a6;
--color-ttg31-hint: oklch(0.66 0.015 265);
--color-ttg31-ok: #4fd398;
--color-ttg31-ok: oklch(0.79 0.15 160);
--color-ttg31-okbg: #17352c;
--color-ttg31-okbg: oklch(0.3 0.06 165);
--color-ttg31-ink: #e8ebf3;
--color-ttg31-ink: oklch(0.93 0.008 265);
--color-ttg31-mute: #939bad;
--color-ttg31-mute: oklch(0.68 0.015 265);
--color-ttg31-focus: #6cb6f0;
--color-ttg31-focus: oklch(0.74 0.14 235);
--font-ttg31-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttg31-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--animate-ttg31-shimmer: ttg31-shimmer 2.4s ease-in-out infinite;
}
@keyframes ttg31-shimmer {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.ttg-31 {
width: 100%;
min-height: 100svh;
}
/* The handle's travel is a live pointer value, so the percentage lives on the rail as a
custom property; only the release animates. */
#ttg-31-handle {
transition: transform 0s;
}
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 250ms cubic-bezier(0.3, 1, 0.35, 1.05);
}
#ttg-31-rail[data-armed] .ttg31-hint {
animation: none;
opacity: 0;
transition: opacity 150ms linear;
}
#ttg-31-rail[data-armed] .ttg31-done {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 80ms linear;
}
}(() => {
const COMMIT = 0.92;
const rail = document.querySelector('#ttg-31-rail');
const handle = document.querySelector('#ttg-31-handle');
const live = document.querySelector('#ttg-31-live');
if (!rail || !handle || !live) return;
const maxPct = () => ((rail.clientWidth - handle.offsetWidth - 8) / handle.offsetWidth) * 100;
const set = (pct) => handle.style.setProperty('--p', String(pct));
const commit = (on) => {
handle.setAttribute('aria-checked', String(on));
rail.toggleAttribute('data-armed', on);
set(on ? maxPct() : 0);
live.textContent = on ? 'Confirmed \u2014 release to run' : 'Locked';
};
let dragging = false, startX = 0;
handle.addEventListener('pointerdown', (e) => {
if (handle.getAttribute('aria-checked') === 'true') { commit(false); return; }
dragging = true; startX = e.clientX;
handle.setPointerCapture(e.pointerId);
rail.setAttribute('data-dragging', '');
});
handle.addEventListener('pointermove', (e) => {
if (!dragging) return;
const travel = rail.clientWidth - handle.offsetWidth - 8;
const px = Math.min(Math.max(0, e.clientX - startX), travel);
set((px / handle.offsetWidth) * 100);
});
const end = () => {
if (!dragging) return;
dragging = false;
rail.removeAttribute('data-dragging');
const travel = rail.clientWidth - handle.offsetWidth - 8;
const px = (parseFloat(handle.style.getPropertyValue('--p')) || 0) / 100 * handle.offsetWidth;
commit(px >= travel * COMMIT);
};
['pointerup', 'pointercancel'].forEach((t) => handle.addEventListener(t, end));
handle.addEventListener('keydown', (e) => {
if (['ArrowRight', 'ArrowUp', 'End'].includes(e.key)) { e.preventDefault(); commit(true); }
if (['ArrowLeft', 'ArrowDown', 'Home'].includes(e.key)) { e.preventDefault(); commit(false); }
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); commit(handle.getAttribute('aria-checked') !== 'true'); }
});
window.addEventListener('resize', () => { if (handle.getAttribute('aria-checked') === 'true') set(maxPct()); });
})();(() => {
const COMMIT = 0.92;
const rail = document.querySelector('#ttg-31-rail');
const handle = document.querySelector('#ttg-31-handle');
const live = document.querySelector('#ttg-31-live');
if (!rail || !handle || !live) return;
const maxPct = () => ((rail.clientWidth - handle.offsetWidth - 8) / handle.offsetWidth) * 100;
const set = (pct) => handle.style.setProperty('--p', String(pct));
const commit = (on) => {
handle.setAttribute('aria-checked', String(on));
rail.toggleAttribute('data-armed', on);
set(on ? maxPct() : 0);
live.textContent = on ? 'Confirmed \u2014 release to run' : 'Locked';
};
let dragging = false, startX = 0;
handle.addEventListener('pointerdown', (e) => {
if (handle.getAttribute('aria-checked') === 'true') { commit(false); return; }
dragging = true; startX = e.clientX;
handle.setPointerCapture(e.pointerId);
rail.setAttribute('data-dragging', '');
});
handle.addEventListener('pointermove', (e) => {
if (!dragging) return;
const travel = rail.clientWidth - handle.offsetWidth - 8;
const px = Math.min(Math.max(0, e.clientX - startX), travel);
set((px / handle.offsetWidth) * 100);
});
const end = () => {
if (!dragging) return;
dragging = false;
rail.removeAttribute('data-dragging');
const travel = rail.clientWidth - handle.offsetWidth - 8;
const px = (parseFloat(handle.style.getPropertyValue('--p')) || 0) / 100 * handle.offsetWidth;
commit(px >= travel * COMMIT);
};
['pointerup', 'pointercancel'].forEach((t) => handle.addEventListener(t, end));
handle.addEventListener('keydown', (e) => {
if (['ArrowRight', 'ArrowUp', 'End'].includes(e.key)) { e.preventDefault(); commit(true); }
if (['ArrowLeft', 'ArrowDown', 'Home'].includes(e.key)) { e.preventDefault(); commit(false); }
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); commit(handle.getAttribute('aria-checked') !== 'true'); }
});
window.addEventListener('resize', () => { if (handle.getAttribute('aria-checked') === 'true') set(maxPct()); });
})();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-ttg31-bg: #111319;
--color-ttg31-bg: oklch(0.19 0.008 265);
--color-ttg31-card: #1a1d25;
--color-ttg31-card: oklch(0.24 0.01 265);
--color-ttg31-edge: #2a2f3b;
--color-ttg31-edge: oklch(0.31 0.012 265);
--color-ttg31-rail: #202530;
--color-ttg31-rail: oklch(0.27 0.012 265);
--color-ttg31-handle: #e9edf5;
--color-ttg31-handle: oklch(0.94 0.006 265);
--color-ttg31-handleink: #1d2230;
--color-ttg31-handleink: oklch(0.25 0.015 265);
--color-ttg31-hint: #8d95a6;
--color-ttg31-hint: oklch(0.66 0.015 265);
--color-ttg31-ok: #4fd398;
--color-ttg31-ok: oklch(0.79 0.15 160);
--color-ttg31-okbg: #17352c;
--color-ttg31-okbg: oklch(0.3 0.06 165);
--color-ttg31-ink: #e8ebf3;
--color-ttg31-ink: oklch(0.93 0.008 265);
--color-ttg31-mute: #939bad;
--color-ttg31-mute: oklch(0.68 0.015 265);
--color-ttg31-focus: #6cb6f0;
--color-ttg31-focus: oklch(0.74 0.14 235);
--font-ttg31-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttg31-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--animate-ttg31-shimmer: ttg31-shimmer 2.4s ease-in-out infinite;
}
@keyframes ttg31-shimmer {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.ttg-31 {
width: 100%;
min-height: 100svh;
}
/* The handle's travel is a live pointer value, so the percentage lives on the rail as a
custom property; only the release animates. */
#ttg-31-handle {
transition: transform 0s;
}
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 250ms cubic-bezier(0.3, 1, 0.35, 1.05);
}
#ttg-31-rail[data-armed] .ttg31-hint {
animation: none;
opacity: 0;
transition: opacity 150ms linear;
}
#ttg-31-rail[data-armed] .ttg31-done {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 80ms linear;
}
}@import "tailwindcss";
@theme {
--color-ttg31-bg: #111319;
--color-ttg31-bg: oklch(0.19 0.008 265);
--color-ttg31-card: #1a1d25;
--color-ttg31-card: oklch(0.24 0.01 265);
--color-ttg31-edge: #2a2f3b;
--color-ttg31-edge: oklch(0.31 0.012 265);
--color-ttg31-rail: #202530;
--color-ttg31-rail: oklch(0.27 0.012 265);
--color-ttg31-handle: #e9edf5;
--color-ttg31-handle: oklch(0.94 0.006 265);
--color-ttg31-handleink: #1d2230;
--color-ttg31-handleink: oklch(0.25 0.015 265);
--color-ttg31-hint: #8d95a6;
--color-ttg31-hint: oklch(0.66 0.015 265);
--color-ttg31-ok: #4fd398;
--color-ttg31-ok: oklch(0.79 0.15 160);
--color-ttg31-okbg: #17352c;
--color-ttg31-okbg: oklch(0.3 0.06 165);
--color-ttg31-ink: #e8ebf3;
--color-ttg31-ink: oklch(0.93 0.008 265);
--color-ttg31-mute: #939bad;
--color-ttg31-mute: oklch(0.68 0.015 265);
--color-ttg31-focus: #6cb6f0;
--color-ttg31-focus: oklch(0.74 0.14 235);
--font-ttg31-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttg31-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--animate-ttg31-shimmer: ttg31-shimmer 2.4s ease-in-out infinite;
}
@keyframes ttg31-shimmer {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.ttg-31 {
width: 100%;
min-height: 100svh;
}
/* The handle's travel is a live pointer value, so the percentage lives on the rail as a
custom property; only the release animates. */
#ttg-31-handle {
transition: transform 0s;
}
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 250ms cubic-bezier(0.3, 1, 0.35, 1.05);
}
#ttg-31-rail[data-armed] .ttg31-hint {
animation: none;
opacity: 0;
transition: opacity 150ms linear;
}
#ttg-31-rail[data-armed] .ttg31-done {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 80ms linear;
}
}<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=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&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=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- This is the one demo where a live value legitimately drives a utility:
translate-x-[calc(var(--p,0)*1%)]reads a number the pointer handler writes every frame. Everything else about the control — armed, dragging, confirmed — is still attribute-driven. - Two data attributes carry two different ideas:
data-dragging(transient, suppresses the return transition and the shimmer) anddata-armed(committed state). Collapsing them into one would make the handle lag the finger. - The transition rules stayed in CSS because they are conditional on the absence of an attribute —
:not([data-dragging])— which is exactly the shape variants make less readable, not more. - UI upgrade over demo 11: a gesture with visible progress beats a hold with a hidden timer, the rail states its own instruction and result in words, and arrow keys are a first-class path rather than an afterthought.
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 Slide-to-Confirm Toggle Rail (Drag or Arrow Keys)
Source: https://codefronts.com/components/tailwind-css-toggles-switches/tailwind-slide-to-confirm-toggle-rail/
The hold-to-confirm guard rebuilt as a gesture: drag the handle the full width of the rail to arm, let go early and it springs back. A shimmer sweeps the hint text while it waits, and the whole thing is operable with arrow keys.
## HTML
```html
<section class="ttg-31 w-full min-h-svh grid place-items-center px-5 py-14 bg-ttg31-bg font-ttg31-sans" aria-label="Slide to confirm toggle">
<div class="w-[min(380px,100%)] flex flex-col gap-5 px-7 py-6 rounded-[22px] bg-ttg31-card border border-ttg31-edge">
<span class="flex flex-col gap-0.5">
<b class="text-[16px] font-bold text-ttg31-ink">Wipe staging database</b>
<span class="text-[12.5px] text-ttg31-mute">Irreversible · slide the handle to confirm</span>
</span>
<div id="ttg-31-rail" class="relative h-14 rounded-2xl overflow-hidden bg-ttg31-rail border border-ttg31-edge data-armed:bg-ttg31-okbg data-armed:border-ttg31-ok data-dragging:[&_.ttg31-hint]:animate-none">
<span aria-hidden="true" class="ttg31-hint absolute inset-0 grid place-items-center text-[13px] font-semibold tracking-[0.14em] uppercase text-ttg31-hint animate-ttg31-shimmer motion-reduce:animate-none">Slide to confirm</span>
<span aria-hidden="true" class="ttg31-done absolute inset-0 grid place-items-center text-[13px] font-bold tracking-[0.14em] uppercase text-ttg31-ok opacity-0 transition-opacity duration-200">Confirmed</span>
<button type="button" id="ttg-31-handle" role="switch" aria-checked="false" aria-label="Slide to confirm wiping the staging database" class="absolute top-1 left-1 h-12 w-12 grid place-items-center rounded-xl border-0 cursor-grab select-none [touch-action:none] bg-ttg31-handle text-ttg31-handleink shadow-[0_4px_12px_-2px_oklch(0_0_0/0.55)] translate-x-[calc(var(--p,0)*1%)] active:cursor-grabbing focus-visible:outline-3 focus-visible:outline-ttg31-focus focus-visible:outline-offset-3">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h13M13 6l6 6-6 6"/></svg>
</button>
</div>
<p id="ttg-31-live" aria-live="polite" class="font-ttg31-mono text-[12px] text-ttg31-mute">Locked</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttg31-bg: #111319;
--color-ttg31-bg: oklch(0.19 0.008 265);
--color-ttg31-card: #1a1d25;
--color-ttg31-card: oklch(0.24 0.01 265);
--color-ttg31-edge: #2a2f3b;
--color-ttg31-edge: oklch(0.31 0.012 265);
--color-ttg31-rail: #202530;
--color-ttg31-rail: oklch(0.27 0.012 265);
--color-ttg31-handle: #e9edf5;
--color-ttg31-handle: oklch(0.94 0.006 265);
--color-ttg31-handleink: #1d2230;
--color-ttg31-handleink: oklch(0.25 0.015 265);
--color-ttg31-hint: #8d95a6;
--color-ttg31-hint: oklch(0.66 0.015 265);
--color-ttg31-ok: #4fd398;
--color-ttg31-ok: oklch(0.79 0.15 160);
--color-ttg31-okbg: #17352c;
--color-ttg31-okbg: oklch(0.3 0.06 165);
--color-ttg31-ink: #e8ebf3;
--color-ttg31-ink: oklch(0.93 0.008 265);
--color-ttg31-mute: #939bad;
--color-ttg31-mute: oklch(0.68 0.015 265);
--color-ttg31-focus: #6cb6f0;
--color-ttg31-focus: oklch(0.74 0.14 235);
--font-ttg31-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttg31-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--animate-ttg31-shimmer: ttg31-shimmer 2.4s ease-in-out infinite;
}
@keyframes ttg31-shimmer {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.ttg-31 {
width: 100%;
min-height: 100svh;
}
/* The handle's travel is a live pointer value, so the percentage lives on the rail as a
custom property; only the release animates. */
#ttg-31-handle {
transition: transform 0s;
}
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 250ms cubic-bezier(0.3, 1, 0.35, 1.05);
}
#ttg-31-rail[data-armed] .ttg31-hint {
animation: none;
opacity: 0;
transition: opacity 150ms linear;
}
#ttg-31-rail[data-armed] .ttg31-done {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 80ms linear;
}
}
```
## JavaScript
```js
(() => {
const COMMIT = 0.92;
const rail = document.querySelector('#ttg-31-rail');
const handle = document.querySelector('#ttg-31-handle');
const live = document.querySelector('#ttg-31-live');
if (!rail || !handle || !live) return;
const maxPct = () => ((rail.clientWidth - handle.offsetWidth - 8) / handle.offsetWidth) * 100;
const set = (pct) => handle.style.setProperty('--p', String(pct));
const commit = (on) => {
handle.setAttribute('aria-checked', String(on));
rail.toggleAttribute('data-armed', on);
set(on ? maxPct() : 0);
live.textContent = on ? 'Confirmed \u2014 release to run' : 'Locked';
};
let dragging = false, startX = 0;
handle.addEventListener('pointerdown', (e) => {
if (handle.getAttribute('aria-checked') === 'true') { commit(false); return; }
dragging = true; startX = e.clientX;
handle.setPointerCapture(e.pointerId);
rail.setAttribute('data-dragging', '');
});
handle.addEventListener('pointermove', (e) => {
if (!dragging) return;
const travel = rail.clientWidth - handle.offsetWidth - 8;
const px = Math.min(Math.max(0, e.clientX - startX), travel);
set((px / handle.offsetWidth) * 100);
});
const end = () => {
if (!dragging) return;
dragging = false;
rail.removeAttribute('data-dragging');
const travel = rail.clientWidth - handle.offsetWidth - 8;
const px = (parseFloat(handle.style.getPropertyValue('--p')) || 0) / 100 * handle.offsetWidth;
commit(px >= travel * COMMIT);
};
['pointerup', 'pointercancel'].forEach((t) => handle.addEventListener(t, end));
handle.addEventListener('keydown', (e) => {
if (['ArrowRight', 'ArrowUp', 'End'].includes(e.key)) { e.preventDefault(); commit(true); }
if (['ArrowLeft', 'ArrowDown', 'Home'].includes(e.key)) { e.preventDefault(); commit(false); }
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); commit(handle.getAttribute('aria-checked') !== 'true'); }
});
window.addEventListener('resize', () => { if (handle.getAttribute('aria-checked') === 'true') set(maxPct()); });
})();
```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 Slide-to-Confirm Toggle Rail (Drag or Arrow Keys)
Source: https://codefronts.com/components/tailwind-css-toggles-switches/tailwind-slide-to-confirm-toggle-rail/
The hold-to-confirm guard rebuilt as a gesture: drag the handle the full width of the rail to arm, let go early and it springs back. A shimmer sweeps the hint text while it waits, and the whole thing is operable with arrow keys.
## HTML
```html
<section class="ttg-31 w-full min-h-svh grid place-items-center px-5 py-14 bg-ttg31-bg font-ttg31-sans" aria-label="Slide to confirm toggle">
<div class="w-[min(380px,100%)] flex flex-col gap-5 px-7 py-6 rounded-[22px] bg-ttg31-card border border-ttg31-edge">
<span class="flex flex-col gap-0.5">
<b class="text-[16px] font-bold text-ttg31-ink">Wipe staging database</b>
<span class="text-[12.5px] text-ttg31-mute">Irreversible · slide the handle to confirm</span>
</span>
<div id="ttg-31-rail" class="relative h-14 rounded-2xl overflow-hidden bg-ttg31-rail border border-ttg31-edge data-armed:bg-ttg31-okbg data-armed:border-ttg31-ok data-dragging:[&_.ttg31-hint]:animate-none">
<span aria-hidden="true" class="ttg31-hint absolute inset-0 grid place-items-center text-[13px] font-semibold tracking-[0.14em] uppercase text-ttg31-hint animate-ttg31-shimmer motion-reduce:animate-none">Slide to confirm</span>
<span aria-hidden="true" class="ttg31-done absolute inset-0 grid place-items-center text-[13px] font-bold tracking-[0.14em] uppercase text-ttg31-ok opacity-0 transition-opacity duration-200">Confirmed</span>
<button type="button" id="ttg-31-handle" role="switch" aria-checked="false" aria-label="Slide to confirm wiping the staging database" class="absolute top-1 left-1 h-12 w-12 grid place-items-center rounded-xl border-0 cursor-grab select-none [touch-action:none] bg-ttg31-handle text-ttg31-handleink shadow-[0_4px_12px_-2px_oklch(0_0_0/0.55)] translate-x-[calc(var(--p,0)*1%)] active:cursor-grabbing focus-visible:outline-3 focus-visible:outline-ttg31-focus focus-visible:outline-offset-3">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h13M13 6l6 6-6 6"/></svg>
</button>
</div>
<p id="ttg-31-live" aria-live="polite" class="font-ttg31-mono text-[12px] text-ttg31-mute">Locked</p>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-ttg31-bg: #111319;
--color-ttg31-bg: oklch(0.19 0.008 265);
--color-ttg31-card: #1a1d25;
--color-ttg31-card: oklch(0.24 0.01 265);
--color-ttg31-edge: #2a2f3b;
--color-ttg31-edge: oklch(0.31 0.012 265);
--color-ttg31-rail: #202530;
--color-ttg31-rail: oklch(0.27 0.012 265);
--color-ttg31-handle: #e9edf5;
--color-ttg31-handle: oklch(0.94 0.006 265);
--color-ttg31-handleink: #1d2230;
--color-ttg31-handleink: oklch(0.25 0.015 265);
--color-ttg31-hint: #8d95a6;
--color-ttg31-hint: oklch(0.66 0.015 265);
--color-ttg31-ok: #4fd398;
--color-ttg31-ok: oklch(0.79 0.15 160);
--color-ttg31-okbg: #17352c;
--color-ttg31-okbg: oklch(0.3 0.06 165);
--color-ttg31-ink: #e8ebf3;
--color-ttg31-ink: oklch(0.93 0.008 265);
--color-ttg31-mute: #939bad;
--color-ttg31-mute: oklch(0.68 0.015 265);
--color-ttg31-focus: #6cb6f0;
--color-ttg31-focus: oklch(0.74 0.14 235);
--font-ttg31-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-ttg31-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--animate-ttg31-shimmer: ttg31-shimmer 2.4s ease-in-out infinite;
}
@keyframes ttg31-shimmer {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.ttg-31 {
width: 100%;
min-height: 100svh;
}
/* The handle's travel is a live pointer value, so the percentage lives on the rail as a
custom property; only the release animates. */
#ttg-31-handle {
transition: transform 0s;
}
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 250ms cubic-bezier(0.3, 1, 0.35, 1.05);
}
#ttg-31-rail[data-armed] .ttg31-hint {
animation: none;
opacity: 0;
transition: opacity 150ms linear;
}
#ttg-31-rail[data-armed] .ttg31-done {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
#ttg-31-rail:not([data-dragging]) #ttg-31-handle {
transition: transform 80ms linear;
}
}
```
## JavaScript
```js
(() => {
const COMMIT = 0.92;
const rail = document.querySelector('#ttg-31-rail');
const handle = document.querySelector('#ttg-31-handle');
const live = document.querySelector('#ttg-31-live');
if (!rail || !handle || !live) return;
const maxPct = () => ((rail.clientWidth - handle.offsetWidth - 8) / handle.offsetWidth) * 100;
const set = (pct) => handle.style.setProperty('--p', String(pct));
const commit = (on) => {
handle.setAttribute('aria-checked', String(on));
rail.toggleAttribute('data-armed', on);
set(on ? maxPct() : 0);
live.textContent = on ? 'Confirmed \u2014 release to run' : 'Locked';
};
let dragging = false, startX = 0;
handle.addEventListener('pointerdown', (e) => {
if (handle.getAttribute('aria-checked') === 'true') { commit(false); return; }
dragging = true; startX = e.clientX;
handle.setPointerCapture(e.pointerId);
rail.setAttribute('data-dragging', '');
});
handle.addEventListener('pointermove', (e) => {
if (!dragging) return;
const travel = rail.clientWidth - handle.offsetWidth - 8;
const px = Math.min(Math.max(0, e.clientX - startX), travel);
set((px / handle.offsetWidth) * 100);
});
const end = () => {
if (!dragging) return;
dragging = false;
rail.removeAttribute('data-dragging');
const travel = rail.clientWidth - handle.offsetWidth - 8;
const px = (parseFloat(handle.style.getPropertyValue('--p')) || 0) / 100 * handle.offsetWidth;
commit(px >= travel * COMMIT);
};
['pointerup', 'pointercancel'].forEach((t) => handle.addEventListener(t, end));
handle.addEventListener('keydown', (e) => {
if (['ArrowRight', 'ArrowUp', 'End'].includes(e.key)) { e.preventDefault(); commit(true); }
if (['ArrowLeft', 'ArrowDown', 'Home'].includes(e.key)) { e.preventDefault(); commit(false); }
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); commit(handle.getAttribute('aria-checked') !== 'true'); }
});
window.addEventListener('resize', () => { if (handle.getAttribute('aria-checked') === 'true') set(maxPct()); });
})();
```How this works
The gesture is three handlers and one number. Pointer-down captures, pointer-move writes a 0–100 progress onto the rail as --p, pointer-up either commits (past 92%) or animates back to zero. The handle's position is translate-x driven by that property, so nothing lays out during the drag.
Keyboard parity is a first-class path, not a fallback. Right/Up arrow or End commits; Left/Down or Home releases. This is why the control is a role="switch" button rather than a slider: the outcome is binary, the gesture is just one way to reach it.
Springing back is the confirmation. Releasing early transitions --p home over 250ms, which reads as the control refusing — far clearer than simply doing nothing.
Make it yours
- Commit threshold: the
0.92constant. Lower it to 0.8 for a friendlier rail. - Reverse to a slide-to-disarm by inverting the committed state's copy and colours.
- Add
navigator.vibrate?.(14)on commit for a physical latch. - Swap the shimmer for an arrow chevron trio marching right — same idea, less bling.
Gotchas — read before shipping
setPointerCaptureis required, or dragging off the rail strands the handle mid-travel.[touch-action:none]on the handle, or mobile scrolls instead of dragging.- Never leave the gesture as the only path — drag-only controls are unusable by keyboard and by most switch-access hardware.
- Do not transition
--pduring the drag, only on release; a transition during pointer-move makes the handle lag the finger.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Pointer Events and custom properties in transforms are universal in evergreen browsers; the shimmer mask degrades to a plain hint line.