Tailwind v4MIT licensed

Tailwind Liquid Glass Multi-Step Form Wizard

An onboarding wizard whose progress dots physically merge as the fill grows toward them — the goo filter from demo 04, applied to a progress track. The port's discipline: the script sets data-active, aria-current and one custom property, and every visual consequence is a variant in the markup.

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 id="lgt-20-root" class="lgt-20 grid w-full min-h-svh place-items-center p-15 font-lgt20-sans [background:radial-gradient(circle_at_25%_20%,#2a2350,#120f24_60%),radial-gradient(circle_at_80%_90%,#1c2f55,#120f24_60%)]" style="--p:0%" aria-label="Tailwind liquid glass multi-step wizard">
  <svg width="0" height="0" aria-hidden="true" class="absolute">
    <filter id="lgt-20-goo">
      <feGaussianBlur in="SourceGraphic" stdDeviation="5" 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 18 -8"/>
    </filter>
  </svg>

  <form novalidate class="w-[min(440px,94vw)] rounded-[26px] border border-white/30 bg-white/11 p-7.5 text-white backdrop-blur-[24px] backdrop-saturate-180 shadow-[inset_0_1px_0_rgba(255,255,255,.45),0_40px_80px_-34px_rgba(0,0,0,.7)]">

    <div aria-hidden="true" class="mb-2 h-6.5">
      <div class="relative h-6.5 [filter:url(#lgt-20-goo)]">
        <span class="absolute top-2.25 left-2.25 h-2 rounded-full bg-lgt20-accent transition-[width] duration-500 ease-out [width:var(--p,0%)] motion-reduce:transition-none"></span>
        <span data-dot data-active class="absolute top-0.75 left-0 size-5 rounded-full bg-lgt20-track transition-colors duration-400 data-active:bg-lgt20-accent"></span>
        <span data-dot class="absolute top-0.75 left-[calc(50%-10px)] size-5 rounded-full bg-lgt20-track transition-colors duration-400 data-active:bg-lgt20-accent"></span>
        <span data-dot class="absolute top-0.75 right-0 size-5 rounded-full bg-lgt20-track transition-colors duration-400 data-active:bg-lgt20-accent"></span>
      </div>
    </div>

    <p id="lgt-20-count" aria-live="polite" class="mb-4.5 text-xs font-bold uppercase tracking-[0.08em] text-white/70">Step 1 of 3</p>

    <div class="relative">
      <fieldset data-step data-active class="hidden border-0 p-0 data-active:block data-active:animate-lgt20-slide motion-reduce:data-active:animate-none">
        <legend class="mb-4 p-0 text-xl font-extrabold">Your details</legend>
        <label for="lgt-20-name" class="mt-3.5 mb-1.75 block text-xs font-semibold uppercase tracking-[0.05em] text-white/70">Name</label>
        <input id="lgt-20-name" type="text" required placeholder="Ada Lovelace" class="w-full rounded-[12px] border border-white/25 bg-white/8 px-3.5 py-3 text-[15px] text-white outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-white/50 focus:border-lgt20-accent focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-accent)_40%,transparent)] user-invalid:border-lgt20-err user-invalid:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-err)_40%,transparent)]">
        <label for="lgt-20-email" class="mt-3.5 mb-1.75 block text-xs font-semibold uppercase tracking-[0.05em] text-white/70">Email</label>
        <input id="lgt-20-email" type="email" required placeholder="ada@studio.dev" class="w-full rounded-[12px] border border-white/25 bg-white/8 px-3.5 py-3 text-[15px] text-white outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-white/50 focus:border-lgt20-accent focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-accent)_40%,transparent)] user-invalid:border-lgt20-err user-invalid:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-err)_40%,transparent)]">
      </fieldset>

      <fieldset data-step class="hidden border-0 p-0 data-active:block data-active:animate-lgt20-slide motion-reduce:data-active:animate-none">
        <legend class="mb-4 p-0 text-xl font-extrabold">Workspace</legend>
        <label for="lgt-20-org" class="mt-3.5 mb-1.75 block text-xs font-semibold uppercase tracking-[0.05em] text-white/70">Organization</label>
        <input id="lgt-20-org" type="text" required placeholder="Studio Lumen" class="w-full rounded-[12px] border border-white/25 bg-white/8 px-3.5 py-3 text-[15px] text-white outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-white/50 focus:border-lgt20-accent focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-accent)_40%,transparent)] user-invalid:border-lgt20-err user-invalid:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-err)_40%,transparent)]">
        <label for="lgt-20-role" class="mt-3.5 mb-1.75 block text-xs font-semibold uppercase tracking-[0.05em] text-white/70">Role</label>
        <input id="lgt-20-role" type="text" placeholder="Design lead" class="w-full rounded-[12px] border border-white/25 bg-white/8 px-3.5 py-3 text-[15px] text-white outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-white/50 focus:border-lgt20-accent focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-accent)_40%,transparent)]">
      </fieldset>

      <fieldset data-step class="hidden border-0 p-0 data-active:block data-active:animate-lgt20-slide motion-reduce:data-active:animate-none">
        <legend class="mb-4 p-0 text-xl font-extrabold">All set</legend>
        <p class="py-3 text-base leading-relaxed text-white/90">Review and finish &mdash; your Liquid Glass workspace is ready to launch.</p>
      </fieldset>
    </div>

    <div class="mt-6 flex justify-between gap-3">
      <button id="lgt-20-back" type="button" disabled class="cursor-pointer rounded-[12px] border border-white/30 bg-white/12 px-6 py-3 text-[15px] font-bold text-white transition-[background-color,transform,opacity] duration-200 not-disabled:hover:-translate-y-0.5 not-disabled:hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none">Back</button>
      <button id="lgt-20-next" type="button" class="cursor-pointer rounded-[12px] border border-lgt20-accent/60 bg-linear-to-b from-lgt20-accent-top to-lgt20-accent px-6 py-3 text-[15px] font-bold text-white transition-[filter,transform] duration-200 hover:-translate-y-0.5 hover:brightness-110 focus-visible:-translate-y-0.5 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none">Next</button>
    </div>
  </form>
</section>
@import "tailwindcss";

@theme {
  --color-lgt20-accent: #7c5cf0;
  --color-lgt20-accent: oklch(0.66 0.19 285);
  --color-lgt20-accent-top: #9b83f6;
  --color-lgt20-accent-top: oklch(0.72 0.16 285);
  --color-lgt20-track: #3d3763;
  --color-lgt20-err: #e2503c;
  --color-lgt20-err: oklch(0.65 0.2 25);
  --font-lgt20-sans: 'Manrope', system-ui, sans-serif;
  --animate-lgt20-slide: lgt-20-slide 0.4s ease;
}

@keyframes lgt-20-slide {
  from {
    opacity: 0;
    transform: translateX(14px);
  }

  to {
    opacity: 1;
    transform: translateX(0);
  }
}
(() => {
  const root = document.querySelector('#lgt-20-root');
  const steps = [...root.querySelectorAll('[data-step]')];
  const dots = [...root.querySelectorAll('[data-dot]')];
  const fill = root.querySelector('[data-dot]').parentElement.querySelector('span');
  const count = root.querySelector('#lgt-20-count');
  const back = root.querySelector('#lgt-20-back');
  const next = root.querySelector('#lgt-20-next');
  let i = 0;
  const last = steps.length - 1;

  const paint = () => {
    steps.forEach((s, n) => n === i ? s.setAttribute('data-active', '') : s.removeAttribute('data-active'));
    dots.forEach((d, n) => n <= i ? d.setAttribute('data-active', '') : d.removeAttribute('data-active'));
    root.style.setProperty('--p', (i / last * 100) + '%');
    count.textContent = 'Step ' + (i + 1) + ' of ' + steps.length;
    back.disabled = i === 0;
    next.textContent = i === last ? 'Finish' : 'Next';
    const f = steps[i].querySelector('input');
    if (f) f.focus();
  };

  // Constraint validation does the work; :user-invalid paints the result.
  const validate = () => [...steps[i].querySelectorAll('input[required]')]
    .map(inp => { inp.reportValidity?.(); return inp.checkValidity(); })
    .every(Boolean);

  next.addEventListener('click', () => {
    if (i === last) { count.textContent = 'Complete'; return; }
    if (!validate()) return;
    i++; paint();
  });
  back.addEventListener('click', () => { if (i > 0) { i--; paint(); } });
  paint();
})();

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-lgt20-accent: #7c5cf0;
  --color-lgt20-accent: oklch(0.66 0.19 285);
  --color-lgt20-accent-top: #9b83f6;
  --color-lgt20-accent-top: oklch(0.72 0.16 285);
  --color-lgt20-track: #3d3763;
  --color-lgt20-err: #e2503c;
  --color-lgt20-err: oklch(0.65 0.2 25);
  --font-lgt20-sans: 'Manrope', system-ui, sans-serif;
  --animate-lgt20-slide: lgt-20-slide 0.4s ease;
}

@keyframes lgt-20-slide {
  from {
    opacity: 0;
    transform: translateX(14px);
  }

  to {
    opacity: 1;
    transform: translateX(0);
  }
}
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.

  • Step visibility, dot state and panel entrance are all data-active: variants — the script sets attributes, never styling classes.
  • Validation defers to the browser: required and type=&quot;email&quot; plus checkValidity(), with user-invalid: painting the result. The hand-rolled email regex is gone.
  • Resting dots use a solid token rather than a translucent white: the goo feColorMatrix thresholds alpha, so anything under about 50% opacity is erased by the filter. The goo itself is demo 04's, doing a different job — the same six lines of SVG merging a progress fill into its dots.
  • Progress is one custom property consumed by an arbitrary [width:var(--p)] utility.
  • not-disabled:hover: keeps the hover lift off the disabled Back button without a second rule.
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 Glass Multi-Step Form Wizard
Source: https://codefronts.com/design-styles/tailwind-liquid-glass-effects/tailwind-liquid-glass-multi-step-form-wizard/

An onboarding wizard whose progress dots physically merge as the fill grows toward them &mdash; the goo filter from demo 04, applied to a progress track. The port's discipline: the script sets data-active, aria-current and one custom property, and every visual consequence is a variant in the markup.
## HTML
```html
<section id="lgt-20-root" class="lgt-20 grid w-full min-h-svh place-items-center p-15 font-lgt20-sans [background:radial-gradient(circle_at_25%_20%,#2a2350,#120f24_60%),radial-gradient(circle_at_80%_90%,#1c2f55,#120f24_60%)]" style="--p:0%" aria-label="Tailwind liquid glass multi-step wizard">
  <svg width="0" height="0" aria-hidden="true" class="absolute">
    <filter id="lgt-20-goo">
      <feGaussianBlur in="SourceGraphic" stdDeviation="5" 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 18 -8"/>
    </filter>
  </svg>

  <form novalidate class="w-[min(440px,94vw)] rounded-[26px] border border-white/30 bg-white/11 p-7.5 text-white backdrop-blur-[24px] backdrop-saturate-180 shadow-[inset_0_1px_0_rgba(255,255,255,.45),0_40px_80px_-34px_rgba(0,0,0,.7)]">

    <div aria-hidden="true" class="mb-2 h-6.5">
      <div class="relative h-6.5 [filter:url(#lgt-20-goo)]">
        <span class="absolute top-2.25 left-2.25 h-2 rounded-full bg-lgt20-accent transition-[width] duration-500 ease-out [width:var(--p,0%)] motion-reduce:transition-none"></span>
        <span data-dot data-active class="absolute top-0.75 left-0 size-5 rounded-full bg-lgt20-track transition-colors duration-400 data-active:bg-lgt20-accent"></span>
        <span data-dot class="absolute top-0.75 left-[calc(50%-10px)] size-5 rounded-full bg-lgt20-track transition-colors duration-400 data-active:bg-lgt20-accent"></span>
        <span data-dot class="absolute top-0.75 right-0 size-5 rounded-full bg-lgt20-track transition-colors duration-400 data-active:bg-lgt20-accent"></span>
      </div>
    </div>

    <p id="lgt-20-count" aria-live="polite" class="mb-4.5 text-xs font-bold uppercase tracking-[0.08em] text-white/70">Step 1 of 3</p>

    <div class="relative">
      <fieldset data-step data-active class="hidden border-0 p-0 data-active:block data-active:animate-lgt20-slide motion-reduce:data-active:animate-none">
        <legend class="mb-4 p-0 text-xl font-extrabold">Your details</legend>
        <label for="lgt-20-name" class="mt-3.5 mb-1.75 block text-xs font-semibold uppercase tracking-[0.05em] text-white/70">Name</label>
        <input id="lgt-20-name" type="text" required placeholder="Ada Lovelace" class="w-full rounded-[12px] border border-white/25 bg-white/8 px-3.5 py-3 text-[15px] text-white outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-white/50 focus:border-lgt20-accent focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-accent)_40%,transparent)] user-invalid:border-lgt20-err user-invalid:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-err)_40%,transparent)]">
        <label for="lgt-20-email" class="mt-3.5 mb-1.75 block text-xs font-semibold uppercase tracking-[0.05em] text-white/70">Email</label>
        <input id="lgt-20-email" type="email" required placeholder="ada@studio.dev" class="w-full rounded-[12px] border border-white/25 bg-white/8 px-3.5 py-3 text-[15px] text-white outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-white/50 focus:border-lgt20-accent focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-accent)_40%,transparent)] user-invalid:border-lgt20-err user-invalid:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-err)_40%,transparent)]">
      </fieldset>

      <fieldset data-step class="hidden border-0 p-0 data-active:block data-active:animate-lgt20-slide motion-reduce:data-active:animate-none">
        <legend class="mb-4 p-0 text-xl font-extrabold">Workspace</legend>
        <label for="lgt-20-org" class="mt-3.5 mb-1.75 block text-xs font-semibold uppercase tracking-[0.05em] text-white/70">Organization</label>
        <input id="lgt-20-org" type="text" required placeholder="Studio Lumen" class="w-full rounded-[12px] border border-white/25 bg-white/8 px-3.5 py-3 text-[15px] text-white outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-white/50 focus:border-lgt20-accent focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-accent)_40%,transparent)] user-invalid:border-lgt20-err user-invalid:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-err)_40%,transparent)]">
        <label for="lgt-20-role" class="mt-3.5 mb-1.75 block text-xs font-semibold uppercase tracking-[0.05em] text-white/70">Role</label>
        <input id="lgt-20-role" type="text" placeholder="Design lead" class="w-full rounded-[12px] border border-white/25 bg-white/8 px-3.5 py-3 text-[15px] text-white outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-white/50 focus:border-lgt20-accent focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--color-lgt20-accent)_40%,transparent)]">
      </fieldset>

      <fieldset data-step class="hidden border-0 p-0 data-active:block data-active:animate-lgt20-slide motion-reduce:data-active:animate-none">
        <legend class="mb-4 p-0 text-xl font-extrabold">All set</legend>
        <p class="py-3 text-base leading-relaxed text-white/90">Review and finish &mdash; your Liquid Glass workspace is ready to launch.</p>
      </fieldset>
    </div>

    <div class="mt-6 flex justify-between gap-3">
      <button id="lgt-20-back" type="button" disabled class="cursor-pointer rounded-[12px] border border-white/30 bg-white/12 px-6 py-3 text-[15px] font-bold text-white transition-[background-color,transform,opacity] duration-200 not-disabled:hover:-translate-y-0.5 not-disabled:hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none">Back</button>
      <button id="lgt-20-next" type="button" class="cursor-pointer rounded-[12px] border border-lgt20-accent/60 bg-linear-to-b from-lgt20-accent-top to-lgt20-accent px-6 py-3 text-[15px] font-bold text-white transition-[filter,transform] duration-200 hover:-translate-y-0.5 hover:brightness-110 focus-visible:-translate-y-0.5 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none">Next</button>
    </div>
  </form>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-lgt20-accent: #7c5cf0;
  --color-lgt20-accent: oklch(0.66 0.19 285);
  --color-lgt20-accent-top: #9b83f6;
  --color-lgt20-accent-top: oklch(0.72 0.16 285);
  --color-lgt20-track: #3d3763;
  --color-lgt20-err: #e2503c;
  --color-lgt20-err: oklch(0.65 0.2 25);
  --font-lgt20-sans: 'Manrope', system-ui, sans-serif;
  --animate-lgt20-slide: lgt-20-slide 0.4s ease;
}

@keyframes lgt-20-slide {
  from {
    opacity: 0;
    transform: translateX(14px);
  }

  to {
    opacity: 1;
    transform: translateX(0);
  }
}
```

## JavaScript
```js
(() => {
  const root = document.querySelector('#lgt-20-root');
  const steps = [...root.querySelectorAll('[data-step]')];
  const dots = [...root.querySelectorAll('[data-dot]')];
  const fill = root.querySelector('[data-dot]').parentElement.querySelector('span');
  const count = root.querySelector('#lgt-20-count');
  const back = root.querySelector('#lgt-20-back');
  const next = root.querySelector('#lgt-20-next');
  let i = 0;
  const last = steps.length - 1;

  const paint = () => {
    steps.forEach((s, n) => n === i ? s.setAttribute('data-active', '') : s.removeAttribute('data-active'));
    dots.forEach((d, n) => n <= i ? d.setAttribute('data-active', '') : d.removeAttribute('data-active'));
    root.style.setProperty('--p', (i / last * 100) + '%');
    count.textContent = 'Step ' + (i + 1) + ' of ' + steps.length;
    back.disabled = i === 0;
    next.textContent = i === last ? 'Finish' : 'Next';
    const f = steps[i].querySelector('input');
    if (f) f.focus();
  };

  // Constraint validation does the work; :user-invalid paints the result.
  const validate = () => [...steps[i].querySelectorAll('input[required]')]
    .map(inp => { inp.reportValidity?.(); return inp.checkValidity(); })
    .every(Boolean);

  next.addEventListener('click', () => {
    if (i === last) { count.textContent = 'Complete'; return; }
    if (!validate()) return;
    i++; paint();
  });
  back.addEventListener('click', () => { if (i > 0) { i--; paint(); } });
  paint();
})();
```

How this works

The script owns state; the markup owns appearance. Every visual consequence of advancing a step is declared as a variant, and the script only writes attributes and one number:

<fieldset data-active class="hidden data-active:block
                              data-active:animate-lgt20-slide">
<span data-active class="bg-white/25 data-active:bg-lgt20-accent">

The goo is demo 04's filter doing a different job. The fill bar and the dots sit on one filtered layer, so as the fill approaches a dot the two shapes bulge and fuse before the dot sets active. Same six lines of SVG, entirely different component — which is the argument for learning the technique rather than copying a component.

Validation is the browser's. required and type="email" on the inputs, user-invalid: for the styling. The script asks checkValidity() before advancing rather than reimplementing an email regex, and the invalid state paints itself.

Announce and move focus. The counter is aria-live="polite", and each advance focuses the new step's first field — without that, a keyboard user's focus stays on a button attached to a panel that no longer exists.

Make it yours

  • Add or remove steps by adding panels and dots; the fill percentage is derived from the step count, so nothing else changes.
  • Tune the merge with the SVG filter's stdDeviation and alpha threshold — more blur means a longer, more viscous fuse.
  • Change the panel entrance in the @keyframes; sliding from the right on next and the left on back is a nice refinement worth the extra variant.
  • Put real rules in the validate hook — it currently defers entirely to constraint validation, which is the right default.
  • Persist answers between steps in sessionStorage if the form is long enough that a refresh would be painful.

Gotchas — read before shipping

  • Filter the goo layer only. Applying it to a container holding inputs smears the entire form into abstract art.
  • Validate the visible step before advancing, and move focus to the new panel — otherwise focus is left on a control inside a hidden fieldset.
  • Announce step changes with aria-live. Visual progress is invisible to screen-reader users.
  • hidden plus data-active:block is the correct pairing; a fieldset that is merely transparent still holds focusable inputs.
  • SVG filter ids are document-global — namespaced here as #lgt-20-goo to avoid resolving to demo 04's filter on a page holding both.
  • Use the real disabled attribute on Back at step one. A visually-disabled button that is still clickable is worse than no disabled state.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

SVG filters and backdrop-filter are broadly supported and the navigation is plain JavaScript; :user-invalid needs Chrome 119+, Safari 16.5+ and Firefox 88+, degrading to no validation styling while the form still blocks advancement. Tailwind v4's sheet sets the overall floor.

Techniques used in this demo

Plain CSSno Tailwind surface

Search CodeFronts

Loading…