Certificate Issue
The border strokes itself around the sheet, the seal settles on, and the name is written last.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Certificate Issue
*
* A certificate being made rather than being shown. The border strokes
* itself around the sheet, the seal settles onto it, and the recipient's
* name arrives last — the order in which the document would actually be
* produced, which is what makes the result feel issued to someone.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The sheet and rules are mixed from the inherited text color; the ink
* colour is semantic and stays literal.
* Works with zero props; tune via `variant`, `recipient`, `course`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CertificateIssueProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Line above the recipient. */
eyebrow?: string;
/** Who the certificate is for. */
recipient?: string;
/** What it is for. */
course?: string;
/** Issue date. */
issued?: string;
/** Issuing body. */
issuer?: string;
/** Ink colour. Semantic, so it stays literal. */
ink?: string;
/** Fires once the name has arrived. */
onIssued?: () => void;
};
type VariantConfig = {
/** How long the border takes to close. */
draw: number;
/** When the seal lands. */
sealAt: number;
/** When the name arrives. */
nameAt: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A brisk issue, for a certificate shown inline in a course list.
subtle: {
draw: 0.62,
sealAt: 0.4,
nameAt: 0.56,
spring: { type: "spring", stiffness: 520, damping: 42 },
},
// The all-purpose setting.
default: {
draw: 0.95,
sealAt: 0.62,
nameAt: 0.86,
spring: { type: "spring", stiffness: 400, damping: 35 },
},
// A slow, ceremonial draw for a completion screen.
playful: {
draw: 1.35,
sealAt: 0.88,
nameAt: 1.22,
spring: { type: "spring", stiffness: 320, damping: 30 },
},
};
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
// A rounded rectangle as an explicit path: `pathLength` is only reliable
// on real path geometry, and the border has to draw as one continuous
// stroke rather than four edges appearing at once.
const FRAME =
"M17 3 H283 A14 14 0 0 1 297 17 V179 A14 14 0 0 1 283 193 H17 A14 14 0 0 1 3 179 V17 A14 14 0 0 1 17 3 Z";
export default function CertificateIssue({
variant = "default",
eyebrow = "Certificate of completion",
recipient = "Priya Raman",
course = "Advanced Data Modelling",
issued = "18 August 2026",
issuer = "Northwind Academy",
ink = "#8A6A2F",
onIssued,
}: CertificateIssueProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const still = !!reduceMotion;
const line = (at: number) => ({
initial: still ? { opacity: 0 } : { opacity: 0, y: 6 },
animate: still ? { opacity: 1 } : { opacity: 1, y: 0 },
transition: {
delay: still ? 0.04 : at,
duration: 0.36,
ease: [0.22, 1, 0.36, 1] as [number, number, number, number],
},
});
return (
<div
style={{
position: "relative",
width: 300,
height: 196,
borderRadius: 16,
background: tone(4),
}}
>
<svg
viewBox="0 0 300 196"
width="100%"
height="100%"
fill="none"
aria-hidden
style={{ position: "absolute", inset: 0 }}
>
<motion.path
d={FRAME}
stroke={ink}
strokeWidth="1.6"
strokeLinecap="round"
initial={{ pathLength: still ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: still ? 0 : cfg.draw, ease: [0.4, 0, 0.2, 1] }}
opacity="0.75"
/>
<motion.path
d="M11 11 H289 A6 6 0 0 1 295 17 V179 A6 6 0 0 1 289 185 H11 A6 6 0 0 1 5 179 V17 A6 6 0 0 1 11 11 Z"
stroke={ink}
strokeWidth="0.8"
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
transition={{
duration: still ? 0 : 0.4,
delay: still ? 0 : cfg.draw * 0.7,
ease: "easeOut",
}}
/>
</svg>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
padding: "24px 26px 20px",
}}
>
<motion.span
{...line(cfg.draw * 0.55)}
style={{
fontSize: 9.5,
fontWeight: 640,
letterSpacing: "0.15em",
textTransform: "uppercase",
color: tone(46),
}}
>
{eyebrow}
</motion.span>
{/* The name is the last thing to arrive and the only thing that
arrives alone — everything before it was the blank form. */}
<motion.span
{...line(cfg.nameAt)}
onAnimationComplete={onIssued}
style={{
marginTop: 12,
fontSize: 23,
fontWeight: 620,
letterSpacing: "-0.015em",
lineHeight: 1.15,
}}
>
{recipient}
</motion.span>
<motion.span
{...line(cfg.nameAt + 0.08)}
style={{ marginTop: 5, fontSize: 12.5, color: tone(58) }}
>
{course}
</motion.span>
<motion.span
{...line(cfg.draw * 0.8)}
style={{
marginTop: "auto",
display: "flex",
alignItems: "flex-end",
gap: 10,
fontSize: 10.5,
color: tone(46),
maxWidth: 168,
lineHeight: 1.5,
}}
>
<span style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontWeight: 600, color: tone(58) }}>{issuer}</span>
<span>{issued}</span>
</span>
</motion.span>
</div>
{/* The seal is applied to a finished sheet, so it lands between the
border closing and the name being written. */}
<motion.div
aria-hidden
initial={
still
? { opacity: 0 }
: { opacity: 0, scale: 0.72, rotate: -12 }
}
animate={
still ? { opacity: 1 } : { opacity: 1, scale: 1, rotate: -6 }
}
transition={
still
? { duration: 0.2, ease: "easeOut" }
: {
...cfg.spring,
delay: cfg.sealAt,
opacity: { duration: 0.16, delay: cfg.sealAt, ease: "easeOut" },
}
}
style={{
position: "absolute",
right: 26,
bottom: 22,
width: 62,
height: 74,
color: ink,
lineHeight: 0,
}}
>
<svg viewBox="0 0 62 74" width="100%" height="100%" fill="none">
<path
d="M23.5 42 20.5 71l10.5-6 10.5 6-3-29"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
opacity="0.65"
/>
<circle cx="31" cy="28" r="20" fill="currentColor" opacity="0.1" />
<circle cx="31" cy="28" r="20" stroke="currentColor" strokeWidth="1.6" />
<circle
cx="31"
cy="28"
r="15"
stroke="currentColor"
strokeWidth="0.9"
strokeDasharray="2.5 3.5"
opacity="0.7"
/>
<path
d="M25.4 28.4 29.6 32.6 37.2 24"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.div>
</div>
);
}About this pattern
A certificate being produced rather than displayed. The frame draws as one continuous stroke, an inner rule fades in behind it, the seal lands on the finished sheet, and only then does the recipient's name arrive — the order the document would genuinely be made in, which is what makes the result feel issued to a person instead of rendered from a template. The frame is an explicit rounded-rectangle path rather than a styled element, because stroke length is only dependable on real path geometry and the border has to close as one line instead of four edges appearing together. The seal is inline SVG with a scalloped inner ring, so nothing ships alongside the copied file.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Achievements
Completion credential assembled on screen before it can be downloaded.
Related patterns
- Rank PromotionThe old tier ring fades while the new tier's ring strokes itself around the badge.
- Trophy Shelf AddA new award settles into the first slot while the awards already there slide over to make room.
- Year in Review StatA headline figure counts up over two deliberate seconds, then the line that explains it arrives.