A completion certificate that eases into view with a single restrained shine sweep.
npx shadcn@latest add @paragon/certificate-reveal"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>
);
}