Feedback
Success Check
An SVG outcome mark that draws its ring and then its stroke — a check for success, a cross for failure.
Preview in your theme
Loading preview…
- Registry dependencies
- utils, use-prefers-reduced-motion
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/success-check.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SuccessCheck" component. No external
dependencies beyond React and the shared use-prefers-reduced-motion hook — the
whole thing is one inline SVG.
Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
registryDependencies) — usePrefersReducedMotion() decides whether the strokes
animate or render at their final frame. If rebuilding without it: a
useSyncExternalStore over matchMedia("(prefers-reduced-motion: reduce)") with
a "change" listener and a server snapshot of false.
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: status?: "success" | "error" (default "success"); size?: number |
"sm" | "md" | "lg" (named sizes map to 32 / 48 / 72 px, default "md");
duration?: number (total draw time in ms, default 900); onComplete?: () =>
void; label?: string (visible caption under the mark); replayKey?: string |
number; aria-label?: string; className merged onto the root via cn().
Behavior
- Every <path> shares one keyframe: a ring drawn from 12 o'clock, then the
mark's strokes. Each carries pathLength={1} and strokeDasharray={1}, so the
keyframe is simply stroke-dashoffset 1 -> 0 regardless of each path's real
length — no circumference math, and it works on <path> in every browser.
- The ring path is two half arcs ("M26 3 A23 23 0 0 1 26 49 A23 23 0 0 1 26 3")
inside a 52x52 viewBox; a single 360-degree arc renders inconsistently.
- The mark is a list of strokes per status: success is one polyline
("M16 27 L23 34 L37 19"); error is TWO separate paths ("M19 19 L33 33",
"M33 19 L19 33"). Not one path with two subpaths — the dash pattern restarts
at every subpath, so a single path would draw both arms at the same time and
look finished halfway through its animation.
- Timing split: the ring gets 55% of `duration`, the mark the remaining 45%,
divided evenly between its strokes — stroke i is delayed by the ring's
duration plus i shares, so the cross draws arm one, then arm two. Fill mode
"both" holds the invisible start state through the delay and the finished
state afterwards.
- onComplete fires from the LAST stroke's onAnimationEnd — an animation event,
never a setTimeout, so it can't drift or fire after unmount. Keep the
callback in a ref so re-renders don't re-arm it.
- Replay: the <svg> is keyed by `${status}:${replayKey}`. Changing either
remounts the SVG and every stroke redraws from zero. Consumers who prefer it
can drop replayKey and put a key on the component itself instead.
- prefers-reduced-motion (read through usePrefersReducedMotion, whose server
snapshot is false, so SSR and hydration agree): no animation at all — every
path renders at stroke-dashoffset 0, i.e. the final frame, and onComplete
fires from an effect on mount. A consumer that navigates away in onComplete
must still work with motion disabled.
Rendering & styling
- Semantic tokens only: the root carries text-[var(--chart-2)] for success and
text-destructive for error, and every path strokes "currentColor" — so a
single text-* class in className recolors the whole mark (cn() lets the
consumer's token win).
- strokeWidth 3 in a 52-unit viewBox with round caps and joins, so thickness
scales with `size`; the caption is text-foreground and stays readable.
- The <svg> carries role="img" plus an aria-label defaulting to "Success" /
"Error"; the optional caption renders as ordinary text below it.
- Keyframes ship inside the component via a React 19 hoisted <style href
precedence> tag, so dozens of instances still produce one style tag.
Customization levers
- Palette: swap text-[var(--chart-2)] for text-primary or any chart token, or
pass a text-* class in className per instance — every path follows.
- Pace: `duration` scales the whole choreography; the 55/45 ring-to-mark split
is one constant if you want the ring quicker and the mark more deliberate.
- Weight: strokeWidth is the only line-thickness knob and it is viewBox
relative, so it stays proportional at every size.
- Shape: the mark is a list of `d` strings per status, drawn one after another
— swap in an exclamation bar, a plus, or a custom glyph (one entry per
stroke) and the timing, replay and reduced-motion handling all keep working
unchanged.
- Composition: `label` covers the common caption; for a full success screen,
render the component bare and lay out your own heading and CTA around it.Concepts
- Outcome affirmation — a drawn mark reads as the system finished and knows the result, which is why it belongs at the end of a flow rather than anywhere progress is still unknown.
- Normalized path length —
pathLength={1}rescales any path to a length of one, so a singledashoffset: 1 → 0keyframe drives a circle, a check and a cross without measuring any of them. - Two-beat choreography — the ring claims the space, then the mark lands inside it; overlapping the two beats destroys the sense of "settled, then confirmed".
- Strokes draw in sequence — the cross is two paths, the second delayed by the first's share of the mark time, so it draws stroke by stroke; one path with two subpaths would not, because the dash pattern restarts at every subpath and both arms would draw at once.
- Animation events over timers —
onAnimationEndis the only completion signal that cannot drift from what the user actually saw, and it disappears with the element instead of leaking. - Reduced motion still resolves — with motion disabled the mark renders at its final frame and
onCompletefires immediately, so any redirect or reveal chained to it still happens.