Description
Success Check runs four animations in parallel when an action completes: the wrapper fades in, rotates from a slight tilt to upright, rises from below with a Y-bob, and de-blurs. At the same time, the SVG path draws its stroke from offset to zero, so the checkmark appears to write itself. The animations share the same duration and ease, so all four properties land together rather than staggering awkwardly. This pattern suits payment confirmations, file uploads, form saves, or any moment where βdoneβ deserves a beat of visual punctuation.
Example
CSS
:root {
--check-opacity-dur: 550ms;
--check-rotate-dur: 550ms;
--check-rotate-from: 80deg;
--check-bob-dur: 450ms;
--check-y-amount: 40px;
--check-blur-dur: 500ms;
--check-blur-from: 10px;
--check-path-dur: 550ms;
--check-path-delay: 80ms;
--check-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--check-ease-opacity: cubic-bezier(0.22, 1, 0.36, 1);
--check-ease-rotate: cubic-bezier(0.22, 1, 0.36, 1);
--check-ease-bob: cubic-bezier(0.34, 1.35, 0.64, 1);
--check-ease-path: cubic-bezier(0.22, 1, 0.36, 1);
}
/* Wrapper drives the appear animation; it doesn't own any
sizing or color so you can drop in any icon. */
.t-success-check {
display: inline-block;
transform-origin: center;
opacity: 0;
will-change: transform, opacity, filter;
}
/* overflow: visible keeps the stroke from clipping while it
draws; display: block kills the inline whitespace under SVGs. */
.t-success-check svg {
display: block;
overflow: visible;
}
/* Stroke-draw setup. Replace 20 with the result of
path.getTotalLength() for your path; round caps mean any
sub-pixel overshoot is invisible. */
.t-success-check svg path {
stroke-dasharray: 20;
stroke-dashoffset: 20;
}
.t-success-check[data-state="in"] {
animation:
t-check-fade var(--check-opacity-dur) var(--check-ease-opacity) forwards,
t-check-rotate var(--check-rotate-dur) var(--check-ease-rotate) forwards,
t-check-blur var(--check-blur-dur) var(--check-ease-out) forwards,
t-check-bob var(--check-bob-dur) var(--check-ease-bob) forwards;
}
.t-success-check[data-state="in"] svg path {
animation: t-check-draw var(--check-path-dur) var(--check-ease-path)
var(--check-path-delay, 0ms) forwards;
}
@keyframes t-check-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes t-check-rotate {
from {
transform: rotate(var(--check-rotate-from));
}
to {
transform: rotate(0deg);
}
}
@keyframes t-check-blur {
from {
filter: blur(var(--check-blur-from));
}
to {
filter: blur(0);
}
}
@keyframes t-check-bob {
from {
translate: 0 var(--check-y-amount);
}
to {
translate: 0 0;
}
}
@keyframes t-check-draw {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.t-success-check {
animation: none !important;
opacity: 1;
}
.t-success-check svg path {
animation: none !important;
stroke-dashoffset: 0 !important;
}
}
React
import { useEffect, useRef, useState } from "react";
import "./success-check.css"; // paste the CSS above
function Check() {
const pathRef = useRef<SVGPathElement>(null);
const [state, setState] = useState<"out" | "in">("out");
useEffect(() => {
const path = pathRef.current;
if (path) {
const len = Math.ceil(path.getTotalLength());
path.style.strokeDasharray = String(len);
path.style.strokeDashoffset = String(len);
}
const id = requestAnimationFrame(() => setState("in"));
return () => cancelAnimationFrame(id);
}, []);
return (
<span className="t-success-check" data-state={state} aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none" style={{ width: 56, height: 56 }}>
<path
ref={pathRef}
d="M14 24 l7 7 l13 -15"
stroke="currentColor"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
);
}
export function SuccessCheck() {
const [key, setKey] = useState(0);
return (
<div>
<Check key={key} />
<button onClick={() => setKey((k) => k + 1)}>Replay</button>
</div>
);
}
Variables
| Variable | Default | Notes |
|---|---|---|
--check-opacity-dur | 550ms | sourced from --p10-opacity-dur |
--check-rotate-dur | 550ms | sourced from --p10-rotate-dur |
--check-rotate-from | 80deg | sourced from --p10-rotate-from |
--check-bob-dur | 450ms | sourced from --p10-bob-dur |
--check-y-amount | 40px | sourced from --p10-y-amount |
--check-blur-dur | 500ms | sourced from --p10-blur-dur |
--check-blur-from | 10px | sourced from --p10-blur-from |
--check-path-dur | 550ms | sourced from --p10-path-dur |
--check-path-delay | 80ms | sourced from --p10-path-delay |
--check-ease-out | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p10-ease-out |
--check-ease-opacity | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p10-ease-opacity |
--check-ease-rotate | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p10-ease-rotate |
--check-ease-bob | cubic-bezier(0.34, 1.35, 0.64, 1) | sourced from --p10-ease-bob |
--check-ease-path | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p10-ease-path |
Credit
Adapted from Success Check on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.