Certificate Reveal
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">{`
        /* A wide, soft specular band glides across once. The band is 150% of
           the card wide and blurred, so its edges are feathered — it reads as
           light catching foil, not a rectangle sliding by. It starts fully off
           the left (-160%) and exits fully off the right (260%). Opacity eases
           in then out over the pass so there is no hard on/off pop. */
        @keyframes certificate-shine {
          0% { transform: translateX(-160%) rotate(12deg); opacity: 0; }
          18% { opacity: 1; }
          82% { opacity: 1; }
          100% { transform: translateX(260%) rotate(12deg); opacity: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-certificate-shine] { animation: none !important; opacity: 0 !important; }
        }
      `}</style>

      <div
        className="relative overflow-hidden rounded-xl 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,
        }}
      >
        {/* Inner hairline frame, like a real certificate's double rule.
            Concentric: 14px outer radius − 8px inset = 6px. */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-2 rounded-[calc(var(--radius-xl)-8px)] border border-border/70"
        />

        {/* Single specular sweep — a soft, wide, feathered sheen that glides
            once after the reveal settles. Extra-tall/wide so the rotated,
            blurred band never shows an edge; the gradient peak is low-opacity
            with a bright hairline core, so it feels like light, not a box. */}
        {animate && revealed && (
          <span
            data-certificate-shine
            aria-hidden
            className="pointer-events-none absolute -inset-y-8 left-0 w-[55%] blur-md"
            style={{
              background:
                "linear-gradient(105deg, transparent 30%, color-mix(in oklch, var(--color-foreground) 6%, transparent) 44%, color-mix(in oklch, var(--color-foreground) 13%, transparent) 50%, color-mix(in oklch, var(--color-foreground) 6%, transparent) 56%, transparent 70%)",
              animation: "certificate-shine 1200ms var(--ease-in-out) 400ms 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>
  );
}