You submitted the form. The spinner stopped. Now something has to say it worked, and a checkmark that draws itself says it better than the word “Success” ever will. It is the small reward at the end of a task, the visual equivalent of a satisfying click.
The whole effect rests on one SVG trick, and once it clicks you will use it for signatures, icons, progress lines, everything. This guide builds the checkmark from an empty stroke to a finished, class-triggered success animation, then hands you a reduced-motion fallback that still communicates. It leans on the core CSS animation model, so the @keyframes and animation bits assume you have seen those before.
A warning before the code. The dramatic two-second draw is overrated. Success feedback should feel fast, because the operation it confirms already finished. Keep the whole thing under about 700 milliseconds or you make a quick action feel slow, which is the opposite of reassuring.
The self-drawing SVG checkmark
Check animation css draws an SVG stroke, not an HTML element. You cannot draw a border into existence, but you can draw an SVG line, and that is the entire reason this effect lives in SVG. So the markup comes first: a viewBox, an optional circle, and a path shaped like a check.
<svg class="check" viewBox="0 0 52 52" width="52" height="52"
role="img" aria-label="Success">
<circle class="check-circle" cx="26" cy="26" r="24" pathLength="100" />
<path class="check-mark" d="M15 27 l7 7 l15 -16" pathLength="100" />
</svg>
See that pathLength="100" on both shapes? That is the detail that saves your afternoon. Normally the dash trick needs the exact geometric length of the path, which you get by calling getTotalLength() in JavaScript and pasting a number like 84.7 into your CSS. Setting pathLength="100" tells the browser to pretend the path is 100 units long, so every dash value becomes a clean percentage. No measuring, no magic numbers, no JavaScript just to draw a line.
The role and aria-label matter as much as the geometry. To a screen reader an unlabeled SVG is nothing, and “your payment went through” is exactly the kind of thing a blind user needs told. Label it.
The stroke-dasharray / stroke-dashoffset trick
Picture the checkmark stroke as one long dash. That is the mental model that makes this make sense. stroke-dasharray normally chops a line into a dashed pattern, but if you set the dash as long as the whole path, you get a single solid dash with one solid gap. Now you have a line made of exactly one dash. stroke-dashoffset slides that dash along the path, and sliding it by its full length pushes it entirely out of view.
So: hide the stroke by offsetting it, then animate the offset back to zero, and the line appears to draw itself.
.check-circle,
.check-mark {
fill: none;
stroke: #16a34a;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.check-mark {
stroke-dasharray: 100; /* one dash as long as the whole path */
stroke-dashoffset: 100; /* pushed fully out of sight */
}
@keyframes draw {
to { stroke-dashoffset: 0; } /* slide it back: the line appears */
}
Because we set pathLength="100", the dasharray and the offset are both just 100. Animating stroke-dashoffset from 100 to 0 draws the stroke from its start point to its end. That is the whole illusion. There is no partial-opacity fade, no clip path, just an offset counting down.
One real browser note, because it bit people for years: engines used to disagree on which end of the path a zero offset revealed first. Modern Chrome, Firefox, and Safari are consistent now, but if a drawing ever runs backward from what you expect, flip the sign of the offset or reverse the path direction in the d attribute. It is a five-second fix once you know the cause.
Adding the circle and a spring pop
Two moves make the check feel alive instead of merely correct. First, the circle draws itself just before the checkmark, so the eye follows a ring closing and then a tick landing inside it. Second, the whole badge pops in with a spring, a scale that overshoots slightly past 100% and settles back. That overshoot is what sells it as physical.
/* the badge springs in from nothing */
.check {
transform: scale(0);
}
.check.is-visible {
animation: pop 300ms cubic-bezier(0.2, 1.4, 0.35, 1) forwards;
}
@keyframes pop {
to { transform: scale(1); }
}
/* circle draws first, then the mark, staggered by a delay */
.check.is-visible .check-circle {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: draw 450ms ease-out forwards;
}
.check.is-visible .check-mark {
animation: draw 300ms ease-out 350ms forwards; /* waits for the circle */
}
The spring is entirely in one number. The cubic-bezier has a y value of 1.4, and any y above 1 pushes the animation past its target before it comes home, which is the overshoot. That is the honest way to build a spring in pure CSS, no library required. If those four numbers look like hieroglyphs, the timing function guide shows how to read and tune a bezier curve on sight.
The checkmark itself gets a short ease-out, not a spring. A success tick wants to arrive quickly and stop, decisive, no wobble. Save the bounce for the circle. And here is the SVG gotcha that catches everyone: if you ever move that scale onto an inner <g> or <path> instead of the root <svg>, it will scale from the corner of the canvas, not its own center, because SVG transform origins default to 0 0. The fix is transform-box: fill-box; transform-origin: center; on that element. On the root <svg>, as written above, you do not need it.
Triggering it on success with a class
How does the check know the payment cleared? It does not, and it should not. CSS animations fire the moment their element mounts, which is wrong for a success state that has to wait for a real result. The fix is to gate every animation behind a class and let your JavaScript add that class when, and only when, the thing actually succeeded.
Notice the CSS above already does this: the resting .check sits at scale(0), invisible and inert, and nothing animates until .is-visible lands on it. Your script does one line.
const check = document.querySelector('.check');
// after the request resolves successfully:
fetch('/api/pay', { method: 'POST' })
.then((res) => {
if (res.ok) check.classList.add('is-visible'); // draw runs now
});
That is the clean seam between logic and motion. The network layer decides whether. The CSS decides how. If you need to replay the check later, remove the class, force a reflow, and add it back, the usual restart dance you will recognize from any looping effect. This success-on-class pattern is the same one that drives the loading and done states in button animation css, so if you built the spinner button there, this will feel familiar.
Want the check to appear when a countdown hits zero instead of when a fetch resolves? Same idea, different trigger. The CSS countdown animation guide covers timing the reveal off an ending count rather than a network event.
Reduced-motion fallback
Show the finished check instantly. That is the fallback, and it is genuinely the right one, because the information here is “it worked,” and a completed checkmark carries that whether or not it drew itself. Someone who asked for less motion still gets the confirmation, just without the animation stealing focus.
@media (prefers-reduced-motion: reduce) {
.check.is-visible {
animation: none;
transform: scale(1); /* already there, no pop */
}
.check.is-visible .check-circle,
.check.is-visible .check-mark {
animation: none;
stroke-dashoffset: 0; /* fully drawn, no self-draw */
}
}
The move is the same every time you do this properly: cut the motion, keep the end state. Set stroke-dashoffset to 0 so both shapes render complete, drop the pop so nothing scales, and the badge simply is, no drama. This is a case where reduced motion loses nothing that mattered, because the meaning was always the final frame, never the trip to it.
Support for all of this is boringly solid. Animating stroke-dashoffset through CSS works in every current browser, the pathLength attribute is widely supported, and prefers-reduced-motion has been safe to rely on for years. There is nothing experimental here, which is exactly why it is worth learning once and reusing forever. The wider case for building fades and reduced states this deliberately is in accessible animation with prefers-reduced-motion.