Retour au catalogue

Newsletter Animated Input

Input avec placeholder anime qui cycle entre differents exemples d'email en fade in/out via AnimatePresence. Bouton avec hover scale spring.

newslettermedium Both Responsive a11y
minimalplayfulsaasagencyuniversalcentered
Theme
"use client";

import { useRef, useState, useEffect } from "react";
import { motion, useInView, AnimatePresence } from "framer-motion";
import { Send } from "lucide-react";

interface NewsletterAnimatedInputProps {
  title?: string;
  subtitle?: string;
  ctaLabel?: string;
  placeholders?: string[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

function AnimatedPlaceholder({ placeholders, isFocused }: { placeholders: string[]; isFocused: boolean }) {
  const [index, setIndex] = useState(0);

  useEffect(() => {
    if (isFocused) return;
    const interval = setInterval(() => {
      setIndex((prev) => (prev + 1) % placeholders.length);
    }, 2800);
    return () => clearInterval(interval);
  }, [placeholders.length, isFocused]);

  if (isFocused) return null;

  return (
    <div
      style={{
        position: "absolute",
        left: "1.5rem",
        top: "50%",
        transform: "translateY(-50%)",
        pointerEvents: "none",
        overflow: "hidden",
        height: "1.2em",
      }}
    >
      <AnimatePresence mode="wait">
        <motion.span
          key={index}
          initial={{ opacity: 0, y: 12 }}
          animate={{ opacity: 0.5, y: 0 }}
          exit={{ opacity: 0, y: -12 }}
          transition={{ duration: 0.35, ease: EASE }}
          style={{
            display: "block",
            fontSize: "0.9375rem",
            color: "var(--color-foreground-muted)",
            whiteSpace: "nowrap",
          }}
        >
          {placeholders[index]}
        </motion.span>
      </AnimatePresence>
    </div>
  );
}

export default function NewsletterAnimatedInput({
  title = "Restez informe",
  subtitle = "Recevez nos dernieres actualites directement dans votre boite mail.",
  ctaLabel = "S'abonner",
  placeholders = [
    "votre@email.com",
    "newsletter@exemple.fr",
    "contact@startup.io",
    "hello@agence.com",
  ],
}: NewsletterAnimatedInputProps) {
  const ref = useRef<HTMLDivElement>(null);
  const isInView = useInView(ref, { once: true, margin: "-80px" });
  const [focused, setFocused] = useState(false);
  const [value, setValue] = useState("");

  return (
    <section
      style={{
        padding: "var(--section-padding-y) 0",
        background: "var(--color-background)",
      }}
    >
      <div ref={ref} style={{ maxWidth: "560px", margin: "0 auto", padding: "0 var(--container-padding-x)", textAlign: "center" }}>
        <motion.div
          initial={{ opacity: 0, y: 16 }}
          animate={isInView ? { opacity: 1, y: 0 } : {}}
          transition={{ duration: 0.5, ease: EASE }}
        >
          <h2 style={{ fontFamily: "var(--font-sans)", fontSize: "clamp(1.5rem, 3vw, 2.5rem)", fontWeight: 700, lineHeight: 1.15, letterSpacing: "-0.02em", color: "var(--color-foreground)", marginBottom: "0.75rem" }}>
            {title}
          </h2>
          <p style={{ fontSize: "1rem", lineHeight: 1.65, color: "var(--color-foreground-muted)", marginBottom: "2rem" }}>
            {subtitle}
          </p>
        </motion.div>

        <motion.div
          initial={{ opacity: 0, y: 12 }}
          animate={isInView ? { opacity: 1, y: 0 } : {}}
          transition={{ duration: 0.5, delay: 0.12, ease: EASE }}
          style={{
            display: "flex",
            gap: "0.5rem",
            alignItems: "center",
          }}
        >
          <div style={{ flex: 1, position: "relative" }}>
            <input
              type="email"
              value={value}
              onChange={(e) => setValue(e.target.value)}
              onFocus={() => setFocused(true)}
              onBlur={() => setFocused(false)}
              style={{
                width: "100%",
                padding: "1rem 1.5rem",
                borderRadius: "var(--radius-full)",
                border: "1px solid var(--color-border)",
                background: "var(--color-background-card)",
                color: "var(--color-foreground)",
                fontSize: "0.9375rem",
                outline: "none",
                transition: "border-color 0.3s ease, box-shadow 0.3s ease",
                borderColor: focused ? "var(--color-accent)" : "var(--color-border)",
                boxShadow: focused
                  ? "0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent)"
                  : "none",
              }}
            />
            {!value && (
              <AnimatedPlaceholder
                placeholders={placeholders}
                isFocused={focused}
              />
            )}
          </div>

          <motion.button
            whileHover={{ scale: 1.05 }}
            whileTap={{ scale: 0.97 }}
            transition={{ type: "spring", stiffness: 400, damping: 17 }}
            style={{
              padding: "1rem 1.75rem",
              borderRadius: "var(--radius-full)",
              background: "var(--color-accent)",
              color: "var(--color-foreground)",
              fontWeight: 600,
              fontSize: "0.9375rem",
              border: "none",
              cursor: "pointer",
              display: "inline-flex",
              alignItems: "center",
              gap: 6,
              whiteSpace: "nowrap",
            }}
          >
            {ctaLabel}
            <Send style={{ width: 15, height: 15 }} />
          </motion.button>
        </motion.div>

        <motion.p
          initial={{ opacity: 0 }}
          animate={isInView ? { opacity: 1 } : {}}
          transition={{ duration: 0.4, delay: 0.3 }}
          style={{
            fontSize: "0.75rem",
            color: "var(--color-foreground-light, var(--color-foreground-muted))",
            marginTop: "1rem",
          }}
        >
          Gratuit. Pas de spam. Desabonnement en un clic.
        </motion.p>
      </div>
    </section>
  );
}

Avis

Newsletter Animated Input — React Newsletter Section — Incubator