feat(landing): hero animé dessiné — la promesse du produit montrée au lieu d'une photo de banque d'images
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m11s

Deux pages de document côte à côte (source FR, traduction qui cycle
EN/ES/DE) : squelette identique (titre, lignes, tableau, graphique),
seuls les mots et les longueurs de lignes changent, avec balayage doré
à chaque changement et points animés sur le connecteur. Respecte le
réglage « réduire les animations ». Légende : « Même mise en page,
nouvelle langue — rien d'autre ne bouge ».
This commit is contained in:
2026-08-30 23:19:07 +02:00
parent 5ae6c48435
commit 9d5b5ce9c3
4 changed files with 196 additions and 14 deletions

View File

@@ -0,0 +1,187 @@
'use client';
import { useEffect, useState } from 'react';
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
import { Languages } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Landing hero visual — the product promise drawn instead of photographed:
* one document whose layout never moves while its language changes.
* Pure geometry (bars, table, chart) + one real translated heading per card.
*/
const SOURCE_HEADING = 'Rapport annuel';
const TARGETS = [
{ code: 'EN', heading: 'Annual report', widths: [0.94, 0.82, 0.88, 0.58, 0.76] },
{ code: 'ES', heading: 'Informe anual', widths: [0.9, 0.86, 0.84, 0.64, 0.8] },
{ code: 'DE', heading: 'Jahresbericht', widths: [0.98, 0.78, 0.92, 0.52, 0.7] },
] as const;
const SOURCE_WIDTHS = [0.92, 0.8, 0.86, 0.62, 0.78];
const CYCLE_MS = 3600;
function TextLines({ widths, accent = false }: { widths: readonly number[]; accent?: boolean }) {
return (
<div className="space-y-[5%]">
{widths.map((w, i) => (
<motion.div
key={i}
variants={{
hidden: { opacity: 0, x: 6 },
show: { opacity: 1, x: 0, transition: { duration: 0.35, ease: 'easeOut' } },
}}
className={cn('h-[4.5%] min-h-[3px] rounded-full', accent ? 'bg-brand-dark/70 dark:bg-white/70' : 'bg-brand-dark/55 dark:bg-white/55')}
style={{ width: `${w * 100}%` }}
/>
))}
</div>
);
}
function MiniTable() {
return (
<div className="grid grid-cols-3 gap-[3%]">
{[0, 1, 2].map((c) => (
<div key={c} className="space-y-[4%]">
<div className="h-[6px] min-h-[3px] rounded-sm bg-brand-accent/60" />
<div className="h-[4px] min-h-[2px] rounded-sm bg-brand-dark/25 dark:bg-white/25" />
<div className="h-[4px] min-h-[2px] w-4/5 rounded-sm bg-brand-dark/25 dark:bg-white/25" />
</div>
))}
</div>
);
}
function MiniChart() {
const heights = [42, 68, 52, 84];
return (
<div className="flex h-full items-end justify-between gap-[6%]">
{heights.map((h, i) => (
<div
key={i}
className={cn('w-full rounded-t-sm', i === 3 ? 'bg-brand-accent' : 'bg-brand-dark/30 dark:bg-white/30')}
style={{ height: `${h}%` }}
/>
))}
</div>
);
}
function DocumentCard({
side,
heading,
widths,
tag,
}: {
side: 'source' | 'target';
heading: string;
widths: readonly number[];
tag: string;
}) {
return (
<div className="relative flex h-full w-[44%] flex-col rounded-lg border border-black/[0.06] bg-white p-[5%] shadow-[0_10px_30px_rgba(26,26,26,0.06)] dark:border-white/[0.08] dark:bg-[#181818]">
<span
className={cn(
'absolute right-[4%] top-[4%] rounded-full px-2 py-0.5 text-[9px] font-black tracking-widest',
side === 'source'
? 'border border-black/10 text-brand-dark/50 dark:border-white/15 dark:text-white/50'
: 'border border-brand-accent/40 bg-brand-accent/10 text-brand-accent'
)}
>
{tag}
</span>
<p className="mb-[6%] font-serif text-[clamp(10px,1.6vw,15px)] font-medium leading-tight tracking-tight text-brand-dark dark:text-white">
{heading}
</p>
<motion.div
key={heading}
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.05 } } }}
initial="hidden"
animate="show"
className="flex flex-1 flex-col justify-between gap-[5%]"
>
<TextLines widths={widths} accent={side === 'target'} />
<div className="h-[16%] min-h-[26px]">
<MiniTable />
</div>
<div className="h-[20%] min-h-[30px]">
<MiniChart />
</div>
</motion.div>
{/* one shimmer sweep per language change — the "translation" moment */}
{side === 'target' && (
<motion.div
key={`sweep-${heading}`}
className="pointer-events-none absolute inset-y-0 w-1/3 bg-gradient-to-r from-transparent via-brand-accent/10 to-transparent"
initial={{ x: '-160%', opacity: 1 }}
animate={{ x: '420%', opacity: 0 }}
transition={{ duration: 0.9, ease: 'easeOut' }}
/>
)}
</div>
);
}
export function HeroVisual() {
const prefersReduced = useReducedMotion();
const [index, setIndex] = useState(0);
useEffect(() => {
if (prefersReduced) return;
const id = setInterval(() => setIndex((i) => (i + 1) % TARGETS.length), CYCLE_MS);
return () => clearInterval(id);
}, [prefersReduced]);
const target = TARGETS[index];
return (
<div className="editorial-card overflow-hidden !bg-brand-muted/30 p-4 dark:!bg-[#1f1f1f]/30 lg:p-6">
<div className="relative aspect-[16/10] overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm dark:border-white/5 dark:bg-[#141414] lg:aspect-[21/9]">
<div className="absolute inset-0 flex items-center justify-center gap-[3%] p-[4%] lg:p-[3%]">
<DocumentCard side="source" heading={SOURCE_HEADING} widths={SOURCE_WIDTHS} tag="FR" />
{/* connector: the translation itself, dots travelling left to right */}
<div className="relative flex h-full w-[10%] min-w-[44px] flex-col items-center justify-center">
<div className="absolute inset-x-[18%] top-1/2 h-px -translate-y-1/2 bg-brand-dark/10 dark:bg-white/10" />
<div className="relative z-10 flex size-9 items-center justify-center rounded-full bg-brand-accent text-white shadow-lg lg:size-10">
<Languages size={16} />
</div>
{!prefersReduced && (
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2">
{[0, 1, 2].map((d) => (
<motion.span
key={d}
className="absolute top-0 size-1 rounded-full bg-brand-accent"
initial={{ left: '0%', opacity: 0 }}
animate={{ left: '100%', opacity: [0, 1, 1, 0] }}
transition={{ duration: 1.6, repeat: Infinity, delay: d * 0.45, ease: 'easeInOut' }}
/>
))}
</div>
)}
<AnimatePresence mode="wait">
<motion.span
key={target.code}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.25 }}
className="absolute bottom-[14%] rounded-full border border-brand-accent/30 px-2 py-0.5 text-[9px] font-black tracking-widest text-brand-accent"
>
{target.code}
</motion.span>
</AnimatePresence>
</div>
<DocumentCard side="target" heading={target.heading} widths={target.widths} tag={target.code} />
</div>
</div>
</div>
);
}

View File

@@ -23,10 +23,10 @@ import {
Languages, Languages,
Monitor, Monitor,
Eye, Eye,
Activity,
} from "lucide-react"; } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { HeroVisual } from "./hero-visual";
import { API_BASE } from "@/lib/config"; import { API_BASE } from "@/lib/config";
import { LanguageSwitcher } from "@/components/ui/language-switcher"; import { LanguageSwitcher } from "@/components/ui/language-switcher";
@@ -134,17 +134,10 @@ const Hero = () => {
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1], delay: 0.2 }} transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
className="relative max-w-5xl mx-auto" className="relative max-w-5xl mx-auto"
> >
<div className="editorial-card p-4 lg:p-6 overflow-hidden !bg-brand-muted/30 dark:!bg-[#1f1f1f]/30"> <HeroVisual />
<div className="aspect-[16/9] lg:aspect-[21/9] bg-white dark:bg-[#141414] rounded-2xl relative overflow-hidden shadow-sm border border-black/5 dark:border-white/5"> <p className="mt-6 text-center text-xs font-light uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50">
<img {t('landing.hero.visualCaption')}
src="https://images.unsplash.com/photo-1499951360447-b19be8fe80f5?auto=format&fit=crop&q=80&w=2000" </p>
alt="Workspace"
className="w-full h-full object-cover filter saturate-[0.8] grayscale-[0.1]"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-brand-dark/5 dark:bg-black/20" />
</div>
</div>
</motion.div> </motion.div>
</div> </div>
</section> </section>

View File

@@ -149,5 +149,6 @@
"landing.translate.aiAnalysis": "Active AI Analysis", "landing.translate.aiAnalysis": "Active AI Analysis",
"landing.translate.processing": "Processing", "landing.translate.processing": "Processing",
"landing.translate.preservingLayout": "Your layout is being preserved", "landing.translate.preservingLayout": "Your layout is being preserved",
"landing.formats.pill": "COMPATIBILITY" "landing.formats.pill": "COMPATIBILITY",
"landing.hero.visualCaption": "Same layout, new language — nothing else moves"
} }

View File

@@ -149,5 +149,6 @@
"landing.translate.aiAnalysis": "Analyse IA Active", "landing.translate.aiAnalysis": "Analyse IA Active",
"landing.translate.processing": "Traitement en cours", "landing.translate.processing": "Traitement en cours",
"landing.translate.preservingLayout": "Votre mise en page est en cours de préservation", "landing.translate.preservingLayout": "Votre mise en page est en cours de préservation",
"landing.formats.pill": "COMPATIBILITÉ" "landing.formats.pill": "COMPATIBILITÉ",
"landing.hero.visualCaption": "Même mise en page, nouvelle langue — rien d'autre ne bouge"
} }