mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 19:51:16 +00:00
127 lines
4 KiB
TypeScript
127 lines
4 KiB
TypeScript
"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>
|
||
)
|
||
}
|