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>
  );
}

Avis

Trust Badges Animated — React Trust Section — Incubator