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