Retour au catalogue
Trust Badges Animated
Badges de confiance (SSL, GDPR, SOC2, uptime…) en pop staggeré au scroll. Hover avec glow accent et bounce d'icône. Compteur animé du nombre d'entreprises clientes en bas.
trustmedium Both Responsive a11y
corporateminimalelegantsaaslegalmedicaluniversalgrid
Theme
"use client";
import { useRef, useState, useEffect } from "react";
import { motion, useInView, useMotionValue, useSpring, animate } from "framer-motion";
import * as LucideIcons from "lucide-react";
interface TrustBadge {
id: string;
icon: string;
label: string;
value: string;
}
interface TrustBadgesAnimatedProps {
title?: string;
subtitle?: string;
trustedCount?: number;
badges?: TrustBadge[];
}
const EASE: [number, number, number, number] = [0.16, 1, 0.3, 1];
function getIcon(name: string): React.ElementType {
return (
(LucideIcons as unknown as Record<string, React.ElementType>)[name] ??
LucideIcons.ShieldCheck
);
}
function Badge({ badge, index, isInView }: { badge: TrustBadge; index: number; isInView: boolean }) {
const Icon = getIcon(badge.icon);
const [hovered, setHovered] = useState(false);
return (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={
isInView
? { opacity: 1, scale: [0, 1.05, 1] }
: {}
}
transition={{
duration: 0.5,
delay: index * 0.08,
ease: EASE,
scale: { times: [0, 0.7, 1] },
}}
onHoverStart={() => setHovered(true)}
onHoverEnd={() => setHovered(false)}
style={{
position: "relative",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.625rem",
padding: "1.5rem 1.25rem",
borderRadius: "var(--radius-xl, 1rem)",
background: "var(--color-background-card)",
border: "1px solid var(--color-border)",
cursor: "default",
transition: "box-shadow 0.25s ease, border-color 0.25s ease",
boxShadow: hovered
? "0 0 0 1px var(--color-accent), 0 4px 32px color-mix(in srgb, var(--color-accent) 18%, transparent)"
: "none",
borderColor: hovered ? "var(--color-accent)" : "var(--color-border)",
}}
>
{/* Subtle accent glow bg */}
<motion.div
aria-hidden
animate={{ opacity: hovered ? 1 : 0 }}
transition={{ duration: 0.25 }}
style={{
position: "absolute",
inset: 0,
borderRadius: "inherit",
background:
"radial-gradient(ellipse at 50% 0%, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent 70%)",
pointerEvents: "none",
}}
/>
{/* Icon with bounce on hover */}
<motion.div
animate={hovered ? { y: [0, -4, 0] } : { y: 0 }}
transition={
hovered
? { type: "spring", stiffness: 400, damping: 12 }
: { duration: 0.2 }
}
style={{
width: 44,
height: 44,
borderRadius: "var(--radius-md, 0.5rem)",
background: "color-mix(in srgb, var(--color-accent) 10%, transparent)",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
zIndex: 1,
flexShrink: 0,
}}
>
<Icon size={22} style={{ color: "var(--color-accent)" }} strokeWidth={1.75} />
</motion.div>
{/* Value */}
<div
style={{
fontSize: "1.25rem",
fontWeight: 800,
letterSpacing: "-0.025em",
color: "var(--color-foreground)",
position: "relative",
zIndex: 1,
lineHeight: 1,
}}
>
{badge.value}
</div>
{/* Label */}
<div
style={{
fontSize: "0.8125rem",
fontWeight: 500,
color: "var(--color-foreground-muted)",
textAlign: "center",
position: "relative",
zIndex: 1,
lineHeight: 1.4,
}}
>
{badge.label}
</div>
</motion.div>
);
}
function AnimatedCounter({ target, suffix = "" }: { target: number; suffix?: string }) {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true });
const motionVal = useMotionValue(0);
const spring = useSpring(motionVal, { damping: 30, stiffness: 60 });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (isInView) {
void animate(motionVal, target, { duration: 2, ease: "easeOut" });
}
}, [isInView, target, motionVal]);
useEffect(() => {
const unsub = spring.on("change", (v: number) => {
setDisplay(Math.round(v));
});
return unsub;
}, [spring]);
return (
<span ref={ref}>
{display.toLocaleString()}
{suffix}
</span>
);
}
export default function TrustBadgesAnimated({
title = "Conçu pour la confiance",
subtitle = "Chaque standard de sécurité respecté. Chaque engagement tenu.",
trustedCount = 12000,
badges = [],
}: TrustBadgesAnimatedProps) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, margin: "-80px" });
return (
<section
style={{
padding: "var(--section-padding-y, 5rem) 0",
background: "var(--color-background)",
}}
>
<div
ref={ref}
style={{
maxWidth: "var(--container-max-width, 1200px)",
margin: "0 auto",
padding: "0 var(--container-padding-x, 1.5rem)",
textAlign: "center",
}}
>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.55, ease: EASE }}
style={{ marginBottom: "3rem" }}
>
<h2
style={{
fontSize: "clamp(1.75rem, 3.5vw, 3rem)",
fontWeight: 800,
letterSpacing: "-0.03em",
lineHeight: 1.1,
color: "var(--color-foreground)",
marginBottom: "0.625rem",
}}
>
{title}
</h2>
<p
style={{
fontSize: "1.0625rem",
color: "var(--color-foreground-muted)",
maxWidth: "460px",
margin: "0 auto",
lineHeight: 1.65,
}}
>
{subtitle}
</p>
</motion.div>
{/* Badges grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 160px), 1fr))",
gap: "1rem",
marginBottom: "2.5rem",
}}
>
{badges.map((badge, i) => (
<Badge key={badge.id} badge={badge} index={i} isInView={isInView} />
))}
</div>
{/* Trusted by line */}
<motion.p
initial={{ opacity: 0, y: 8 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: badges.length * 0.08 + 0.3, ease: EASE }}
style={{
fontSize: "0.9375rem",
color: "var(--color-foreground-muted)",
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
}}
>
<span
style={{
fontWeight: 700,
color: "var(--color-foreground)",
}}
>
<AnimatedCounter target={trustedCount} suffix="+" />
</span>{" "}
entreprises nous font confiance dans le monde entier
</motion.p>
</div>
</section>
);
}