Cards

Certificate Reveal

A completion certificate that eases into view with a single restrained shine sweep.

Install

npx shadcn@latest add @paragon/certificate-reveal

certificate-reveal.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Award } from "lucide-react";
import { cn } from "@/lib/utils";

export interface CertificateRevealProps
  extends React.ComponentProps<"div"> {
  /** Recipient name. */
  recipient: string;
  /** Course or achievement title. */
  title: string;
  /** Issuing org. */
  issuer?: string;
  /** Completion date label. */
  date?: string;
  /** Credential id, shown small at the foot. */
  credentialId?: string;
  /** Suppresses the reveal and shine. */
  static?: boolean;
}

/**
 * A completion certificate that reveals once when it enters view: the card
 * eases up and a single restrained shine sweeps across it (a masked gradient
 * translated once, then done — never looping). Reduced motion shows the
 * finished certificate with no reveal and no sweep.
 */
export function CertificateReveal({
  recipient,
  title,
  issuer = "Paragon Academy",
  date,
  credentialId,
  static: isStatic = false,
  className,
  ...props
}: CertificateRevealProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -48px 0px" });
  const animate = !isStatic && !reducedMotion;
  const revealed = !animate || inView;

  return (
    <div
      ref={ref}
      data-slot="certificate-reveal"
      className={cn("w-full max-w-md", className)}
      {...props}
    >
      <style href="paragon-certificate-reveal" precedence="paragon">{`
        @keyframes certificate-shine {
          from { transform: translateX(-120%) rotate(8deg); }
          to { transform: translateX(220%) rotate(8deg); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-certificate-shine] { animation: none !important; opacity: 0 !important; }
        }
      `}</style>

      <div
        className="relative overflow-hidden rounded-xl border border-border bg-card p-8 text-center shadow-border"
        style={{
          opacity: revealed ? 1 : 0,
          transform: revealed ? "translateY(0)" : "translateY(12px)",
          filter: revealed ? "blur(0px)" : "blur(4px)",
          transition: animate
            ? "opacity 500ms var(--ease-out), transform 500ms var(--ease-out), filter 500ms var(--ease-out)"
            : undefined,
        }}
      >
        {/* Single shine sweep — runs once after the reveal settles. */}
        {animate && revealed && (
          <span
            data-certificate-shine
            aria-hidden
            className="pointer-events-none absolute inset-y-0 -left-1/3 w-1/3"
            style={{
              background:
                "linear-gradient(100deg, transparent, color-mix(in oklch, var(--color-foreground) 10%, transparent), transparent)",
              animation: "certificate-shine 900ms var(--ease-out) 350ms 1 both",
            }}
          />
        )}

        <span
          className="mx-auto flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground"
          aria-hidden
        >
          <Award className="size-6" />
        </span>

        <p className="mt-4 text-xs font-medium tracking-[0.2em] text-muted-foreground uppercase">
          Certificate of Completion
        </p>

        <p className="mt-4 text-xs text-muted-foreground">
          This certifies that
        </p>
        <p className="mt-1 text-2xl font-semibold text-balance">{recipient}</p>

        <p className="mt-3 text-xs text-muted-foreground">
          has successfully completed
        </p>
        <p className="mt-1 text-base font-medium text-balance">{title}</p>

        <div className="mt-6 flex items-center justify-center gap-6 border-t border-border pt-4 text-xs text-muted-foreground">
          <span>{issuer}</span>
          {date && (
            <>
              <span aria-hidden className="text-border">

              </span>
              <span className="tabular-nums">{date}</span>
            </>
          )}
        </div>

        {credentialId && (
          <p className="mt-2 text-[11px] text-muted-foreground/70 tabular-nums">
            Credential {credentialId}
          </p>
        )}
      </div>
    </div>
  );
}