Secure Checkout Badge
A lock strokes itself shut and the payment fields firm up behind it.
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 { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Secure Checkout Badge
*
* The shackle strokes itself shut, the body lands under it, and only
* then do the payment fields firm up and accept input. The order is the
* claim: the form becomes usable at the moment the lock finishes, so the
* badge is reporting a state rather than decorating one.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the form reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `processorName`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CheckoutSecureBadgeProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Who the connection is secured with. */
processorName?: string;
/** Size of the lock glyph in px. */
size?: number;
/** Fires once the form has firmed up. */
onReady?: () => void;
};
type VariantConfig = {
/** Seconds for the shackle to stroke shut. */
draw: number;
/** Spring for the body landing and the fields firming. */
spring: { type: "spring"; stiffness: number; damping: number };
/** Seconds between fields firming up. */
stagger: number;
};
// Quality rule: a security affordance that bounces undercuts the thing
// it is claiming. Every spring is at or above a 0.8 damping ratio, the
// shackle draws on an eased curve, and the badge text crossfades in a
// fixed slot rather than resizing its row.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Fast enough to be over before the customer looks down at the fields.
subtle: {
draw: 0.26,
spring: { type: "spring", stiffness: 560, damping: 42 },
stagger: 0.03,
},
// The stroke reads as a closing action. All-purpose.
default: {
draw: 0.42,
spring: { type: "spring", stiffness: 420, damping: 35 },
stagger: 0.05,
},
// A deliberate close for a first payment, where the reassurance is
// worth the extra beat.
playful: {
draw: 0.58,
spring: { type: "spring", stiffness: 350, damping: 32 },
stagger: 0.07,
},
};
const ACCENT = "#2F9E6E";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const FIELDS = [
{ id: "number", label: "Card number", value: "•••• •••• •••• 4242", wide: true },
{ id: "expiry", label: "Expires", value: "08 / 29", wide: false },
{ id: "cvc", label: "Security code", value: "•••", wide: false },
];
export default function CheckoutSecureBadge({
variant = "default",
processorName = "Northgate Pay",
size = 22,
onReady,
}: CheckoutSecureBadgeProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const drawDuration = reduceMotion ? 0 : cfg.draw;
const bodyAt = reduceMotion ? 0 : cfg.draw * 0.55;
const readyAt = reduceMotion ? 0.12 : cfg.draw + 0.12;
// The label only claims the connection is secured once the lock has
// finished closing — the wording follows the state, never leads it.
const [secured, setSecured] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setSecured(true), readyAt * 1000);
return () => clearTimeout(timer);
}, [readyAt]);
return (
<div
style={{
width: 330,
padding: 16,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
fontFamily: "inherit",
boxSizing: "border-box",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: 34,
height: 34,
flexShrink: 0,
borderRadius: 10,
background: `color-mix(in srgb, ${ACCENT} 14%, transparent)`,
color: ACCENT,
}}
>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
aria-hidden
style={{ display: "block" }}
>
{/* The shackle strokes shut along its own path — the one
gesture in this component, and the reason the badge reads
as an event rather than a sticker. */}
<motion.path
d="M7.4 11V7.6a4.6 4.6 0 0 1 9.2 0V11"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: drawDuration, ease: "easeOut" }}
/>
<motion.g
initial={{
scale: reduceMotion ? 1 : 0.55,
opacity: reduceMotion ? 1 : 0,
}}
animate={{ scale: 1, opacity: 1 }}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: { ...cfg.spring, delay: bodyAt }
}
style={{ transformOrigin: "12px 16px" }}
>
<rect
x="4.6"
y="10.6"
width="14.8"
height="10.4"
rx="3"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M12 14.4v3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</motion.g>
</svg>
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 13, fontWeight: 650 }}>
Secure payment
</span>
{/* The status line swaps inside a fixed-height slot: the label
changes wording, the row does not change size. */}
<span
style={{
position: "relative",
display: "block",
height: 15,
marginTop: 2,
}}
>
<AnimatePresence initial={false}>
<motion.span
key={secured ? "secured" : "opening"}
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
animate={{ opacity: secured ? 0.7 : 0.45, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
transition={{ duration: 0.22, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 11.5,
lineHeight: "15px",
whiteSpace: "nowrap",
color: secured ? ACCENT : undefined,
fontWeight: secured ? 600 : 500,
}}
>
{secured
? `Encrypted end to end with ${processorName}`
: "Opening a secure connection"}
</motion.span>
</AnimatePresence>
</span>
</span>
</div>
<div
style={{
marginTop: 14,
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 9,
}}
>
{FIELDS.map((field, index) => {
const at = readyAt + (reduceMotion ? 0 : index * cfg.stagger);
return (
// The fields firm up rather than appearing: they were always
// laid out, so nothing reflows when they become usable.
<motion.div
key={field.id}
initial={{ opacity: reduceMotion ? 1 : 0.3 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut", delay: at }}
onAnimationComplete={
index === FIELDS.length - 1 ? onReady : undefined
}
style={{
gridColumn: field.wide ? "1 / -1" : undefined,
minWidth: 0,
}}
>
<div style={{ fontSize: 10.5, opacity: 0.5, marginBottom: 4 }}>
{field.label}
</div>
<div
style={{
height: 34,
display: "flex",
alignItems: "center",
padding: "0 11px",
borderRadius: 10,
border: `1px solid ${tone(14)}`,
background: tone(4),
fontSize: 12.5,
letterSpacing: "0.04em",
fontVariantNumeric: "tabular-nums",
}}
>
{field.value}
</div>
</motion.div>
);
})}
</div>
<motion.button
type="button"
initial={{ opacity: reduceMotion ? 1 : 0.4 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.3,
ease: "easeOut",
delay: readyAt + (reduceMotion ? 0 : FIELDS.length * cfg.stagger),
}}
style={{
marginTop: 14,
width: "100%",
padding: "10px 14px",
fontSize: 13,
fontWeight: 650,
fontFamily: "inherit",
borderRadius: 11,
border: "none",
background: ACCENT,
color: "#FFFFFF",
cursor: "pointer",
}}
>
Pay $111.50
</motion.button>
</div>
);
}About this pattern
The payment step coming up ready. The shackle strokes along its own path, the body lands under it, and the fields firm up only after — the order is the claim, because a form that becomes usable at the moment the lock finishes is reporting a state rather than decorating one. The fields were always laid out and simply gain opacity, so nothing reflows as they become usable, and the status line swaps wording inside a fixed-height slot. A security affordance that bounces undercuts the thing it is asserting, so every settle here happens once.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
Card fields mount inactive and firm up once the secure frame has loaded.
Related patterns
- Checkout Step ProgressThe finished stage folds into a one-line recap while the following one opens and the rail fills.
- Payment Card FlipA saved card turns on its vertical axis to put the security code where it actually lives.
- Bundle Savings HighlightItems select in turn, a bracket draws down their edge, and the combined saving arrives after it.