mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +00:00
496 lines
26 KiB
TypeScript
496 lines
26 KiB
TypeScript
"use client"
|
||
|
||
import {
|
||
motion,
|
||
useAnimationFrame,
|
||
useInView,
|
||
useMotionTemplate,
|
||
useMotionValue,
|
||
useReducedMotionConfig,
|
||
useScroll,
|
||
useSpring,
|
||
useTransform,
|
||
type MotionValue,
|
||
} from "motion/react"
|
||
import { useEffect, useRef } from "react"
|
||
import { cn } from "@/lib/utils"
|
||
import { splitList } from "../shared/hooks"
|
||
import { TImage } from "../shared/media"
|
||
import { ease, wordmarkSize } from "../shared/motion"
|
||
import type { HeroAuroraData, HeroEditorialData, HeroKinetikData, HeroKinoData, HeroVollbildData } from "./definitions"
|
||
import type { BlockProps } from "./types"
|
||
import { Action, container, easeInOut, Eyebrow, Heading, LineLink, linkProps, Magnetic, RevealImage } from "./ui"
|
||
|
||
// Hervorhebung auf Bildern: Akzent mit Weiß gemischt, damit sie auch bei dunklen Akzenten lesbar bleibt
|
||
const emOnImage = "italic text-[color-mix(in_oklab,var(--t-accent)_55%,white)]"
|
||
|
||
// ── Kino-Fenster ─────────────────────────────────────────────────────────────
|
||
|
||
// Ein Bildfenster unter der Überschrift öffnet sich beim Scrollen zum Vollbild (Sektion bleibt stehen)
|
||
export function HeroKino({ data }: BlockProps<HeroKinoData>) {
|
||
const ref = useRef<HTMLElement>(null)
|
||
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] })
|
||
const progress = useSpring(scrollYProgress, { stiffness: 140, damping: 30, mass: 0.35 })
|
||
const inset = useTransform(progress, [0, 0.72], [1, 0])
|
||
// Abstände des Fensters als CSS-Variablen (mobil/desktop), skaliert mit dem Fortschritt
|
||
const clipPath = useMotionTemplate`inset(calc(var(--it) * ${inset}) calc(var(--ix) * ${inset}) calc(var(--ib) * ${inset}) calc(var(--ix) * ${inset}) round calc(1.75rem * ${inset}))`
|
||
const imageScale = useTransform(progress, [0, 0.72], [1.3, 1])
|
||
// Kameraschwenk: das kleine Fenster zeigt zuerst den oberen Bildteil, beim Öffnen gleitet das Bild zur Mitte
|
||
const imageY = useTransform(progress, [0, 0.72], ["24%", "0%"])
|
||
const titleY = useTransform(progress, [0, 0.4], ["0%", "-40%"])
|
||
const titleOpacity = useTransform(progress, [0.04, 0.36], [1, 0])
|
||
const shade = useTransform(progress, [0.45, 0.8], [0, 1])
|
||
const outroOpacity = useTransform(progress, [0.7, 0.88], [0, 1])
|
||
const outroY = useTransform(progress, [0.7, 0.88], [48, 0])
|
||
const outroEvents = useTransform(progress, (value) => (value > 0.75 ? "auto" : "none"))
|
||
const cue = useTransform(progress, [0, 0.06], [1, 0])
|
||
|
||
return (
|
||
<section ref={ref} className="relative h-[250vh]">
|
||
<div className="sticky top-0 h-svh overflow-hidden">
|
||
<motion.div
|
||
data-surface="dark"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ duration: 1.2, delay: 0.3 }}
|
||
style={{ clipPath }}
|
||
className="absolute inset-0 bg-black [--ib:7%] [--it:47%] [--ix:5%] md:[--ib:6%] md:[--it:45%] md:[--ix:27%]"
|
||
>
|
||
<motion.div style={{ y: imageY, scale: imageScale }} className="absolute inset-0">
|
||
<TImage src={data.image} eager sizes="100vw" />
|
||
</motion.div>
|
||
<motion.div style={{ opacity: shade }} className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-black/10" />
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
style={{ y: titleY, opacity: titleOpacity }}
|
||
className={cn(container, "relative flex h-[45%] flex-col items-center justify-end pb-7 text-center md:pb-9")}
|
||
>
|
||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.2 }}>
|
||
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
|
||
</motion.div>
|
||
<Heading as="h1" onMount delay={0.25} text={data.title} className="mt-5 text-[clamp(3rem,8.4vw,9.25rem)] leading-[0.88] tracking-[-0.04em]" />
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
style={{ opacity: outroOpacity, y: outroY, pointerEvents: outroEvents }}
|
||
className={cn(container, "absolute inset-x-0 bottom-0 flex flex-col gap-8 pb-12 text-white md:flex-row md:items-end md:justify-between md:pb-16")}
|
||
>
|
||
<p className="max-w-2xl font-t-display text-[clamp(1.6rem,3vw,2.75rem)] leading-[1.12] tracking-[-0.02em]">{data.text}</p>
|
||
<Action href={data.href} variant="light" className="self-start md:self-auto">
|
||
{data.cta}
|
||
</Action>
|
||
</motion.div>
|
||
|
||
<motion.div style={{ opacity: cue }} aria-hidden className="absolute right-5 bottom-[calc(7%+1.25rem)] flex items-center gap-3 text-[10px] tracking-[0.3em] text-white/80 uppercase md:right-[calc(27%+1.5rem)] md:bottom-[calc(6%+1.5rem)]">
|
||
Scrollen
|
||
<span className="relative h-8 w-px overflow-hidden bg-white/25">
|
||
<motion.span
|
||
className="absolute inset-x-0 top-0 h-1/2 bg-white"
|
||
animate={{ y: ["-100%", "200%"] }}
|
||
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
|
||
/>
|
||
</span>
|
||
</motion.div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ── Vollbild ─────────────────────────────────────────────────────────────────
|
||
|
||
export function HeroVollbild({ data }: BlockProps<HeroVollbildData>) {
|
||
const ref = useRef<HTMLElement>(null)
|
||
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
|
||
const y = useTransform(scrollYProgress, [0, 1], ["0%", "18%"])
|
||
const fade = useTransform(scrollYProgress, [0, 0.55], [1, 0])
|
||
|
||
return (
|
||
<section ref={ref} data-surface="dark" className="relative h-svh min-h-[600px] overflow-hidden bg-black text-white">
|
||
<motion.div style={{ y }} className="absolute inset-0">
|
||
<motion.div initial={{ scale: 1.18 }} animate={{ scale: 1 }} transition={{ duration: 2.6, ease: [0.16, 1, 0.3, 1] }} className="absolute inset-0">
|
||
<TImage src={data.image} eager sizes="100vw" />
|
||
</motion.div>
|
||
</motion.div>
|
||
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-black/30" />
|
||
<motion.div style={{ opacity: fade }} className={cn(container, "relative flex h-full flex-col justify-end pb-10 md:pb-14")}>
|
||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.9, ease, delay: 0.3 }}>
|
||
<Eyebrow className="text-white/75">{data.eyebrow}</Eyebrow>
|
||
</motion.div>
|
||
<Heading
|
||
as="h1"
|
||
onMount
|
||
delay={0.4}
|
||
text={data.title}
|
||
emClassName={emOnImage}
|
||
className="mt-6 text-[clamp(3.25rem,8.8vw,10rem)] leading-[0.9] tracking-[-0.04em]"
|
||
/>
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 1, ease, delay: 0.9 }}
|
||
className="mt-10 flex flex-col gap-8 border-t border-white/20 pt-8 md:flex-row md:items-end md:justify-between"
|
||
>
|
||
<p className="max-w-md text-lg leading-relaxed text-white/80">{data.text}</p>
|
||
<div className="flex flex-wrap gap-3">
|
||
<Action href={data.href}>{data.cta}</Action>
|
||
<Action href={data.hrefSecondary} variant="light">
|
||
{data.ctaSecondary}
|
||
</Action>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ── Aurora ───────────────────────────────────────────────────────────────────
|
||
|
||
const auroraBlobs = [
|
||
{ style: { top: "-25%", left: "-10%", width: "60vw", height: "60vw", background: "radial-gradient(closest-side, var(--t-accent), transparent)" }, x: ["0%", "14%"], y: ["0%", "8%"], scale: [1, 1.15], duration: 18 },
|
||
{ style: { top: "-15%", right: "-15%", width: "55vw", height: "55vw", background: "radial-gradient(closest-side, color-mix(in oklab, var(--t-accent) 50%, #22d3ee), transparent)" }, x: ["0%", "-12%"], y: ["0%", "14%"], scale: [1.1, 0.95], duration: 22 },
|
||
{ style: { bottom: "-35%", left: "18%", width: "65vw", height: "65vw", background: "radial-gradient(closest-side, color-mix(in oklab, var(--t-accent) 45%, #f472b6), transparent)" }, x: ["0%", "-8%"], y: ["0%", "-12%"], scale: [0.95, 1.12], duration: 26 },
|
||
]
|
||
|
||
// Langsam ziehende Farbwolken in Akzent-Tönen über einem feinen Raster; auch für andere Bausteine nutzbar
|
||
export function AuroraGlow({ className }: { className?: string }) {
|
||
return (
|
||
<div aria-hidden className={cn("pointer-events-none absolute inset-0 overflow-hidden", className)}>
|
||
{auroraBlobs.map((blob, i) => (
|
||
<motion.div
|
||
key={i}
|
||
className="absolute rounded-full opacity-45"
|
||
style={blob.style}
|
||
animate={{ x: blob.x, y: blob.y, scale: blob.scale }}
|
||
transition={{ duration: blob.duration, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
|
||
/>
|
||
))}
|
||
<div className="absolute inset-0 [background-image:linear-gradient(to_right,color-mix(in_oklab,var(--t-fg)_7%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--t-fg)_7%,transparent)_1px,transparent_1px)] [background-size:64px_64px] [mask-image:radial-gradient(ellipse_at_50%_35%,black_15%,transparent_70%)]" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function HeroAurora({ data }: BlockProps<HeroAuroraData>) {
|
||
const keywords = splitList(data.keywords).slice(0, 8)
|
||
|
||
return (
|
||
<section className="relative overflow-hidden pt-32 pb-12 md:pt-44 md:pb-20">
|
||
<AuroraGlow />
|
||
<div className={cn(container, "relative flex flex-col items-center text-center")}>
|
||
{data.badge.trim() && (
|
||
<motion.p
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.8, ease, delay: 0.1 }}
|
||
className="relative inline-flex items-center gap-2.5 overflow-hidden rounded-full border border-t-fg/15 bg-t-bg/40 px-4 py-1.5 text-xs font-medium backdrop-blur-md"
|
||
>
|
||
<span aria-hidden className="relative flex size-2">
|
||
<span className="absolute inset-0 animate-ping rounded-full bg-t-accent opacity-60" />
|
||
<span className="relative size-2 rounded-full bg-t-accent" />
|
||
</span>
|
||
{data.badge}
|
||
<motion.span
|
||
aria-hidden
|
||
className="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-t-fg/15 to-transparent"
|
||
initial={{ x: "-120%" }}
|
||
animate={{ x: "320%" }}
|
||
transition={{ duration: 2.4, repeat: Infinity, repeatDelay: 2.5, ease: "easeInOut" }}
|
||
/>
|
||
</motion.p>
|
||
)}
|
||
<Heading
|
||
as="h1"
|
||
onMount
|
||
delay={0.2}
|
||
text={data.title}
|
||
emClassName="bg-gradient-to-r from-t-accent via-[color-mix(in_oklab,var(--t-accent)_50%,#22d3ee)] to-[color-mix(in_oklab,var(--t-accent)_45%,#f472b6)] bg-clip-text text-transparent [-webkit-background-clip:text]"
|
||
className="mt-7 max-w-5xl text-[clamp(2.9rem,7vw,7.25rem)] leading-[0.98] tracking-[-0.045em]"
|
||
/>
|
||
<motion.p
|
||
initial={{ opacity: 0, y: 16 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.9, ease, delay: 0.6 }}
|
||
className="mt-7 max-w-xl text-lg leading-relaxed text-t-muted md:text-xl"
|
||
>
|
||
{data.text}
|
||
</motion.p>
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 16 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.9, ease, delay: 0.75 }}
|
||
className="mt-9 flex flex-wrap justify-center gap-3"
|
||
>
|
||
<Magnetic>
|
||
<Action href={data.href}>{data.cta}</Action>
|
||
</Magnetic>
|
||
<Action href={data.hrefSecondary} variant="outline" className="bg-t-bg/30 backdrop-blur-md">
|
||
{data.ctaSecondary}
|
||
</Action>
|
||
</motion.div>
|
||
{keywords.length > 0 && <Orbit words={keywords} />}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// Lichtkugel mit Schlagworten auf einer Ellipsen-Bahn; hinten kleiner und blasser, vorne vor der Kugel
|
||
function Orbit({ words }: { words: string[] }) {
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
const angle = useMotionValue(0)
|
||
const width = useMotionValue(0)
|
||
const height = useMotionValue(0)
|
||
const reduce = useReducedMotionConfig()
|
||
const visible = useInView(ref)
|
||
|
||
useEffect(() => {
|
||
const element = ref.current
|
||
if (!element) return
|
||
const observer = new ResizeObserver(([entry]) => {
|
||
width.set(entry.contentRect.width)
|
||
height.set(entry.contentRect.height)
|
||
})
|
||
observer.observe(element)
|
||
return () => observer.disconnect()
|
||
}, [width, height])
|
||
|
||
useAnimationFrame((_, delta) => {
|
||
if (reduce || !visible) return
|
||
angle.set(angle.get() + delta * 0.008)
|
||
})
|
||
|
||
return (
|
||
<motion.div
|
||
ref={ref}
|
||
initial={{ opacity: 0, scale: 0.94 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
transition={{ duration: 1.4, ease, delay: 0.5 }}
|
||
className="relative mt-12 aspect-[4/3] w-full max-w-5xl sm:aspect-[2/1] md:mt-16"
|
||
>
|
||
<div aria-hidden className="absolute inset-x-[3%] inset-y-[12%] rounded-[50%] border border-t-fg/10" />
|
||
<div aria-hidden className="absolute inset-x-[16%] inset-y-[4%] rounded-[50%] border border-dashed border-t-fg/[0.07]" />
|
||
<div aria-hidden className="absolute top-1/2 left-1/2 z-[2] aspect-square w-[34%] -translate-x-1/2 -translate-y-1/2 sm:w-[28%]">
|
||
<div className="absolute inset-[-40%] rounded-full opacity-60 blur-2xl" style={{ background: "radial-gradient(closest-side, var(--t-accent), transparent)" }} />
|
||
<motion.div
|
||
className="absolute inset-0 rounded-full"
|
||
style={{
|
||
background: "radial-gradient(circle at 32% 28%, color-mix(in oklab, white 80%, var(--t-accent)) 0%, var(--t-accent) 38%, color-mix(in oklab, var(--t-accent) 35%, black) 80%)",
|
||
boxShadow: "inset -18px -24px 60px rgba(0,0,0,0.35), 0 30px 90px -20px var(--t-accent)",
|
||
}}
|
||
animate={{ y: [0, -10] }}
|
||
transition={{ duration: 3.5, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
|
||
/>
|
||
</div>
|
||
{words.map((word, i) => (
|
||
<OrbitChip key={`${word}-${i}`} word={word} angle={angle} width={width} height={height} offset={(360 / words.length) * i} />
|
||
))}
|
||
</motion.div>
|
||
)
|
||
}
|
||
|
||
function OrbitChip({ word, angle, width, height, offset }: {
|
||
word: string
|
||
angle: MotionValue<number>
|
||
width: MotionValue<number>
|
||
height: MotionValue<number>
|
||
offset: number
|
||
}) {
|
||
const theta = useTransform(angle, (value) => ((value + offset) * Math.PI) / 180)
|
||
const x = useTransform(() => Math.cos(theta.get()) * width.get() * 0.45)
|
||
const y = useTransform(() => Math.sin(theta.get()) * height.get() * 0.38)
|
||
// 0 = hinten (oben auf der Bahn), 1 = vorne (unten). Gerundet, weil der Server Stilwerte mit weniger
|
||
// Nachkommastellen ausgibt – sonst meldet React beim Hydrieren abweichende Attribute.
|
||
const depth = useTransform(theta, (value) => (Math.sin(value) + 1) / 2)
|
||
const scale = useTransform(depth, (value) => Math.round((0.78 + value * 0.28) * 1000) / 1000)
|
||
const opacity = useTransform(depth, (value) => Math.round((0.45 + value * 0.55) * 1000) / 1000)
|
||
const zIndex = useTransform(depth, (value) => (value > 0.5 ? 3 : 1))
|
||
|
||
return (
|
||
<motion.span
|
||
style={{ x, y, scale, opacity, zIndex }}
|
||
className="absolute top-1/2 left-1/2 inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-t-fg/15 bg-t-surface/85 px-3.5 py-1.5 text-xs font-medium whitespace-nowrap shadow-[0_10px_30px_-14px_rgba(0,0,0,0.5)] sm:px-4 sm:py-2 sm:text-sm"
|
||
>
|
||
<span aria-hidden className="size-1.5 rounded-full bg-t-accent" />
|
||
{word}
|
||
</motion.span>
|
||
)
|
||
}
|
||
|
||
// ── Editorial ────────────────────────────────────────────────────────────────
|
||
|
||
export function HeroEditorial({ data, brand }: BlockProps<HeroEditorialData>) {
|
||
const ref = useRef<HTMLElement>(null)
|
||
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
|
||
const imageY = useTransform(scrollYProgress, [0, 1], ["0%", "10%"])
|
||
|
||
return (
|
||
<section ref={ref} className={cn(container, "relative grid gap-10 pt-24 pb-16 md:grid-cols-12 md:gap-6 md:pt-32 md:pb-24")}>
|
||
{brand.tagline.trim() && (
|
||
<p aria-hidden className="absolute top-32 left-0 hidden rotate-180 text-[10px] tracking-[0.3em] text-t-muted uppercase [writing-mode:vertical-rl] xl:block">
|
||
{brand.tagline}
|
||
</p>
|
||
)}
|
||
<div className="flex flex-col md:col-span-7 md:pr-6">
|
||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.1 }}>
|
||
<Eyebrow>{data.eyebrow}</Eyebrow>
|
||
</motion.div>
|
||
<Heading as="h1" onMount delay={0.15} text={data.title} className="mt-8 text-[clamp(3.25rem,7.8vw,8.75rem)] leading-[0.9] tracking-[-0.035em]" />
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 16 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 1, ease, delay: 0.8 }}
|
||
className="mt-auto grid gap-6 pt-12 sm:grid-cols-[1fr_auto] sm:items-end"
|
||
>
|
||
<p className="max-w-sm text-lg leading-relaxed text-t-muted">{data.text}</p>
|
||
<LineLink href={data.href}>{data.cta}</LineLink>
|
||
</motion.div>
|
||
</div>
|
||
<figure className="md:col-span-5">
|
||
<div className="relative aspect-[4/5] overflow-hidden bg-t-surface">
|
||
<motion.div style={{ y: imageY }} className="absolute inset-x-0 -top-[6%] -bottom-[6%]">
|
||
<motion.div initial={{ scale: 1.25 }} animate={{ scale: 1 }} transition={{ duration: 2, ease }} className="absolute inset-0">
|
||
<TImage src={data.image} alt={data.caption} eager sizes="(min-width: 768px) 42vw, 100vw" />
|
||
</motion.div>
|
||
</motion.div>
|
||
{/* Jalousien: fünf Streifen in Hintergrundfarbe ziehen nacheinander nach oben weg */}
|
||
{[0, 1, 2, 3, 4].map((i) => (
|
||
<motion.span
|
||
key={i}
|
||
aria-hidden
|
||
className="absolute inset-y-0 origin-top bg-t-bg"
|
||
style={{ left: `${i * 20}%`, width: "20.5%" }}
|
||
initial={{ scaleY: 1 }}
|
||
animate={{ scaleY: 0 }}
|
||
transition={{ duration: 1.1, ease: easeInOut, delay: 0.3 + i * 0.09 }}
|
||
/>
|
||
))}
|
||
</div>
|
||
{data.caption.trim() && (
|
||
<figcaption className="mt-3 flex justify-between gap-4 text-xs text-t-muted">
|
||
<span>{data.caption}</span>
|
||
<span>Nº 01</span>
|
||
</figcaption>
|
||
)}
|
||
</figure>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ── Kinetik ──────────────────────────────────────────────────────────────────
|
||
|
||
const REST = `"wght" 250, "wdth" 75`
|
||
|
||
// Buchstaben werden zur Maus hin fetter und breiter (variable Schrift mit wght/wdth-Achse, z. B. Mona Sans).
|
||
// Ohne Maus wandert ein gedachter Punkt langsam über das Wort.
|
||
function PressureWord({ text, className }: { text: string; className?: string }) {
|
||
const ref = useRef<HTMLHeadingElement>(null)
|
||
const letters = useRef<(HTMLSpanElement | null)[]>([])
|
||
const pointer = useRef({ x: -1e4, y: -1e4, targetX: -1e4, targetY: -1e4, active: false })
|
||
const reduce = useReducedMotionConfig()
|
||
const visible = useInView(ref)
|
||
|
||
useEffect(() => {
|
||
const move = (event: PointerEvent) => {
|
||
if (event.pointerType !== "mouse") return
|
||
Object.assign(pointer.current, { targetX: event.clientX, targetY: event.clientY, active: true })
|
||
}
|
||
const leave = () => {
|
||
pointer.current.active = false
|
||
}
|
||
window.addEventListener("pointermove", move)
|
||
document.documentElement.addEventListener("pointerleave", leave)
|
||
return () => {
|
||
window.removeEventListener("pointermove", move)
|
||
document.documentElement.removeEventListener("pointerleave", leave)
|
||
}
|
||
}, [])
|
||
|
||
useAnimationFrame((time) => {
|
||
const element = ref.current
|
||
if (!element || !visible) return
|
||
const rect = element.getBoundingClientRect()
|
||
const p = pointer.current
|
||
if (!p.active) {
|
||
p.targetX = reduce ? -1e4 : rect.left + rect.width * (0.5 + 0.48 * Math.sin(time / 2200))
|
||
p.targetY = rect.top + rect.height / 2
|
||
}
|
||
p.x += (p.targetX - p.x) * 0.12
|
||
p.y += (p.targetY - p.y) * 0.12
|
||
|
||
const reach = Math.max(rect.width / 2.6, 180)
|
||
const boxes = letters.current.map((letter) => letter?.getBoundingClientRect())
|
||
boxes.forEach((box, i) => {
|
||
const letter = letters.current[i]
|
||
if (!box || !letter) return
|
||
const distance = Math.hypot(p.x - (box.left + box.width / 2), p.y - (box.top + box.height / 2))
|
||
const t = Math.max(0, 1 - distance / reach)
|
||
const eased = t * t * (3 - 2 * t)
|
||
letter.style.fontVariationSettings = `"wght" ${Math.round(250 + eased * 650)}, "wdth" ${Math.round(75 + eased * 50)}`
|
||
})
|
||
})
|
||
|
||
return (
|
||
<h1
|
||
ref={ref}
|
||
aria-label={text}
|
||
className={cn("text-center font-t-display leading-[0.82] tracking-[-0.04em] whitespace-nowrap select-none", className)}
|
||
style={{ fontSize: wordmarkSize(text, 24) }}
|
||
>
|
||
{Array.from(text).map((char, i) => (
|
||
<span
|
||
key={i}
|
||
aria-hidden
|
||
ref={(node) => {
|
||
letters.current[i] = node
|
||
}}
|
||
style={{ fontVariationSettings: REST }}
|
||
>
|
||
{char === " " ? " " : char}
|
||
</span>
|
||
))}
|
||
</h1>
|
||
)
|
||
}
|
||
|
||
export function HeroKinetik({ data, brand }: BlockProps<HeroKinetikData>) {
|
||
const word = data.word.trim() || brand.name
|
||
|
||
return (
|
||
<section className="relative overflow-hidden pt-24 md:pt-28">
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ duration: 1, delay: 0.2 }}
|
||
className={cn(container, "flex items-center justify-between gap-6 text-[11px] tracking-[0.25em] text-t-muted uppercase")}
|
||
>
|
||
<span>{data.eyebrow}</span>
|
||
<span className="hidden sm:inline">Scrollen ↓</span>
|
||
</motion.div>
|
||
<motion.div initial={{ opacity: 0, y: 40 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1.2, ease, delay: 0.1 }}>
|
||
<PressureWord text={word} className="mt-4 md:mt-2" />
|
||
</motion.div>
|
||
<div className={cn(container, "mt-6 grid gap-8 border-t border-t-fg/15 py-10 md:mt-8 md:grid-cols-12 md:gap-6 md:py-12")}>
|
||
<motion.p
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 1, ease, delay: 0.5 }}
|
||
className="font-t-display text-[clamp(1.4rem,2.4vw,2.25rem)] leading-[1.15] tracking-[-0.02em] md:col-span-6"
|
||
>
|
||
{data.text}
|
||
</motion.p>
|
||
{data.cta.trim() && (
|
||
<div className="flex items-end md:col-span-2 md:col-start-8">
|
||
<Magnetic strength={0.4}>
|
||
<a
|
||
{...linkProps(data.href)}
|
||
className="grid size-28 place-items-center rounded-full bg-t-accent p-4 text-center text-sm leading-tight font-medium text-t-accent-fg transition-[scale] duration-200 active:scale-95 md:size-32"
|
||
>
|
||
{data.cta} ↗
|
||
</a>
|
||
</Magnetic>
|
||
</div>
|
||
)}
|
||
<RevealImage src={data.image} sizes="(min-width: 768px) 33vw, 100vw" className="aspect-[4/3] rounded-[1rem] md:col-span-4 md:col-start-9" />
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|