# Animated SVG Checkmark in React and Tailwind

> Set pathLength={100} on an SVG path or circle, give it stroke-dasharray 100 and stroke-dashoffset 100, then transition the offset to 0 and it draws itself. Spin a quarter arc with animate-spin and transform-box: fill-box while a server action runs, then close the ring and draw the check. Put the words in a role="status" span that is always rendered, and turn the motion off under reduced motion.

- URL: https://qodesign.dev/blog/animated-svg-checkmark-react-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

Every flow that ends well needs a moment that says so: a code accepted, a payment through, a file uploaded. Ask Cursor, v0 or Lovable for one and you usually get a green check icon that pops in with a bouncing keyframe, or a whole animation library for one tick. A screen reader hears nothing, and the bounce keeps going for people who turned motion off.

An SVG checkmark that draws itself takes about 50 lines of React and a few Tailwind classes. This post builds one that spins while a server action runs, closes its ring and draws the check when the action succeeds, tells screen readers what happened, and stays still under reduced motion.

I make the [Step-Up OTP Gate](/components/otp-verify) on qodesign, where arcs spin around six code cells and then merge into one check. That piece uses Framer Motion. The code in this post is a smaller version written for this post, uses no animation library, and is not the component's source.

## How a line draws itself

[stroke-dasharray](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/stroke-dasharray) sets the pattern of dashes and gaps used to paint a shape's outline. Make one dash as long as the whole line, push it away with `stroke-dashoffset` by the same amount, and the line is invisible. Transition the offset back to 0 and the line paints itself from start to end.

The catch is that you need the line's length. You could measure it with `getTotalLength()` in an effect, but you don't have to. The [pathLength attribute](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/pathLength) lets you declare a total length for the path in user units, and the browser scales its distance math to match. Put `pathLength={100}` on a path or a circle and the dash and offset values become percentages: 100 is hidden, 0 is fully drawn, whatever the real length is.

It's the same trick that draws the line in the [animated SVG line chart post](/blog/animated-svg-line-chart-react-no-library), used here on a ring and a tick. For a ring that fills only part of the way, like a goal at 72%, the [circular progress ring post](/blog/circular-progress-ring-react-tailwind-svg) uses the same offset and works it out from a value.

## The component

```tsx
// components/success-check.tsx
"use client";

type CheckState = "idle" | "loading" | "success";

const draw =
  "[stroke-dasharray:100] transition-[stroke-dashoffset] ease-out motion-reduce:transition-none";

export function SuccessCheck({
  state,
  className = "",
}: {
  state: CheckState;
  className?: string;
}) {
  const done = state === "success";
  return (
    <span role="status" className={`inline-flex items-center gap-2 ${className}`}>
      <svg viewBox="0 0 52 52" className="size-10 text-emerald-500" aria-hidden="true">
        {/* track */}
        <circle cx="26" cy="26" r="23" fill="none" stroke="currentColor" strokeWidth="3" className="opacity-15" />
        {/* spinner arc, only while loading */}
        <g
          className={`origin-center [transform-box:fill-box] animate-spin motion-reduce:animate-none ${
            state === "loading" ? "opacity-100" : "opacity-0"
          }`}
        >
          <circle
            cx="26" cy="26" r="23" fill="none" stroke="currentColor" strokeWidth="3"
            strokeLinecap="round" pathLength={100} className="[stroke-dasharray:25_75]"
          />
        </g>
        {/* ring that closes on success */}
        <circle
          cx="26" cy="26" r="23" fill="none" stroke="currentColor" strokeWidth="3"
          pathLength={100} transform="rotate(-90 26 26)"
          className={`${draw} duration-500 ${done ? "[stroke-dashoffset:0]" : "[stroke-dashoffset:100]"}`}
        />
        {/* the check, drawn after the ring */}
        <path
          d="M16 27l7 7 14-15" fill="none" stroke="currentColor" strokeWidth="4"
          strokeLinecap="round" strokeLinejoin="round" pathLength={100}
          className={`${draw} duration-300 ${
            done ? "delay-300 motion-reduce:delay-0 [stroke-dashoffset:0]" : "delay-0 [stroke-dashoffset:100]"
          }`}
        />
      </svg>
      <span className="sr-only">
        {state === "loading" ? "Checking code" : done ? "Code verified" : ""}
      </span>
    </span>
  );
}
```

What each part does:

1. The state is a union of three strings. A typo like `state="done"` fails at compile time instead of rendering nothing.
2. The track is the full circle at 15% opacity, so the shape is there before anything happens.
3. The spinner is a quarter dash (`25_75` out of 100) on the same circle, spun by Tailwind's `animate-spin`, which is a 1 second linear rotation that repeats forever. It fades out when the state leaves `loading`.
4. The ring is a second circle that goes from offset 100 to 0 in 500 ms when the state becomes `success`. A circle's stroke starts at three o'clock, so the SVG `transform="rotate(-90 26 26)"` attribute moves the start to the top. The attribute rotates around the center you give it, so there is no transform origin to think about.
5. The check is drawn in 300 ms after a 300 ms delay, so it starts as the ring finishes.
6. `transition-[stroke-dashoffset]` names the one property that animates, instead of `transition-all`.

### Why the spinner needs transform-box

CSS transforms on SVG elements measure their origin against the SVG's view box by default, not the element. So `origin-center` on an arc that isn't in the middle of the view box spins it around the wrong point and it wobbles. `[transform-box:fill-box]` makes the [reference box](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transform-box) the element's own bounding box, so `origin-center` means the center of the circle. In this example the circle sits in the middle of the view box anyway, but the class keeps it right if you move it.

### Reduced motion

`motion-reduce:transition-none`, `motion-reduce:animate-none` and `motion-reduce:delay-0` turn the motion off for people who asked their system for less. The ring and check then appear at once and the arc stops spinning, while the text next to it still says what is going on. The [reduced motion post](/blog/prefers-reduced-motion-react) goes deeper on this.

### One thing transitions won't do

A transition only runs when a class changes on an element that is already on the screen. If you render the check straight into `success`, it shows up fully drawn with no animation. Render it in `idle` first and switch the state, the way the form below does.

## Telling screen readers

The SVG is `aria-hidden`, so the words live in a visually hidden span inside a `role="status"` wrapper. Per [MDN's status role page](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role), a status is advisory information that isn't important enough to be an alert, and it has an implicit `aria-live` of polite and `aria-atomic` of true. That fits a success message: it waits for the screen reader to finish what it's saying.

Keep the status element rendered from the start and change only its text. Live regions announce changes inside an element that already exists, and one that is added at the same moment as its message is often skipped.

## Wiring it to a server action

[useActionState](https://react.dev/reference/react/useActionState) returns the action's state, a function to run it, and a pending flag. That flag maps straight onto the three states.

```tsx
// components/verify-form.tsx
"use client";

import { useActionState } from "react";
import { SuccessCheck } from "./success-check";

type VerifyState = { ok: boolean; error?: string };

export function VerifyForm({
  verify,
}: {
  verify: (prev: VerifyState, data: FormData) => Promise<VerifyState>;
}) {
  const [state, formAction, pending] = useActionState(verify, { ok: false });
  const status = pending ? "loading" : state.ok ? "success" : "idle";

  return (
    <form action={formAction} className="flex items-center gap-3">
      <input
        name="code"
        inputMode="numeric"
        autoComplete="one-time-code"
        aria-label="Verification code"
        aria-invalid={state.error ? true : undefined}
        className="w-32 rounded-lg border px-3 py-2 tabular-nums"
      />
      <button type="submit" disabled={pending || state.ok} className="rounded-lg bg-black px-4 py-2 text-white disabled:opacity-60">
        Verify
      </button>
      <SuccessCheck state={status} />
      {state.error ? <p className="text-sm text-red-600">{state.error}</p> : null}
    </form>
  );
}
```

The server action returns `ok` or an error message:

```ts
// app/verify/actions.ts
"use server";

type VerifyState = { ok: boolean; error?: string };

async function checkCode(code: string): Promise<boolean> {
  // Replace with your real check (database, auth provider, etc.).
  return code === "123456";
}

export async function verifyCode(_prev: VerifyState, data: FormData): Promise<VerifyState> {
  const code = String(data.get("code") ?? "").trim();
  if (!/^\d{6}$/.test(code)) return { ok: false, error: "Enter the 6 digit code." };
  const valid = await checkCode(code);
  return valid ? { ok: true } : { ok: false, error: "That code didn't match. Try again." };
}
```

In a server component page, pass it in with `<VerifyForm verify={verifyCode} />`. While the action runs, `pending` is true and the arc spins. When it returns `ok: true`, the ring closes and the check draws. The button stays disabled after success so nobody submits twice.

If your code input is the part that needs work, the [OTP input post](/blog/otp-input-react-paste-autofill) covers paste, autofill and backspace. If you'd rather confirm with a toast after the action, see [toast notifications with server actions](/blog/toast-notifications-nextjs-server-actions).

## Mistakes AI tools make with success checks

1. A static check icon swapped in with a scale or bounce keyframe that loops forever.
2. Measuring the path with `getTotalLength()` and a ref in an effect. It works, but `pathLength={100}` does the same job with no JavaScript.
3. `transition-all` on SVG elements, so every property change animates.
4. `animate-spin` on an arc without `transform-box: fill-box`, so it wobbles around the view box center.
5. The status element mounted together with its message, so nothing is announced.
6. No reduced motion fallback, so the spinner keeps going for people who asked for less motion.

## A prompt for Cursor, v0 or Lovable

```text
Build an animated SVG success check in React and Tailwind, no animation library.
- Props: state "idle" | "loading" | "success".
- viewBox 0 0 52 52. A faint track circle (r 23). A loading arc: same circle, pathLength 100, stroke-dasharray 25 75, inside a <g> with animate-spin, origin-center and [transform-box:fill-box]; opacity 0 unless loading.
- A ring circle with pathLength 100, transform="rotate(-90 26 26)", stroke-dasharray 100, stroke-dashoffset 100, transitioning to 0 on success over 500ms.
- A check path "M16 27l7 7 14-15" with pathLength 100, same dash trick, 300ms with a 300ms delay on success.
- Use transition-[stroke-dashoffset], never transition-all. Add motion-reduce:transition-none, motion-reduce:animate-none and motion-reduce:delay-0.
- SVG aria-hidden. Wrap it in an always-rendered role="status" span with sr-only text: "Checking code" while loading, "Code verified" on success.
- Drive it from useActionState: pending = loading, state.ok = success.
```

## A check with more going on

The component above is one small icon on purpose. The [Step-Up OTP Gate](/components/otp-verify) is the full version: a banking dashboard waits blurred behind a one-time code card, a lime arc spins around each of the six cells while the code checks, then the cells slide together into one lime check and "Verified successfully" lands before the dashboard unlocks and the balances count up. Paste, the error shake, the resend timer and re-lock all work.

## FAQ

### How do I animate an SVG checkmark in React without a library?

Give the check path `pathLength={100}`, `stroke-dasharray: 100` and `stroke-dashoffset: 100`, then switch the offset to 0 with a CSS transition when the state becomes success. In Tailwind that is `[stroke-dasharray:100]`, `transition-[stroke-dashoffset]` and a class that flips between `[stroke-dashoffset:100]` and `[stroke-dashoffset:0]`.

### What does pathLength do in SVG?

It declares the total length of the shape in user units, and the browser scales its dash and offset math to match. With `pathLength={100}`, dash values work like percentages, so you never need to measure the path.

### Why does my SVG spinner wobble with animate-spin?

Transforms on SVG elements use the view box as their reference box by default, so `origin-center` may not be the center of your circle. Add `[transform-box:fill-box]` so the origin is the element's own box.

### How do I announce a success state to screen readers?

Hide the decorative SVG with `aria-hidden` and put a short message in a `role="status"` element that is rendered from the start. Change only its text when the state changes, and it will be read politely.
