polish: rebuild hero with asymmetric layout + animated geometric composition
All checks were successful
Build & Push / build-and-push (push) Successful in 1m24s

- Asymmetric layout: text left-aligned (58%), geometric right (42%)
- SVG background: scaled down, pushed right, cleaner architectural lines
- Right column: 3 concentric rings rotating at different speeds/directions
- Orbiting accent dots tracing circular paths
- Tick marks, crosshair center, corner brackets, dimension lines
- Radial glow backdrop for atmospheric warmth
- Word-by-word headline stagger animation preserved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 21:51:13 +01:00
parent d76ecbda7a
commit d033927896
2 changed files with 471 additions and 511 deletions

View File

@@ -2,18 +2,21 @@
import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import Button from '@/components/ui/Button';
import HeroGeometric from '@/components/icons/HeroGeometric';
// Slow drift animation for the SVG background layers
// ─── Animation variants ────────────────────────────────────────────────────
const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as [number, number, number, number];
// Background SVG drift — slow, almost imperceptible
const bgDriftA = {
animate: {
y: [0, -12, 0],
x: [0, 6, 0],
y: [0, -10, 0],
x: [0, 5, 0],
transition: {
duration: 18,
duration: 22,
ease: 'easeInOut' as const,
repeat: Infinity,
repeatType: 'loop' as const,
@@ -23,11 +26,10 @@ const bgDriftA = {
const bgDriftB = {
animate: {
y: [0, 8, 0],
x: [0, -8, 0],
scale: [1, 1.015, 1],
y: [0, 7, 0],
x: [0, -6, 0],
transition: {
duration: 24,
duration: 28,
ease: 'easeInOut' as const,
repeat: Infinity,
repeatType: 'loop' as const,
@@ -35,237 +37,392 @@ const bgDriftB = {
},
};
const bgRotate = {
animate: {
rotate: [0, 1.5, 0, -1.5, 0],
transition: {
duration: 30,
ease: 'easeInOut' as const,
repeat: Infinity,
repeatType: 'loop' as const,
},
},
};
// Word-level stagger container — fires on mount (not scroll)
const heroHeadlineContainer = {
// Headline word stagger container
const headlineContainer = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.07,
delayChildren: 0.15,
staggerChildren: 0.065,
delayChildren: 0.2,
},
},
};
// Individual word reveal
// Per-word reveal — slide up + blur clear
const wordReveal = {
hidden: { opacity: 0, y: 48, filter: 'blur(4px)' },
hidden: { opacity: 0, y: 52, filter: 'blur(6px)' },
visible: {
opacity: 1,
y: 0,
filter: 'blur(0px)',
transition: {
duration: 0.7,
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
duration: 0.72,
ease: EASE_OUT_EXPO,
},
},
};
// Subtitle fade — delay after headline
const subtitleVariant = {
hidden: { opacity: 0, y: 20 },
// Eyebrow label fade
const eyebrowVariant = {
hidden: { opacity: 0, y: 14 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
delay: 0.5,
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
},
transition: { duration: 0.5, delay: 0.08, ease: EASE_OUT_EXPO },
},
};
// CTA scale-in
const ctaVariant = {
hidden: { opacity: 0, scale: 0.93 },
// Subtitle fade-up — delayed after headline completes
const subtitleVariant = {
hidden: { opacity: 0, y: 18 },
visible: {
opacity: 1,
scale: 1,
transition: {
duration: 0.45,
delay: 0.7,
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
},
y: 0,
transition: { duration: 0.6, delay: 0.6, ease: EASE_OUT_EXPO },
},
};
// Decorative separator line fade-in — after subtitle
// Separator expand from origin-left
const separatorVariant = {
hidden: { opacity: 0, scaleX: 0 },
visible: {
opacity: 1,
scaleX: 1,
transition: { duration: 0.55, delay: 0.72, ease: EASE_OUT_EXPO },
},
};
// CTA fade in
const ctaVariant = {
hidden: { opacity: 0, y: 12 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5, delay: 0.82, ease: EASE_OUT_EXPO },
},
};
// Right column entrance — SVG composition fades in from right
const rightColumnVariant = {
hidden: { opacity: 0, x: 24 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.9, delay: 0.1, ease: EASE_OUT_EXPO },
},
};
// Slow full rotation for the large decorative ring
const slowRotate = {
animate: {
rotate: [0, 360],
transition: {
duration: 0.6,
delay: 0.65,
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
duration: 30,
ease: 'linear' as const,
repeat: Infinity,
},
},
};
// ─── Component ────────────────────────────────────────────────────────────
export default function Hero() {
const t = useTranslations('hero');
// Split the raw title by the {exclusively} placeholder so we can inject the styled <em>
// Expected translation shape: "Built {exclusively} for ambitious brands."
// Split the raw title on the {exclusively} placeholder
// Expected translation: "Built {exclusively} for ambitious brands."
const rawTitle: string = t.raw('title');
const parts = rawTitle.split('{exclusively}');
const before = parts[0] ?? '';
const after = parts[1] ?? '';
// Split each segment into words for per-word animation
const beforeWords = before.trim() ? before.trim().split(' ') : [];
const afterWords = after.trim() ? after.trim().split(' ') : [];
const exclusivelyWord = 'exclusively';
// All words in order: before + [exclusively] + after
// Build flat word array with type tagging for accent word
type WordItem =
| { type: 'normal'; text: string }
| { type: 'accent'; text: string };
const beforeWords: WordItem[] = before.trim()
? before.trim().split(' ').map((w) => ({ type: 'normal', text: w }))
: [];
const afterWords: WordItem[] = after.trim()
? after.trim().split(' ').map((w) => ({ type: 'normal', text: w }))
: [];
const allWords: WordItem[] = [
...beforeWords.map((w) => ({ type: 'normal' as const, text: w })),
{ type: 'accent' as const, text: exclusivelyWord },
...afterWords.map((w) => ({ type: 'normal' as const, text: w })),
...beforeWords,
{ type: 'accent', text: 'exclusively' },
...afterWords,
];
return (
<section
id="hero"
aria-label="Hero"
className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-surface-high"
className="relative min-h-screen flex flex-col overflow-hidden bg-surface"
>
{/* ─── Background: animated SVG layers ─────────────────────────── */}
{/* ─── Full-bleed SVG background — spans entire section ─────────── */}
<motion.div
className="absolute inset-0 z-0 pointer-events-none"
{...bgDriftA}
>
<motion.div className="absolute inset-0" {...bgRotate}>
<HeroGeometric className="absolute inset-0 w-full h-full" />
</motion.div>
<HeroGeometric className="absolute inset-0 w-full h-full" />
</motion.div>
{/* Subtle radial gradient vignette over the SVG */}
{/* ─── Radial glow — soft primary haze in right column ──────────── */}
<motion.div
className="absolute inset-0 z-0 pointer-events-none"
{...bgDriftB}
style={{
background:
'radial-gradient(ellipse 70% 60% at 50% 45%, transparent 30%, rgba(248,249,250,0.55) 100%)',
'radial-gradient(ellipse 55% 70% at 78% 48%, rgba(91,164,217,0.045) 0%, transparent 70%)',
}}
aria-hidden="true"
/>
{/* ─── Content ──────────────────────────────────────────────────── */}
<div className="relative z-10 w-full max-w-4xl mx-auto px-6 pt-32 md:pt-40 pb-24 flex flex-col items-center text-center">
{/* ─── Asymmetric two-column layout ─────────────────────────────── */}
<div className="relative z-10 w-full max-w-screen-xl mx-auto px-6 lg:px-12 xl:px-16 flex flex-col lg:flex-row lg:items-center min-h-screen">
{/* Eyebrow */}
<motion.span
className="label-md text-primary mb-6 tracking-widest uppercase"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.05, ease: [0.16, 1, 0.3, 1] }}
>
BESPOKE DIGITAL STUDIO
</motion.span>
{/* ── LEFT COLUMN — text content (55% on desktop) ─────────────── */}
<div className="flex-1 lg:max-w-[58%] flex flex-col justify-center pt-32 pb-16 lg:pt-0 lg:pb-0">
{/* Headline with per-word stagger */}
<motion.h1
className={cn(
'font-serif font-semibold text-on-surface',
'text-5xl sm:text-6xl md:text-7xl leading-[1.08] tracking-[-0.02em]',
'mb-6 flex flex-wrap items-baseline justify-center gap-x-[0.25em] gap-y-1',
)}
variants={heroHeadlineContainer}
initial="hidden"
animate="visible"
aria-label={rawTitle.replace('{exclusively}', exclusivelyWord)}
>
{allWords.map((word, i) =>
word.type === 'accent' ? (
<motion.span
key={`word-accent-${i}`}
variants={wordReveal}
className="inline-block overflow-hidden"
style={{ display: 'inline-block' }}
>
<em className="text-primary font-serif italic not-italic">
{/* Eyebrow */}
<motion.span
className="label-md text-primary tracking-widest uppercase mb-6 block"
variants={eyebrowVariant}
initial="hidden"
animate="visible"
>
Bespoke Digital Studio
</motion.span>
{/* Headline — word-by-word stagger */}
<motion.h1
className="font-serif font-semibold text-on-surface text-5xl sm:text-6xl lg:text-[4.25rem] xl:text-7xl leading-[1.07] tracking-[-0.025em] mb-7 flex flex-wrap items-baseline gap-x-[0.22em] gap-y-1"
variants={headlineContainer}
initial="hidden"
animate="visible"
aria-label={rawTitle.replace('{exclusively}', 'exclusively')}
>
{allWords.map((word, i) =>
word.type === 'accent' ? (
<motion.span
key={`word-accent-${i}`}
variants={wordReveal}
style={{ display: 'inline-block' }}
>
<em className="text-primary font-serif italic">{word.text}</em>
</motion.span>
) : (
<motion.span
key={`word-${i}`}
variants={wordReveal}
style={{ display: 'inline-block' }}
>
{word.text}
</em>
</motion.span>
) : (
<motion.span
key={`word-${i}`}
variants={wordReveal}
className="inline-block"
style={{ display: 'inline-block' }}
>
{word.text}
</motion.span>
),
)}
</motion.h1>
</motion.span>
)
)}
</motion.h1>
{/* Subtitle */}
<motion.p
className="text-lg text-outline leading-relaxed max-w-xl mb-8"
variants={subtitleVariant}
initial="hidden"
animate="visible"
>
{t('subtitle')}
</motion.p>
{/* Subtitle */}
<motion.p
className="text-lg text-outline leading-relaxed max-w-lg mb-8"
variants={subtitleVariant}
initial="hidden"
animate="visible"
>
{t('subtitle')}
</motion.p>
{/* Decorative gradient separator */}
<motion.span
className="block w-20 h-px mb-10 origin-center"
style={{
background:
'linear-gradient(to right, rgba(0,100,148,0.3), rgba(91,164,217,0.3))',
}}
variants={separatorVariant}
{/* Gradient separator — expands from left */}
<motion.span
className="block w-20 h-px mb-10 origin-left"
style={{
background:
'linear-gradient(to right, rgba(0,100,148,0.5), rgba(91,164,217,0.2), transparent)',
}}
variants={separatorVariant}
initial="hidden"
animate="visible"
aria-hidden="true"
/>
{/* CTA row */}
<motion.div
className="flex flex-col sm:flex-row items-start gap-4"
variants={ctaVariant}
initial="hidden"
animate="visible"
>
<Button
variant="primary"
size="lg"
arrow
href="#configure"
>
{t('cta')}
</Button>
<Button
variant="secondary"
size="lg"
href="#work"
>
{t('ctaSecondary')}
</Button>
</motion.div>
</div>
{/* ── RIGHT COLUMN — animated geometric composition (42%) ────── */}
<motion.div
className="hidden lg:flex flex-1 lg:max-w-[42%] relative self-stretch items-center justify-center"
variants={rightColumnVariant}
initial="hidden"
animate="visible"
aria-hidden="true"
/>
{/* CTA row */}
<motion.div
className="flex flex-col sm:flex-row items-center gap-4"
variants={ctaVariant}
initial="hidden"
animate="visible"
>
<Button variant="primary" size="lg" arrow href="#configure">
{t('cta')}
</Button>
<Button
variant="secondary"
size="lg"
href="#work"
className="bg-on-surface/5 hover:bg-on-surface/10"
>
{t('ctaSecondary')}
</Button>
<div className="relative w-full h-full min-h-[500px] flex items-center justify-center">
{/* Radial glow — warm atmospheric backdrop */}
<div
className="absolute rounded-full"
style={{
width: 500,
height: 500,
top: '50%',
left: '45%',
transform: 'translate(-50%, -50%)',
background: 'radial-gradient(circle, rgba(91,164,217,0.07) 0%, rgba(91,164,217,0.02) 40%, transparent 70%)',
}}
/>
{/* Outer ring — large, slow counter-rotation */}
<motion.div
className="absolute rounded-full"
style={{
width: 380,
height: 380,
top: '50%',
left: '45%',
marginTop: -190,
marginLeft: -190,
border: '1.5px solid rgba(91,164,217,0.12)',
}}
animate={{ rotate: [0, -360] }}
transition={{ duration: 60, ease: 'linear', repeat: Infinity }}
>
{/* Tick marks on the outer ring — fixed positions */}
{[0, 45, 90, 135, 180, 225, 270, 315].map((deg) => (
<div
key={deg}
className="absolute"
style={{
width: 1,
height: 10,
background: 'rgba(91,164,217,0.2)',
top: -5,
left: '50%',
marginLeft: -0.5,
transformOrigin: `50% ${190 + 5}px`,
transform: `rotate(${deg}deg)`,
}}
/>
))}
</motion.div>
{/* Inner ring — dashed, rotating forward */}
<motion.div
className="absolute rounded-full"
style={{
width: 240,
height: 240,
top: '50%',
left: '45%',
marginTop: -120,
marginLeft: -120,
border: '1px dashed rgba(0,100,148,0.15)',
}}
animate={{ rotate: [0, 360] }}
transition={{ duration: 40, ease: 'linear', repeat: Infinity }}
/>
{/* Core ring — solid, slowly rotating */}
<motion.div
className="absolute rounded-full"
style={{
width: 140,
height: 140,
top: '50%',
left: '45%',
marginTop: -70,
marginLeft: -70,
border: '2px solid rgba(91,164,217,0.18)',
}}
{...slowRotate}
/>
{/* Center crosshair */}
<div className="absolute" style={{ top: '50%', left: '45%', transform: 'translate(-50%, -50%)' }}>
<div style={{ width: 24, height: 1, background: 'rgba(91,164,217,0.25)', position: 'absolute', top: 0, left: -12 }} />
<div style={{ width: 1, height: 24, background: 'rgba(91,164,217,0.25)', position: 'absolute', top: -12, left: 0 }} />
<div style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(91,164,217,0.2)', position: 'absolute', top: -3, left: -3 }} />
</div>
{/* Floating accent dot — orbiting slowly */}
<motion.div
className="absolute rounded-full"
style={{
width: 8,
height: 8,
background: 'rgba(91,164,217,0.3)',
top: '50%',
left: '45%',
marginTop: -4,
marginLeft: -4,
}}
animate={{
x: [0, 120, 170, 120, 0, -120, -170, -120, 0],
y: [170, 120, 0, -120, -170, -120, 0, 120, 170],
}}
transition={{ duration: 20, ease: 'linear', repeat: Infinity }}
/>
{/* Second accent dot — opposite orbit, smaller */}
<motion.div
className="absolute rounded-full"
style={{
width: 5,
height: 5,
background: 'rgba(0,100,148,0.25)',
top: '50%',
left: '45%',
marginTop: -2.5,
marginLeft: -2.5,
}}
animate={{
x: [0, -90, -130, -90, 0, 90, 130, 90, 0],
y: [-130, -90, 0, 90, 130, 90, 0, -90, -130],
}}
transition={{ duration: 16, ease: 'linear', repeat: Infinity }}
/>
{/* Corner brackets — crisp architectural detail */}
<div className="absolute" style={{ top: '15%', right: '8%', width: 48, height: 48, borderTop: '1.5px solid rgba(28,43,58,0.2)', borderRight: '1.5px solid rgba(28,43,58,0.2)' }} />
<div className="absolute" style={{ bottom: '18%', left: '8%', width: 40, height: 40, borderBottom: '1.5px solid rgba(28,43,58,0.15)', borderLeft: '1.5px solid rgba(28,43,58,0.15)' }} />
{/* Dimension line — vertical, right side */}
<div className="absolute" style={{ right: '5%', top: '25%', bottom: '30%', width: 1, background: 'rgba(28,43,58,0.08)' }}>
<div style={{ position: 'absolute', top: 0, left: -4, width: 9, height: 1, background: 'rgba(28,43,58,0.12)' }} />
<div style={{ position: 'absolute', bottom: 0, left: -4, width: 9, height: 1, background: 'rgba(28,43,58,0.12)' }} />
</div>
</div>
</motion.div>
</div>
{/* Bottom fade-out to next section */}
{/* ─── Bottom fade into next section ────────────────────────────── */}
<div
className="absolute bottom-0 left-0 right-0 h-24 pointer-events-none z-10"
className="absolute bottom-0 left-0 right-0 h-28 pointer-events-none z-10"
style={{
background:
'linear-gradient(to bottom, transparent, rgba(248,249,250,0.6))',
'linear-gradient(to bottom, transparent, rgba(248,249,250,0.65))',
}}
aria-hidden="true"
/>