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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * One keyframe feeds both strokes: the ring and the check/cross are both
 * <path pathLength={1}>, so the normalised dasharray/dashoffset run 1 → 0 and no
 * real path length ever has to be measured. React 19 dedupes hoisted <style> by
 * href, so dozens of these on a page still ship a single copy.
 */
const KEYFRAMES = `@keyframes sc-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}`

/** The ring: starts at 12 o'clock and closes with two half-arcs — steadier across browsers than one arc. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/success-check.json

Prompt

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 — the whole thing is one inline SVG.

Contract
- 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
- Two <path> elements share one keyframe: a ring drawn from 12 o'clock, then
  the mark. Both carry 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 success mark is one polyline; the error mark is ONE path with two
  subpaths ("M19 19 L33 33 M33 19 L19 33") so a single dash offset draws
  stroke one, then stroke two — the cross is drawn in two beats, not faded in.
- Timing split: the ring gets 55% of `duration`, the mark the remaining 45%
  with the ring's duration as its animation-delay. Fill mode "both" holds the
  invisible start state through the delay and the finished state afterwards.
- onComplete fires from the mark path'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 both strokes redraw from zero. Consumers who prefer it
  can drop replayKey and put a key on the component itself instead.
- prefers-reduced-motion (read through useSyncExternalStore with a false
  server snapshot, so SSR and hydration agree): no animation at all — both
  paths render 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 both paths stroke="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 — both paths follow.
- 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 single `d` string per status — swap in an exclamation
  bar, a plus, or a custom glyph 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 lengthpathLength={1} rescales any path to a length of one, so a single dashoffset: 1 → 0 keyframe 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".
  • Subpaths draw in sequence — one path holding two line segments shares a single dash offset, so the cross draws stroke by stroke instead of both arms appearing at once.
  • Animation events over timersonAnimationEnd is 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 onComplete fires immediately, so any redirect or reveal chained to it still happens.

On This Page