Tailwind + JSMIT licensed

Tailwind Submit Button Success State with Checkmark Draw

A submit button that collapses into a circle and draws a checkmark. The port replaces a class toggle with data-state, so the markup reads data-[state=success]:w-12 and the impossible state ("success and idle at once") stops existing.

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="tab-12 w-full min-h-svh grid place-items-center bg-tab12-bg font-tab12-sans px-6 py-10">
  <div class="w-[min(380px,100%)] p-8 bg-white rounded-2xl shadow-[0_4px_24px_oklch(0_0_0/0.08)]">
    <h2 class="text-[1.4rem] font-bold text-tab12-ink m-0">Contact Us</h2>
    <p class="mt-1.5 mb-6 text-[0.88rem] leading-relaxed text-tab12-mut">We'll get back to you within 24 hours.</p>
    <label class="block mb-3.5">
      <span class="sr-only">Your name</span>
      <input type="text" placeholder="Your name" class="w-full min-h-11 px-4 text-[0.9rem] text-tab12-ink bg-tab12-bg border-[1.5px] border-tab12-edge rounded-lg transition-colors duration-200 focus:border-tab12-primary focus:outline-2 focus:outline-offset-2 focus:outline-tab12-primary/40">
    </label>
    <label class="block mb-4">
      <span class="sr-only">Email address</span>
      <input type="email" placeholder="Email address" class="w-full min-h-11 px-4 text-[0.9rem] text-tab12-ink bg-tab12-bg border-[1.5px] border-tab12-edge rounded-lg transition-colors duration-200 focus:border-tab12-primary focus:outline-2 focus:outline-offset-2 focus:outline-tab12-primary/40">
    </label>
    <div class="flex justify-center">
      <button type="button" id="tab12-btn" data-state="idle" class="relative flex items-center justify-center w-full h-12 px-6 text-base font-bold text-white bg-tab12-primary rounded-full cursor-pointer overflow-hidden shadow-[0_4px_16px_oklch(0.51_0.24_277/0.3)] transition-[width,background-color,box-shadow] duration-[400ms] ease-[cubic-bezier(0.4,0,0.2,1)] hover:brightness-108 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab12-primary data-[state=success]:w-12 data-[state=success]:bg-tab12-success data-[state=success]:shadow-[0_4px_16px_oklch(0.7_0.15_163/0.35)] data-[state=success]:cursor-default motion-reduce:transition-none">
        <span class="whitespace-nowrap transition-[opacity,scale] duration-200">Send Message</span>
        <svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-6 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-[350ms]">
          <polyline points="20 6 9 17 4 12" pathLength="30" stroke-dasharray="30" stroke-dashoffset="30"></polyline>
        </svg>
      </button>
    </div>
    <p id="tab12-status" role="status" aria-live="polite" class="mt-3 text-center text-[0.8rem] text-tab12-mut min-h-5"></p>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tab12-bg: #f8fafc;
  --color-tab12-bg: oklch(0.98 0.004 250);
  --color-tab12-ink: #1e293b;
  --color-tab12-ink: oklch(0.28 0.03 258);
  --color-tab12-mut: #64748b;
  --color-tab12-mut: oklch(0.55 0.03 255);
  --color-tab12-edge: #e2e8f0;
  --color-tab12-edge: oklch(0.92 0.01 250);
  --color-tab12-primary: #4f46e5;
  --color-tab12-primary: oklch(0.51 0.24 277);
  --color-tab12-success: #10b981;
  --color-tab12-success: oklch(0.7 0.15 163);
  --font-tab12-sans: 'Space Grotesk', system-ui, sans-serif;
  --animate-tab12-draw: tab12-draw 0.4s ease 0.4s forwards;
}

@keyframes tab12-draw {
  from {
    stroke-dashoffset: 30;
  }

  to {
    stroke-dashoffset: 0;
  }
}
/* The two state-driven child rules that have no variant form worth writing:
   the label fade and the checkmark reveal both key off the parent's data-state. */

.tab-12 [data-state="success"] > span {
  opacity: 0;
  scale: 0.6;
  position: absolute;
}

.tab-12 [data-state="success"] > svg {
  opacity: 1;
  scale: 1;
}

.tab-12 [data-state="success"] polyline {
  animation: var(--animate-tab12-draw);
}

.tab-12 {
  width: 100%;
  min-height: 100svh;
}

.tab-12 ::selection {
  background: var(--color-tab12-success);
  color: #04231a;
}

@media (prefers-reduced-motion: reduce) {
  .tab-12 [data-state="success"] polyline {
    animation: none;
    stroke-dashoffset: 0;
  }
}
(function () {
  var btn = document.getElementById('tab12-btn');
  var status = document.getElementById('tab12-status');
  if (!btn) return;
  var timer = null;
  btn.addEventListener('click', function () {
    if (btn.dataset.state === 'success') return;
    btn.dataset.state = 'success';
    btn.disabled = true;
    btn.setAttribute('aria-busy', 'false');
    if (status) status.textContent = 'Message sent.';
    clearTimeout(timer);
    timer = setTimeout(function () {
      btn.dataset.state = 'idle';
      btn.disabled = false;
      if (status) status.textContent = '';
    }, 2500);
  });
})();

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-tab12-bg: #f8fafc;
  --color-tab12-bg: oklch(0.98 0.004 250);
  --color-tab12-ink: #1e293b;
  --color-tab12-ink: oklch(0.28 0.03 258);
  --color-tab12-mut: #64748b;
  --color-tab12-mut: oklch(0.55 0.03 255);
  --color-tab12-edge: #e2e8f0;
  --color-tab12-edge: oklch(0.92 0.01 250);
  --color-tab12-primary: #4f46e5;
  --color-tab12-primary: oklch(0.51 0.24 277);
  --color-tab12-success: #10b981;
  --color-tab12-success: oklch(0.7 0.15 163);
  --font-tab12-sans: 'Space Grotesk', system-ui, sans-serif;
  --animate-tab12-draw: tab12-draw 0.4s ease 0.4s forwards;
}

@keyframes tab12-draw {
  from {
    stroke-dashoffset: 30;
  }

  to {
    stroke-dashoffset: 0;
  }
}
/* The two state-driven child rules that have no variant form worth writing:
   the label fade and the checkmark reveal both key off the parent's data-state. */

.tab-12 [data-state="success"] > span {
  opacity: 0;
  scale: 0.6;
  position: absolute;
}

.tab-12 [data-state="success"] > svg {
  opacity: 1;
  scale: 1;
}

.tab-12 [data-state="success"] polyline {
  animation: var(--animate-tab12-draw);
}

.tab-12 {
  width: 100%;
  min-height: 100svh;
}

.tab-12 ::selection {
  background: var(--color-tab12-success);
  color: #04231a;
}

@media (prefers-reduced-motion: reduce) {
  .tab-12 [data-state="success"] polyline {
    animation: none;
    stroke-dashoffset: 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=Space+Grotesk:wght@400;500;600;700&display=swap">

What changed in the port

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

  • Two class toggles become one data-state value, and the three child transitions that read it stay in CSS — a parent-attribute-to-child selector has a variant form (data-[state=success]:[&_polyline]:) but it is less legible than the three-line rule, so this is one of the places the port deliberately keeps CSS.
  • The morph is width only. Radius was already 9999px, so the pill becomes a circle for free — and the Safari rectangle flash the CSS version warned about cannot happen.
  • UI change from the CSS original: an aria-live status line. A checkmark drawing itself is invisible to a screen reader; "Message sent." is not.
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 Submit Button Success State with Checkmark Draw
Source: https://codefronts.com/motion/tailwind-css-animated-buttons/tailwind-submit-button-success-state/

A submit button that collapses into a circle and draws a checkmark. The port replaces a class toggle with data-state, so the markup reads data-[state=success]:w-12 and the impossible state ("success and idle at once") stops existing.
## HTML
```html
<section class="tab-12 w-full min-h-svh grid place-items-center bg-tab12-bg font-tab12-sans px-6 py-10">
  <div class="w-[min(380px,100%)] p-8 bg-white rounded-2xl shadow-[0_4px_24px_oklch(0_0_0/0.08)]">
    <h2 class="text-[1.4rem] font-bold text-tab12-ink m-0">Contact Us</h2>
    <p class="mt-1.5 mb-6 text-[0.88rem] leading-relaxed text-tab12-mut">We'll get back to you within 24 hours.</p>
    <label class="block mb-3.5">
      <span class="sr-only">Your name</span>
      <input type="text" placeholder="Your name" class="w-full min-h-11 px-4 text-[0.9rem] text-tab12-ink bg-tab12-bg border-[1.5px] border-tab12-edge rounded-lg transition-colors duration-200 focus:border-tab12-primary focus:outline-2 focus:outline-offset-2 focus:outline-tab12-primary/40">
    </label>
    <label class="block mb-4">
      <span class="sr-only">Email address</span>
      <input type="email" placeholder="Email address" class="w-full min-h-11 px-4 text-[0.9rem] text-tab12-ink bg-tab12-bg border-[1.5px] border-tab12-edge rounded-lg transition-colors duration-200 focus:border-tab12-primary focus:outline-2 focus:outline-offset-2 focus:outline-tab12-primary/40">
    </label>
    <div class="flex justify-center">
      <button type="button" id="tab12-btn" data-state="idle" class="relative flex items-center justify-center w-full h-12 px-6 text-base font-bold text-white bg-tab12-primary rounded-full cursor-pointer overflow-hidden shadow-[0_4px_16px_oklch(0.51_0.24_277/0.3)] transition-[width,background-color,box-shadow] duration-[400ms] ease-[cubic-bezier(0.4,0,0.2,1)] hover:brightness-108 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-tab12-primary data-[state=success]:w-12 data-[state=success]:bg-tab12-success data-[state=success]:shadow-[0_4px_16px_oklch(0.7_0.15_163/0.35)] data-[state=success]:cursor-default motion-reduce:transition-none">
        <span class="whitespace-nowrap transition-[opacity,scale] duration-200">Send Message</span>
        <svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="absolute size-6 opacity-0 scale-50 transition-[opacity,scale] duration-300 delay-[350ms]">
          <polyline points="20 6 9 17 4 12" pathLength="30" stroke-dasharray="30" stroke-dashoffset="30"></polyline>
        </svg>
      </button>
    </div>
    <p id="tab12-status" role="status" aria-live="polite" class="mt-3 text-center text-[0.8rem] text-tab12-mut min-h-5"></p>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tab12-bg: #f8fafc;
  --color-tab12-bg: oklch(0.98 0.004 250);
  --color-tab12-ink: #1e293b;
  --color-tab12-ink: oklch(0.28 0.03 258);
  --color-tab12-mut: #64748b;
  --color-tab12-mut: oklch(0.55 0.03 255);
  --color-tab12-edge: #e2e8f0;
  --color-tab12-edge: oklch(0.92 0.01 250);
  --color-tab12-primary: #4f46e5;
  --color-tab12-primary: oklch(0.51 0.24 277);
  --color-tab12-success: #10b981;
  --color-tab12-success: oklch(0.7 0.15 163);
  --font-tab12-sans: 'Space Grotesk', system-ui, sans-serif;
  --animate-tab12-draw: tab12-draw 0.4s ease 0.4s forwards;
}

@keyframes tab12-draw {
  from {
    stroke-dashoffset: 30;
  }

  to {
    stroke-dashoffset: 0;
  }
}
/* The two state-driven child rules that have no variant form worth writing:
   the label fade and the checkmark reveal both key off the parent's data-state. */

.tab-12 [data-state="success"] > span {
  opacity: 0;
  scale: 0.6;
  position: absolute;
}

.tab-12 [data-state="success"] > svg {
  opacity: 1;
  scale: 1;
}

.tab-12 [data-state="success"] polyline {
  animation: var(--animate-tab12-draw);
}

.tab-12 {
  width: 100%;
  min-height: 100svh;
}

.tab-12 ::selection {
  background: var(--color-tab12-success);
  color: #04231a;
}

@media (prefers-reduced-motion: reduce) {
  .tab-12 [data-state="success"] polyline {
    animation: none;
    stroke-dashoffset: 0;
  }
}
```

## JavaScript
```js
(function () {
  var btn = document.getElementById('tab12-btn');
  var status = document.getElementById('tab12-status');
  if (!btn) return;
  var timer = null;
  btn.addEventListener('click', function () {
    if (btn.dataset.state === 'success') return;
    btn.dataset.state = 'success';
    btn.disabled = true;
    btn.setAttribute('aria-busy', 'false');
    if (status) status.textContent = 'Message sent.';
    clearTimeout(timer);
    timer = setTimeout(function () {
      btn.dataset.state = 'idle';
      btn.disabled = false;
      if (status) status.textContent = '';
    }, 2500);
  });
})();
```

How this works

One attribute, not three booleans. The CSS version toggled .is-success; a sibling of that pattern (demo 13) toggled two classes that could both be present. Here the controller writes data-state="idle" | "success" and every visual reads it with data-[state=success]:. The state machine is now representable in the DOM exactly once.

The checkmark draws from a dash offset. The polyline carries pathLength="30" with stroke-dasharray:30, so the keyframe simply walks stroke-dashoffset to 0. The child selector [&_polyline]: is how a utility reaches into SVG without giving the path its own class.

The collapse is width-only. The button sits in a fixed-width flex column, so shrinking from full width to 48px shifts nothing around it — the layout cost the original warned about is designed out rather than documented.

Make it yours

  • Success colour: --color-tab12-success drives fill, shadow and the ring.
  • Hold time: the 2500 in the controller's setTimeout.
  • Add an error branch by writing data-state="error" and a data-[state=error]: set — no new boolean, no conflict.

Gotchas — read before shipping

  • Give the keyframe explicit from and to offsets. A to-only keyframe takes its start from the computed value, and when that value comes from a parent's [&_polyline]: descendant utility the tick can resolve to a permanent 30px offset — an invisible checkmark with the animation apparently running.
  • Change the checkmark path and you must change pathLength with it, or the dash maths no longer reaches zero.
  • Keep the button in a container with a resolved width. A collapse from 100% to 48px inside an auto-width parent reflows its siblings.
  • Disable the button while the success state shows — otherwise a fast second click starts a second submit while the first is still confirming.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

SVG dash animation and width transitions are universally supported; the floor is Tailwind v4's generated sheet.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…