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
3 changes: 2 additions & 1 deletion src/features/landing/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useTranslations, useLocale } from '@/i18n'
import { SOCIAL_LINKS, getCvUrl } from '@/lib/constants'
import { useEmailCopyFeedback } from '@/lib/hooks/useEmailCopyFeedback'
import { FEATURED_PROJECTS } from '@/content/featuredProjects'
import { SIDEBAR_BLEED_CLASS } from '@/features/morphNav/layout'
import { CURRENT_ROLE_ID } from '@/content/journey'
import { useEntryPhase } from '@/features/journey'
import { useFeaturedRotation } from './useFeaturedRotation'
Expand Down Expand Up @@ -67,7 +68,7 @@ export function Hero() {
}

return (
<SectionShell id="home" className="flex min-h-[calc(100svh-4rem)] items-center pt-24 lg:pt-28" tone="xr">
<SectionShell id="home" className={`flex min-h-[calc(100svh-4rem)] items-center pt-24 lg:pt-28 ${SIDEBAR_BLEED_CLASS}`} tone="xr">
<div className="grid grid-cols-1 items-center gap-8 sm:gap-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-10 xl:gap-14">
{/* CSS entrance (.hero-enter): the first screen is visible before hydration */}
<div className="hero-enter order-2 flex flex-col items-center [animation-delay:160ms] lg:order-1">
Expand Down
54 changes: 50 additions & 4 deletions src/features/morphNav/MorphNavProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@ import { useScrollProgress } from '@/features/morphNav/useScrollProgress'
import { SIDEBAR_MEDIA_QUERY } from '@/features/morphNav/layout'
import { NAV_ITEMS } from '@/lib/constants'

/** Scroll positions (px) where the header starts handing over to the sidebar and where it is done */
export interface MorphRange {
start: number
end: number
}

interface MorphNavContextValue {
/** Scroll progress from 0 (header) to 1 (sidebar) */
progress: number
/** Scroll range of the morph; the desktop sidebar fades in over the same range */
morphRange: MorphRange
/** Whether sidebar is fully visible */
isMorphed: boolean
/** Whether currently transitioning */
Expand Down Expand Up @@ -51,10 +59,47 @@ export function MorphNavProvider({
morphStart = 100,
morphEnd = 400,
}: MorphNavProviderProps) {
const { progress, isMorphed, isMorphing, prefersReducedMotion } = useScrollProgress({
start: morphStart,
end: morphEnd,
})
// From xl the hero spans the viewport (the sidebar is hidden while it is on screen), so the header only
// hands over to the sidebar once the hero is almost gone; otherwise the sidebar would cover its left column.
// Below xl the fixed props drive the header fade and the floating menu button.
const [desktopRange, setDesktopRange] = useState<MorphRange | null>(null)

useEffect(() => {
const mediaQuery = window.matchMedia(SIDEBAR_MEDIA_QUERY)
const hero = document.getElementById('home')

const measure = () => {
if (!mediaQuery.matches || !hero) {
setDesktopRange(null)
return
}

// Done when the hero's bottom edge reaches the 64px header; the crossfade covers the 30% of the
// viewport before that, by which point the hero's left column has scrolled out of view.
const heroBottom = hero.offsetTop + hero.offsetHeight
const end = Math.max(morphEnd, Math.round(heroBottom - 64))
const start = Math.max(morphStart, Math.round(end - Math.max(200, window.innerHeight * 0.3)))

setDesktopRange((current) => (
current?.start === start && current.end === end ? current : { start, end }
))
}

measure()
const resizeObserver = new ResizeObserver(measure)
if (hero) resizeObserver.observe(hero)
window.addEventListener('resize', measure)
mediaQuery.addEventListener('change', measure)

return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', measure)
mediaQuery.removeEventListener('change', measure)
}
}, [morphStart, morphEnd])

const morphRange: MorphRange = desktopRange ?? { start: morphStart, end: morphEnd }
const { progress, isMorphed, isMorphing, prefersReducedMotion } = useScrollProgress(morphRange)

const [activeSection, setActiveSection] = useState('home')
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
Expand Down Expand Up @@ -148,6 +193,7 @@ export function MorphNavProvider({

const contextValue: MorphNavContextValue = {
progress,
morphRange,
isMorphed,
isMorphing,
activeSection,
Expand Down
9 changes: 4 additions & 5 deletions src/features/morphNav/MorphSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@ import { cn } from '@/lib/utils'
* Below `xl` the same content lives in the drawer rendered by `MorphDrawer`.
*/
export function MorphSidebar() {
const { isMorphed, prefersReducedMotion } = useMorphNav()
const { isMorphed, prefersReducedMotion, morphRange } = useMorphNav()

// Use Framer Motion's useScroll for smooth scroll-based animations
const { scrollY, scrollYProgress } = useScroll()

// Transform scroll position to sidebar reveal values
// Sidebar starts appearing at 120px and is fully visible at 520px
const sidebarOpacity = useTransform(scrollY, [120, 520], [0, 1])
const sidebarX = useTransform(scrollY, [120, 520], [-24, 0])
// The sidebar fades in over the same range the header fades out (see MorphNavProvider)
const sidebarOpacity = useTransform(scrollY, [morphRange.start, morphRange.end], [0, 1])
const sidebarX = useTransform(scrollY, [morphRange.start, morphRange.end], [-24, 0])
const pageProgressScale = useTransform(scrollYProgress, [0, 1], [0.02, 1])

return (
Expand Down
1 change: 1 addition & 0 deletions src/features/morphNav/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { useScrollProgress } from './useScrollProgress'
export {
SIDEBAR_WIDTH_CLASS,
SIDEBAR_CONTENT_OFFSET_CLASS,
SIDEBAR_BLEED_CLASS,
SIDEBAR_RECENTER_CLASS,
SIDEBAR_MEDIA_QUERY,
} from './layout'
6 changes: 6 additions & 0 deletions src/features/morphNav/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export const SIDEBAR_WIDTH_CLASS = 'w-[17rem]'
/** Applied to the home content and footer at xl+ (17rem sidebar + 2rem gutter). */
export const SIDEBAR_CONTENT_OFFSET_CLASS = 'xl:ml-[17rem] xl:pl-8'

/**
* Lets the hero cancel that offset and span the viewport at xl: the sidebar is still hidden while the
* hero is on screen, so the hero centres under the header instead of leaving an empty 19rem band.
*/
export const SIDEBAR_BLEED_CLASS = 'xl:-ml-[19rem]'

/** Below xl the floating menu button (56px, bottom-left) would cover the end of the page; this clears it. */
export const FLOATING_MENU_CLEARANCE_CLASS = 'max-xl:pb-[calc(5rem+env(safe-area-inset-bottom))]'

Expand Down
Loading