Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 126 additions & 126 deletions components/floating-title.tsx
Original file line number Diff line number Diff line change
@@ -1,126 +1,126 @@
"use client"

import { useRef, useEffect, useState } from "react"
import { useScrollContext } from "@/lib/scroll-context"
import { useTranslations, getTranslation } from "@/lib/i18n-context"

/**
* Fixed-position element that interpolates between the hero h1 and the navbar
* brand text during scroll, creating a continuous "dock into navbar" effect.
*
* Uses translate3d + scale for GPU-composited animation.
* Purely decorative — the real heading and nav link remain in the DOM.
*
* Opacity-gated: the clone mounts with opacity 0, applies exact computed styles
* from the hero h1 (read via getComputedStyle), and only becomes visible after
* the first rAF tick to guarantee no unstyled flash frame.
*/
export function FloatingTitle() {
const {
scrollY,
scrollProgress,
heroTitleRect,
navTitleRect,
heroComputedStyles,
navFontSize,
isFloatingTitleActive,
} = useScrollContext()
const [translations] = useTranslations()
const [revealed, setRevealed] = useState(false)
const revealRafRef = useRef(0)

// Determine if the clone should be in the DOM at all.
// Uses isFloatingTitleActive from context which includes: !prefersReducedMotion,
// fontsReady, heroWasVisible, 0 < scrollProgress < 1
const shouldMount =
isFloatingTitleActive &&
heroTitleRect != null &&
navTitleRect != null &&
heroComputedStyles != null &&
navFontSize != null

// Opacity-gated reveal: mount with opacity 0, then reveal after one rAF
// so the browser has time to apply computed styles before painting.
useEffect(() => {
if (shouldMount && !revealed) {
revealRafRef.current = requestAnimationFrame(() => {
setRevealed(true)
})
}
if (!shouldMount && revealed) {
setRevealed(false)
}
return () => {
if (revealRafRef.current) cancelAnimationFrame(revealRafRef.current)
}
}, [shouldMount, revealed])

if (!shouldMount || !heroTitleRect || !navTitleRect || !heroComputedStyles || !navFontSize) {
return null
}

const t = easeInOutCubic(scrollProgress)

// Hero rect is in document coords — convert to viewport
const heroViewportTop = heroTitleRect.top - scrollY
const heroViewportLeft = heroTitleRect.left

// Nav rect is already in viewport coords (fixed navbar)
const navViewportTop = navTitleRect.top
const navViewportLeft = navTitleRect.left

// Position the element at the hero's initial viewport position,
// then translate toward the nav position
const deltaX = (navViewportLeft - heroViewportLeft) * t
const deltaY = (navViewportTop - heroViewportTop) * t

// Scale based on font-size ratio (not element height) so line-wrapping
// on mobile doesn't cause the title to shrink more than it should.
const heroFontPx = parseFloat(heroComputedStyles.fontSize)
const navFontPx = parseFloat(navFontSize)
const targetScale = navFontPx / heroFontPx
const scale = lerp(1, targetScale, t)

const name = getTranslation(translations, "hero.name", "Alejandro Repetto")

return (
<div
className="fixed z-[60] pointer-events-none"
style={{
top: heroViewportTop,
left: heroViewportLeft,
width: heroTitleRect.width,
height: heroTitleRect.height,
transformOrigin: "top left",
transform: `translate3d(${deltaX}px, ${deltaY}px, 0) scale(${scale})`,
willChange: revealed ? "transform, opacity" : undefined,
opacity: revealed ? 1 : 0,
}}
aria-hidden="true"
>
<span
className="whitespace-nowrap block"
style={{
// Use exact computed styles from the hero h1 for pixel-perfect match
fontFamily: heroComputedStyles.fontFamily,
fontWeight: heroComputedStyles.fontWeight,
fontSize: heroComputedStyles.fontSize,
lineHeight: heroComputedStyles.lineHeight,
letterSpacing: heroComputedStyles.letterSpacing,
color: heroComputedStyles.color,
WebkitFontSmoothing: heroComputedStyles.webkitFontSmoothing,
}}
>
{name}
</span>
</div>
)
}

function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}

function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
}
"use client"
import { useRef, useEffect, useState } from "react"
import { useScrollContext } from "@/lib/scroll-context"
import { useTranslations, getTranslation } from "@/lib/i18n-context"
/**
* Fixed-position element that interpolates between the hero h1 and the navbar
* brand text during scroll, creating a continuous "dock into navbar" effect.
*
* Uses translate3d + scale for GPU-composited animation.
* Purely decorative — the real heading and nav link remain in the DOM.
*
* Opacity-gated: the clone mounts with opacity 0, applies exact computed styles
* from the hero h1 (read via getComputedStyle), and only becomes visible after
* the first rAF tick to guarantee no unstyled flash frame.
*/
export function FloatingTitle() {
const {
scrollY,
scrollProgress,
heroTitleRect,
navTitleRect,
heroComputedStyles,
navFontSize,
isFloatingTitleActive,
} = useScrollContext()
const [translations] = useTranslations()
const [revealed, setRevealed] = useState(false)
const revealRafRef = useRef(0)
// Determine if the clone should be in the DOM at all.
// Uses isFloatingTitleActive from context which includes: !prefersReducedMotion,
// fontsReady, heroWasVisible, 0 < scrollProgress < 1
const shouldMount =
isFloatingTitleActive &&
heroTitleRect != null &&
navTitleRect != null &&
heroComputedStyles != null &&
navFontSize != null
// Opacity-gated reveal: mount with opacity 0, then reveal after one rAF
// so the browser has time to apply computed styles before painting.
useEffect(() => {
if (shouldMount && !revealed) {
revealRafRef.current = requestAnimationFrame(() => {
setRevealed(true)
})
}
if (!shouldMount && revealed) {
setRevealed(false)
}
return () => {
if (revealRafRef.current) cancelAnimationFrame(revealRafRef.current)
}
}, [shouldMount, revealed])
if (!shouldMount || !heroTitleRect || !navTitleRect || !heroComputedStyles || !navFontSize) {
return null
}
const t = easeInOutCubic(scrollProgress)
// Hero rect is in document coords — convert to viewport
const heroViewportTop = heroTitleRect.top - scrollY
const heroViewportLeft = heroTitleRect.left
// Nav rect is already in viewport coords (fixed navbar)
const navViewportTop = navTitleRect.top
const navViewportLeft = navTitleRect.left
// Position the element at the hero's initial viewport position,
// then translate toward the nav position
const deltaX = (navViewportLeft - heroViewportLeft) * t
const deltaY = (navViewportTop - heroViewportTop) * t
// Scale based on font-size ratio (not element height) so line-wrapping
// on mobile doesn't cause the title to shrink more than it should.
const heroFontPx = parseFloat(heroComputedStyles.fontSize)
const navFontPx = parseFloat(navFontSize)
const targetScale = navFontPx / heroFontPx
const scale = lerp(1, targetScale, t)
const name = getTranslation(translations, "hero.name")
return (
<div
className="fixed z-[60] pointer-events-none"
style={{
top: heroViewportTop,
left: heroViewportLeft,
width: heroTitleRect.width,
height: heroTitleRect.height,
transformOrigin: "top left",
transform: `translate3d(${deltaX}px, ${deltaY}px, 0) scale(${scale})`,
willChange: revealed ? "transform, opacity" : undefined,
opacity: revealed ? 1 : 0,
}}
aria-hidden="true"
>
<span
className="whitespace-nowrap block"
style={{
// Use exact computed styles from the hero h1 for pixel-perfect match
fontFamily: heroComputedStyles.fontFamily,
fontWeight: heroComputedStyles.fontWeight,
fontSize: heroComputedStyles.fontSize,
lineHeight: heroComputedStyles.lineHeight,
letterSpacing: heroComputedStyles.letterSpacing,
color: heroComputedStyles.color,
WebkitFontSmoothing: heroComputedStyles.webkitFontSmoothing,
}}
>
{name}
</span>
</div>
)
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
}
Loading
Loading