Success Check

A ring fills in while a checkmark fades, rotates, and draws itself into place.

Installation

npx shadcn@latest add @loomui/success-check

Usage

import { SuccessCheck } from "@/components/ui/success-check"
<SuccessCheck active={status === "success"} />

How it works

Two elements sit on top of each other. The ring is a persistent SVG <circle> that fills from transparent to var(--foreground) on a transition, using the same overshoot curve Switch's thumb settles with. It's never removed from the DOM, so toggling active back and forth just reverses that transition normally.

The checkmark is different. It's only mounted while active is true, keyed by an internal replay counter that increments on every false-to-true edge. A checkmark that draws itself in should replay in full every time it appears, and a transition can't do that reliably on an element that never changes state after it mounts. A fresh element with animations sidesteps the problem, since animations always run from their from keyframe on mount no matter what happened before.

The reveal layers four separate animations on the same element (fade, rotate, blur, a small vertical bob), each with its own easing. Rotate rides on transform, but the bob rides on the independent translate property instead of transform: translateY(), so the two don't fight over the same property and the bob can carry its own bounce curve while the rotate settles on a plain ease-out. The checkmark draws on a delay (30% of --dur) via pathLength="1" and an animated stroke-dashoffset, so it visibly starts after the glyph has already begun materializing rather than arriving in the same instant.

This is loom's read on a technique from transitions.dev. The original leans on longer, uncapped durations with no scale limit on its properties. Loom's motion contract caps product-surface animation at 300ms, so the durations, travel distances, and blur radius here are all pulled in to fit.

Props

PropTypeDefaultDescription
activebooleanfalseFilled ring + drawn check when true, empty ring when not.
durationnumber260Reveal duration in ms, exposed as --dur.
classNamestring-Merged onto the wrapper.

The wrapper also exposes --size (default 1.5rem) as a CSS custom property. Override it through style to resize the glyph.

Any other <span> prop is forwarded.

Reduced motion

The ring's transition, the checkmark's four entrance animations, and its stroke-draw animation are all turned off. The component still jumps straight to its filled-and-checked or empty state, it just doesn't animate to get there.