TL;DR 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.
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 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.
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 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, 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 uses the same offset and works it out from a value.
Step-Up OTP Gate running live. Arcs spin around six chunky cells, merge into one check, and the blurred dashboard behind them unlocks.
The state is a union of three strings. A typo like state="done" fails at compile time instead of rendering nothing.
The track is the full circle at 15% opacity, so the shape is there before anything happens.
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.
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.
The check is drawn in 300 ms after a 300 ms delay, so it starts as the ring finishes.
transition-[stroke-dashoffset] names the one property that animates, instead of transition-all.
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 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.
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 goes deeper on this.
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.
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, 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.
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.
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.
The component above is one small icon on purpose. The Step-Up OTP Gate 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.
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].
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.
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.
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.