Tailwind v4MIT licensed

Tailwind Liquid Gooey Toggle Switch

A handle that behaves like liquid metal: it stretches as it travels, fuses with the track blob, then snaps into place. The whole state machine is peer plus peer-checked: on a native checkbox, and the only thing utilities cannot express — the SVG goo filter and its stretch keyframe — stays in the stylesheet where it belongs.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="lgt-04 grid w-full min-h-svh place-items-center p-15 font-lgt04-sans [background:radial-gradient(circle_at_30%_20%,#2a2740,#12101d)]" aria-label="Tailwind liquid gooey toggle switch">
  <svg width="0" height="0" aria-hidden="true" focusable="false" class="absolute">
    <filter id="lgt-04-goo">
      <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="b"/>
      <feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo"/>
      <feBlend in="SourceGraphic" in2="goo"/>
    </filter>
  </svg>
  <label class="inline-flex cursor-pointer items-center gap-3.5 text-[22px] text-lgt04-fg">
    <input type="checkbox" checked class="peer sr-only">
    <span aria-hidden="true" class="relative h-[1.7em] w-[3.4em] rounded-full border border-white/25 bg-lgt04-off backdrop-blur-[6px] transition-colors duration-350 shadow-[inset_0_2px_8px_rgba(0,0,0,.45),inset_0_1px_0_rgba(255,255,255,.25)] peer-checked:bg-lgt04-on peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-white peer-checked:[&amp;_.lgt-04-fixed]:opacity-0 peer-checked:[&amp;_.lgt-04-handle]:left-[2em] peer-checked:[&amp;_.lgt-04-handle]:animate-lgt04-stretch motion-reduce:[&amp;_.lgt-04-handle]:animate-none">
      <span class="absolute inset-0 [filter:url(#lgt-04-goo)]">
        <span class="lgt-04-fixed absolute top-1/2 left-[0.28em] size-[1.14em] -translate-y-1/2 rounded-full bg-white opacity-90 transition-opacity delay-200 duration-300"></span>
        <span class="lgt-04-handle absolute top-1/2 left-[0.28em] size-[1.14em] -translate-y-1/2 rounded-full bg-white shadow-[0_2px_6px_rgba(0,0,0,.35)] transition-[left] duration-400 ease-lgt04"></span>
      </span>
    </span>
    <span class="text-[0.85em] font-semibold">Liquid mode</span>
  </label>
</section>
@import "tailwindcss";

@theme {
  --color-lgt04-on: #34d3a4;
  --color-lgt04-on: oklch(0.7 0.17 165);
  --color-lgt04-off: #6c6a85;
  --color-lgt04-off: oklch(0.55 0.03 260);
  --color-lgt04-fg: #eceaf5;
  --font-lgt04-sans: 'Manrope', system-ui, sans-serif;
  --ease-lgt04: cubic-bezier(0.6, -0.4, 0.4, 1.4);
  --animate-lgt04-stretch: lgt-04-stretch 0.4s ease;
}
/* The two things utilities cannot express: a keyframe, and the -50% Y that
   must survive inside the scaleX so the handle stays vertically centred. */

@keyframes lgt-04-stretch {
  0% {
    transform: translateY(-50%) scaleX(1);
  }

  45% {
    transform: translateY(-50%) scaleX(1.55);
  }

  100% {
    transform: translateY(-50%) scaleX(1);
  }
}

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-lgt04-on: #34d3a4;
  --color-lgt04-on: oklch(0.7 0.17 165);
  --color-lgt04-off: #6c6a85;
  --color-lgt04-off: oklch(0.55 0.03 260);
  --color-lgt04-fg: #eceaf5;
  --font-lgt04-sans: 'Manrope', system-ui, sans-serif;
  --ease-lgt04: cubic-bezier(0.6, -0.4, 0.4, 1.4);
  --animate-lgt04-stretch: lgt-04-stretch 0.4s ease;
}
/* The two things utilities cannot express: a keyframe, and the -50% Y that
   must survive inside the scaleX so the handle stays vertically centred. */

@keyframes lgt-04-stretch {
  0% {
    transform: translateY(-50%) scaleX(1);
  }

  45% {
    transform: translateY(-50%) scaleX(1.55);
  }

  100% {
    transform: translateY(-50%) scaleX(1);
  }
}
3Fonts used by this demooptional — skip if you have your own type stack
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;800&display=swap">

What changed in the port

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

  • A native checkbox with peer replaces every :checked + selector in the CSS version; the state lives in the control, not in a class.
  • peer-checked:[&_.lgt-04-handle]:left-[2em] is the child-selector escape — peer-* reaches siblings, and the handle is a descendant of one.
  • The stretch keyframe becomes an --animate-* theme entry, so the markup says animate-lgt04-stretch and the timing is defined once.
  • The SVG goo filter stays in the markup with a namespaced id, and one arbitrary [filter:url(#lgt-04-goo)] utility attaches it to the blob layer only.
  • sr-only is load-bearing: it clips the input without removing it from the focus order, which is the difference between an accessible switch and a decorative one.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Liquid Gooey Toggle Switch
Source: https://codefronts.com/design-styles/tailwind-liquid-glass-effects/tailwind-liquid-gooey-toggle-switch/

A handle that behaves like liquid metal: it stretches as it travels, fuses with the track blob, then snaps into place. The whole state machine is peer plus peer-checked: on a native checkbox, and the only thing utilities cannot express &mdash; the SVG goo filter and its stretch keyframe &mdash; stays in the stylesheet where it belongs.
## HTML
```html
<section class="lgt-04 grid w-full min-h-svh place-items-center p-15 font-lgt04-sans [background:radial-gradient(circle_at_30%_20%,#2a2740,#12101d)]" aria-label="Tailwind liquid gooey toggle switch">
  <svg width="0" height="0" aria-hidden="true" focusable="false" class="absolute">
    <filter id="lgt-04-goo">
      <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="b"/>
      <feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo"/>
      <feBlend in="SourceGraphic" in2="goo"/>
    </filter>
  </svg>
  <label class="inline-flex cursor-pointer items-center gap-3.5 text-[22px] text-lgt04-fg">
    <input type="checkbox" checked class="peer sr-only">
    <span aria-hidden="true" class="relative h-[1.7em] w-[3.4em] rounded-full border border-white/25 bg-lgt04-off backdrop-blur-[6px] transition-colors duration-350 shadow-[inset_0_2px_8px_rgba(0,0,0,.45),inset_0_1px_0_rgba(255,255,255,.25)] peer-checked:bg-lgt04-on peer-focus-visible:outline-3 peer-focus-visible:outline-offset-3 peer-focus-visible:outline-white peer-checked:[&amp;_.lgt-04-fixed]:opacity-0 peer-checked:[&amp;_.lgt-04-handle]:left-[2em] peer-checked:[&amp;_.lgt-04-handle]:animate-lgt04-stretch motion-reduce:[&amp;_.lgt-04-handle]:animate-none">
      <span class="absolute inset-0 [filter:url(#lgt-04-goo)]">
        <span class="lgt-04-fixed absolute top-1/2 left-[0.28em] size-[1.14em] -translate-y-1/2 rounded-full bg-white opacity-90 transition-opacity delay-200 duration-300"></span>
        <span class="lgt-04-handle absolute top-1/2 left-[0.28em] size-[1.14em] -translate-y-1/2 rounded-full bg-white shadow-[0_2px_6px_rgba(0,0,0,.35)] transition-[left] duration-400 ease-lgt04"></span>
      </span>
    </span>
    <span class="text-[0.85em] font-semibold">Liquid mode</span>
  </label>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-lgt04-on: #34d3a4;
  --color-lgt04-on: oklch(0.7 0.17 165);
  --color-lgt04-off: #6c6a85;
  --color-lgt04-off: oklch(0.55 0.03 260);
  --color-lgt04-fg: #eceaf5;
  --font-lgt04-sans: 'Manrope', system-ui, sans-serif;
  --ease-lgt04: cubic-bezier(0.6, -0.4, 0.4, 1.4);
  --animate-lgt04-stretch: lgt-04-stretch 0.4s ease;
}
/* The two things utilities cannot express: a keyframe, and the -50% Y that
   must survive inside the scaleX so the handle stays vertically centred. */

@keyframes lgt-04-stretch {
  0% {
    transform: translateY(-50%) scaleX(1);
  }

  45% {
    transform: translateY(-50%) scaleX(1.55);
  }

  100% {
    transform: translateY(-50%) scaleX(1);
  }
}
```

How this works

The checkbox is the component. Not a div with role=switch, not a JS store — a native input, clipped with sr-only so it stays focusable, announceable and Space-togglable. Everything visible is a sibling that reads its state:

<input type="checkbox" class="peer sr-only">
<span class="bg-lgt04-off peer-checked:bg-lgt04-on
             peer-focus-visible:outline-3 peer-focus-visible:outline-white">

Reaching inside a sibling needs the child escape. peer-checked: styles the sibling itself; the handle is two levels down inside it. peer-checked:[&_.lgt-04-handle]:left-8 is the idiomatic answer, and forgetting it is the single most common bug when porting a :checked + .track .handle rule.

The goo is SVG, and it stays SVG. feGaussianBlur blurs the blobs, then feColorMatrix hard-thresholds the alpha, so overlapping shapes fuse into one organic mass. There is no CSS equivalent and no utility for it — the filter lives in the markup, and one arbitrary [filter:url(#...)] utility points at it.

The stretch is a theme animation. Declaring --animate-lgt04-stretch in @theme turns the whole shorthand into a utility, so the markup reads peer-checked:[&_.lgt-04-handle]:animate-lgt04-stretch and the timing lives in one place.

Make it yours

  • Change how liquid it feels by editing stdDeviation on the blur (higher = more viscous) and the alpha row of the feColorMatrix (higher multiplier = harder edge).
  • Recolour the two states from @theme: --color-lgt04-on and --color-lgt04-off are the only colour decisions in the demo.
  • Resize the whole switch from the root text-[22px] — every dimension is em-based, so one number scales it.
  • Tune the stretch amount in the @keyframes scaleX values; 1.55 is the point where it reads as liquid rather than as a glitch.
  • Slow the travel with duration-500 on the handle for a heavier, more mercurial feel.

Gotchas — read before shipping

  • The goo filter must wrap the blob layer only. Applying it to the switch root smears the label text into an unreadable blur.
  • Use sr-only, never hidden or display:none, on the input — a hidden checkbox is unreachable by keyboard and invisible to screen readers.
  • peer-* matches previous siblings only. If the input comes after the track in source order, nothing works and there is no error to tell you why.
  • SVG filter ids are document-global. Two un-namespaced #goo filters on one page silently both resolve to the first, which is why every id here carries the lgt-04- prefix.
  • The blob layer needs a real backdrop; goo over a fully transparent parent renders as hard-edged circles.
  • motion-reduce:animate-none is required here — a stretching blob is exactly the kind of organic motion that triggers vestibular discomfort.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Utilities only, no JavaScript. SVG filters and :checked are universally supported; Tailwind v4's generated sheet sets the practical floor. Under prefers-reduced-motion the stretch drops to a plain slide.

Techniques used in this demo

Plain CSSno Tailwind surface

Search CodeFronts

Loading…