since3-website/modules/landingPage/ui/components/hero/kinetic-headline.tsx
2026-09-25 00:45:33 +02:00

127 lines
4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
import { onIntroEnd } from "@/modules/shared/lib/intro"
// Resting state: heavy and condensed. Under the pointer: thin and wide.
const REST = { wght: 800, wdth: 75 }
const NEAR = { wght: 200, wdth: 125 }
const RADIUS = 280
const EASE = 0.14
/**
* Headline whose letters react to the pointer along the font's weight and
* width axes. Each letter also slides in after the preloader (CSS).
*/
export function KineticHeadline({
lines,
className,
}: {
lines: string[]
className?: string
}) {
const rootRef = useRef<HTMLHeadingElement>(null)
useEffect(() => {
const root = rootRef.current
if (!root) return
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (!fine || reduced) return
const chars = Array.from(root.querySelectorAll<HTMLSpanElement>("[data-char]"))
const current = chars.map(() => 0)
let pointerX = -9999
let pointerY = -9999
let frame = 0
let active = false
const tick = () => {
// Read all positions first, then write, to avoid layout thrashing
const rects = chars.map((char) => char.getBoundingClientRect())
let moving = false
rects.forEach((rect, index) => {
const dx = pointerX - (rect.left + rect.width / 2)
const dy = pointerY - (rect.top + rect.height / 2)
const distance = Math.hypot(dx, dy)
const raw = Math.max(0, 1 - distance / RADIUS)
const target = raw * raw * (3 - 2 * raw)
current[index] += (target - current[index]) * EASE
if (Math.abs(target - current[index]) > 0.002) moving = true
})
chars.forEach((char, index) => {
const t = current[index]
const wght = REST.wght + (NEAR.wght - REST.wght) * t
const wdth = REST.wdth + (NEAR.wdth - REST.wdth) * t
char.style.fontVariationSettings = `"wght" ${wght.toFixed(0)}, "wdth" ${wdth.toFixed(1)}`
})
frame = moving ? requestAnimationFrame(tick) : 0
}
const onMove = (event: PointerEvent) => {
if (!active) return
pointerX = event.clientX
pointerY = event.clientY
if (!frame) frame = requestAnimationFrame(tick)
}
const onLeave = () => {
pointerX = pointerY = -9999
if (!frame) frame = requestAnimationFrame(tick)
}
// Wait for the letters to finish sliding in
let timeout = 0
const cleanupIntro = onIntroEnd(() => {
timeout = window.setTimeout(() => (active = true), 1400)
})
window.addEventListener("pointermove", onMove, { passive: true })
document.documentElement.addEventListener("pointerleave", onLeave)
return () => {
cleanupIntro()
window.clearTimeout(timeout)
cancelAnimationFrame(frame)
window.removeEventListener("pointermove", onMove)
document.documentElement.removeEventListener("pointerleave", onLeave)
}
}, [])
let charIndex = 0
return (
<h1 ref={rootRef} className={cn("type-poster", className)}>
<span className="sr-only">{lines.join(" ")}</span>
{lines.map((line) => (
<span
key={line}
aria-hidden="true"
className="block overflow-clip pt-[0.04em] pb-[0.02em] whitespace-nowrap"
>
{line.split(" ").map((word, wordIndex) => (
<span key={wordIndex} className="inline-block">
{word.split("").map((char) => {
const index = charIndex++
return (
<span
key={index}
data-char
className="hero-char"
style={{ "--i": index } as React.CSSProperties}
>
{char}
</span>
)
})}
{wordIndex < line.split(" ").length - 1 ? " " : null}
</span>
))}
</span>
))}
</h1>
)
}