Success Check
A ring draws itself closed, the check mark strokes in, and the badge gives one small pop to say the job is done.
Payment complete
Create an animated success badge in SVG and CSS. Draw a circle (r 26) and a check path inside a 64px viewBox, both with stroke only. Set each stroke-dasharray to its path length and stroke-dashoffset to the same value. Animate the circle offset to 0 over 600ms with cubic-bezier(0.65, 0, 0.35, 1), then the check offset to 0 over 300ms starting at 550ms, then fill the circle by scaling a filled disc from 0 to 1 over 260ms at 450ms so the check strokes in on top of it, and finally pop the whole badge with scale 1 → 1.1 → 1 over 300ms at 850ms. Use animation-fill-mode both so it rests in the finished state. Add a Replay button that remounts the SVG with a new key. Under prefers-reduced-motion, show the finished badge. The caption reads "Payment complete".
Source
'use client'
import { useState } from 'react'
import s from './demo.module.css'
export default function SuccessCheck({ labels }: { labels: Record<string, string> }) {
const [run, setRun] = useState(0)
return (
<div className={s.wrap}>
<svg key={run} className={s.badge} viewBox="0 0 64 64" aria-hidden="true">
<circle className={s.disc} cx="32" cy="32" r="26" />
<circle className={s.ring} cx="32" cy="32" r="26" />
<path className={s.check} d="M21 33l7.5 7.5L44 25" />
</svg>
<p className={s.caption}>{labels.caption}</p>
<button type="button" className={s.replay} onClick={() => setRun((n) => n + 1)}>
{labels.replay}
</button>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 10px;
}
.badge {
width: 72px;
height: 72px;
animation: pop 300ms var(--ease-out) 850ms both;
}
.ring, .check {
fill: none;
stroke: var(--stage-accent);
stroke-width: 3.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.ring {
stroke-dasharray: 164;
stroke-dashoffset: 164;
transform: rotate(-90deg);
transform-origin: center;
animation: draw 600ms var(--ease-in-out) both;
}
.check {
stroke: var(--stage-accent-ink);
stroke-dasharray: 36;
stroke-dashoffset: 36;
animation: draw 300ms var(--ease-out) 550ms both;
}
.disc {
fill: var(--stage-accent);
transform: scale(0);
transform-origin: center;
animation: grow 260ms var(--ease-out) 450ms both;
}
@keyframes draw { to { stroke-dashoffset: 0; } }
@keyframes grow { to { transform: scale(1); } }
@keyframes pop { 50% { transform: scale(1.1); } }
.caption {
margin: 0 0 6px;
font: 600 var(--text-base) / 1.2 var(--font-display);
color: var(--stage-ink);
}
.replay {
height: 32px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.replay:hover { border-color: var(--stage-accent); }
.replay:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.badge, .ring, .check, .disc { animation: none; }
.ring, .check { stroke-dashoffset: 0; }
.disc { transform: none; }
}