Feedback

Success Check

One-shot SVG success mark: circle stroke draws, the check follows with slight overlap, and the whole mark settles from 0.9 scale.

Install

npx shadcn@latest add @paragon/success-check

success-check.tsx

"use client";

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

export interface SuccessCheckProps extends React.ComponentProps<"svg"> {
  /** Rendered size in px. */
  size?: number;
  strokeWidth?: number;
  /** Announced to assistive tech. */
  label?: string;
}

/**
 * One-shot success mark: the circle stroke draws first, the check follows
 * with a slight overlap (~500ms total), while the whole mark settles from
 * scale 0.9→1. Success states persist, so it does not loop — remount to
 * replay. Under reduced motion it renders fully drawn.
 */
export function SuccessCheck({
  size = 48,
  strokeWidth = 2.5,
  label = "Success",
  className,
  ...props
}: SuccessCheckProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");

  // viewBox 52x52, circle r=23 → circumference ≈ 144.5
  const circle = 2 * Math.PI * 23;
  const check = 30; // path length of the check stroke, slightly padded

  return (
    <>
      <style href={`paragon-success-check-${id}`} precedence="paragon">{`
        @keyframes success-circle-${id} {
          from { stroke-dashoffset: ${circle}; }
          to { stroke-dashoffset: 0; }
        }
        @keyframes success-check-${id} {
          from { stroke-dashoffset: ${check}; }
          to { stroke-dashoffset: 0; }
        }
        @keyframes success-settle-${id} {
          from { opacity: 0; transform: scale(0.9); }
          to { opacity: 1; transform: scale(1); }
        }
        [data-success-check="${id}"] {
          animation: success-settle-${id} 250ms var(--ease-out) both;
        }
        [data-success-check="${id}"] [data-part="circle"] {
          stroke-dasharray: ${circle};
          animation: success-circle-${id} 300ms var(--ease-out) both;
        }
        [data-success-check="${id}"] [data-part="check"] {
          stroke-dasharray: ${check};
          animation: success-check-${id} 260ms 240ms var(--ease-out) both;
        }
        @media (prefers-reduced-motion: reduce) {
          [data-success-check="${id}"],
          [data-success-check="${id}"] [data-part] {
            animation: none;
          }
        }
      `}</style>
      <svg
        data-success-check={id}
        role="img"
        aria-label={label}
        viewBox="0 0 52 52"
        width={size}
        height={size}
        fill="none"
        className={cn("text-emerald-600 dark:text-emerald-400", className)}
        {...props}
      >
        <circle
          data-part="circle"
          cx="26"
          cy="26"
          r="23"
          stroke="currentColor"
          strokeWidth={strokeWidth}
          strokeLinecap="round"
          strokeDashoffset={0}
          transform="rotate(-90 26 26)"
        />
        <path
          data-part="check"
          d="M16 27l7 7 13-14"
          stroke="currentColor"
          strokeWidth={strokeWidth}
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeDashoffset={0}
        />
      </svg>
    </>
  );
}