uiception
blockportfolio-v3source: ok · hit
Portfolio v3
Scroll-scrubbed lifeline timeline for a fullstack developer — Jon Doe filler career with fictional companies. Drop images and videos into public/ slots. New projects only (shadcn init --template next).
npx shadcn add "https://uiception.com/r/portfolio-v3.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
https://uiception.com/r/block-media.json
Files47 · source cached 10/14/2026, 12:30:18 PM
registry/new-york/templates/portfolio-v3/app/layout.tsx
import type { Metadata } from "next"
import { GeistSans } from "geist/font/sans"
import type { ReactNode } from "react"
import { ThemeProvider } from "../components/theme-provider"
import "./globals.css"
export const metadata: Metadata = {
title: "Jon Doe",
description:
"Fullstack developer — a scroll-scrubbed timeline of sites shipped and teams joined.",
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html
lang="en"
className={`h-full ${GeistSans.variable} antialiased`}
suppressHydrationWarning
>
<body
className={`${GeistSans.className} flex min-h-svh flex-col bg-background font-sans text-foreground`}
>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
registry/new-york/templates/portfolio-v3/app/page.tsx
import { ClaireCompanyIcons } from "../components/company-icons"
import { Avatar } from "../components/avatar"
import { Lifeline, LifelineLegend } from "../components/lifeline"
import {
LifelineFooter,
LifelineNav,
LifelineShell,
LifelineStage,
} from "../components/lifeline-shell"
import { ThemeSwitcher } from "../components/theme-switcher"
import { getJonDoeLifeline } from "../lib/media"
export default function HomePage() {
const life = getJonDoeLifeline()
return (
<LifelineShell className="lifeline-typeset">
<LifelineNav
logo={
<span className="flex items-center gap-3">
<Avatar />
<span className="text-sm font-semibold tracking-tight text-foreground">
{life.name}
</span>
</span>
}
logoLabel={life.name}
>
<ThemeSwitcher />
</LifelineNav>
<ClaireCompanyIcons />
<LifelineStage>
<Lifeline
markers={life.markers}
birthYear={life.birthYear}
title={life.name}
className="h-full"
/>
</LifelineStage>
<LifelineFooter containerClassName="justify-center">
<LifelineLegend items={life.legend} />
</LifelineFooter>
</LifelineShell>
)
}
registry/new-york/templates/portfolio-v3/components/avatar.tsx
import Image from "next/image"
import { resolveAvatarSrc } from "../lib/media"
export function Avatar({ className = "size-10" }: { className?: string }) {
const src = resolveAvatarSrc()
return (
<Image
src={src}
alt=""
width={40}
height={40}
unoptimized
priority
loading="eager"
className={`shrink-0 rounded-full object-cover ${className}`}
/>
)
}
registry/new-york/templates/portfolio-v3/components/company-icons.tsx
"use client"
import { registerCompanyIcons } from "./lifeline/company-icon"
import { CursorDark } from "./ui/svgs/cursorDark"
import { CursorLight } from "./ui/svgs/cursorLight"
import { NvidiaIconDark } from "./ui/svgs/nvidiaIconDark"
import { NvidiaIconLight } from "./ui/svgs/nvidiaIconLight"
import { Vercel } from "./ui/svgs/vercel"
import { VercelDark } from "./ui/svgs/vercelDark"
registerCompanyIcons({
vercel: { icon: Vercel, darkIcon: VercelDark },
cursor: { icon: CursorLight, darkIcon: CursorDark },
nvidia: { icon: NvidiaIconLight, darkIcon: NvidiaIconDark },
})
export function ClaireCompanyIcons() {
return null
}
registry/new-york/templates/portfolio-v3/components/lifeline-shell.tsx
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
const CONTAINER = "mx-auto flex w-full max-w-5xl items-center px-6"
export function LifelineShell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div
className={cn("flex h-dvh flex-col overflow-hidden bg-background text-foreground antialiased transition-colors duration-300",
className,
)}
>
{children}
</div>
)
}
export function LifelineNav({
logo,
logoHref = "/",
logoLabel = "Home",
children,
className,
containerClassName,
}: {
logo: ReactNode
logoHref?: string
logoLabel?: string
children?: ReactNode
className?: string
containerClassName?: string
}) {
return (
<nav
className={cn("fixed inset-x-0 top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl transition-colors duration-300",
className,
)}
>
<div
data-site-nav-inner
className={cn(CONTAINER, "h-16 justify-between", containerClassName)}
>
<a
href={logoHref}
data-site-nav-logo
aria-label={logoLabel}
className="text-foreground transition-[color,opacity] duration-300 hover:opacity-70"
>
{logo}
</a>
{children ? (
<div className="flex items-center gap-8">{children}</div>
) : null}
</div>
</nav>
)
}
export function LifelineStage({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<main
className={cn("flex-1 min-h-0 overflow-y-auto pt-16 md:overflow-hidden",
className,
)}
>
{children}
</main>
)
}
export function LifelineFooter({
children,
className,
containerClassName,
}: {
children?: ReactNode
className?: string
containerClassName?: string
}) {
return (
<footer
className={cn("shrink-0 border-t border-border bg-background/95 backdrop-blur-sm transition-colors duration-300",
className,
)}
>
<div
className={cn(
CONTAINER,
"h-16 justify-between gap-6",
containerClassName,
)}
>
{children}
</div>
</footer>
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/index.ts
export { Lifeline } from "./lifeline"
export { LifelineMarkerColumn } from "./lifeline-marker"
export { useLifelineScroll } from "./use-lifeline-scroll"
export { CompanyIcon } from "./company-icon"
export { LifelineLegend } from "./lifeline-legend"
export type { CompanyIconId } from "./company-icon"
export type {
LifelineCompany,
LifelineEvent,
LifelineEventCategory,
LifelineEventClip,
LifelineEventImage,
LifelineEventSegment,
LifelineGlobalEvent,
LifelineLegendItem,
LifelineMarker,
LifelineMode,
LifelineProps,
} from "./types"
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline.tsx
"use client"
import { useLayoutEffect, useState } from "react"
import { cn } from "@/lib/utils"
import { LifelineDesktop } from "./lifeline-desktop"
import { LifelineVertical } from "./lifeline-vertical"
import { LIFELINE_MOBILE_BREAKPOINT } from "./lifeline-layout"
import type { LifelineProps } from "./types"
import "./lifeline.css"
export function Lifeline(props: LifelineProps) {
const [isMobile, setIsMobile] = useState<boolean | null>(null)
useLayoutEffect(() => {
const query = window.matchMedia(
`(min-width: ${LIFELINE_MOBILE_BREAKPOINT}px)`,
)
const update = () => setIsMobile(!query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
if (isMobile === null) {
return <div className="invisible h-full" aria-hidden="true" />
}
if (isMobile) {
return (
<div
className={
props.mode === "embed"
? cn(
"lifeline-typeset h-full overflow-y-auto pt-5",
props.className,
)
: "lifeline-typeset pt-5"
}
>
<LifelineVertical {...props} />
</div>
)
}
return (
<LifelineDesktop
{...props}
className={cn("lifeline-typeset py-5", props.className)}
/>
)
}registry/new-york/templates/portfolio-v3/components/lifeline/company-icon.tsx
import type { ComponentType } from "react"
import { cn } from "@/lib/utils"
export type CompanyIconId = string
export interface CompanyIconEntry {
icon: ComponentType<{ className?: string }>
darkIcon?: ComponentType<{ className?: string }>
sizeClassName?: string
}
const registry: Record<string, CompanyIconEntry> = {}
export function registerCompanyIcons(
entries: Record<string, CompanyIconEntry>,
) {
Object.assign(registry, entries)
}
export function CompanyIcon({
id,
label,
className,
}: {
id: CompanyIconId
label: string
className?: string
}) {
const entry = registry[id]
if (entry) {
const Icon = entry.icon
const DarkIcon = entry.darkIcon
return (
<span
className={cn("inline-flex shrink-0 items-center justify-center text-foreground transition-colors duration-300",
entry.sizeClassName ?? "h-4 w-4",
className,
)}
aria-label={label}
title={label}
>
{DarkIcon ? (
<>
<Icon className="h-full w-full dark:hidden" aria-hidden="true" />
<DarkIcon
className="hidden h-full w-full dark:block"
aria-hidden="true"
/>
</>
) : (
<Icon className="h-full w-full" aria-hidden="true" />
)}
</span>
)
}
return (
<span
title={label}
aria-label={label}
className={cn("inline-flex h-5 w-5 select-none items-center justify-center rounded-full text-[10px] font-semibold uppercase leading-none ring-1 ring-current/30",
className,
)}
>
{label.charAt(0)}
</span>
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-desktop.tsx
"use client"
import { useMemo, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
import {
LifelineStickyLabels,
LIFELINE_STICKY_SHIELD_WIDTH,
} from "./lifeline-labels"
import { LifelineMarkerColumn } from "./lifeline-marker"
import type { LifelineEventImage, LifelineProps } from "./types"
import { getLifelineEventImage } from "./lifeline-event"
import { LifelineHoverImageProvider } from "./lifeline-hover-image"
import { useLifelineIntro } from "./use-lifeline-intro"
import { useLifelineScroll } from "./use-lifeline-scroll"
import { getMarkerWidth } from "./lifeline-utils"
export function LifelineDesktop({
markers,
birthYear,
className,
title = "Lifeline",
mode = "auto",
}: LifelineProps) {
const widths = useMemo(
() =>
markers.map((marker, index) =>
getMarkerWidth(marker, markers[index + 1]?.year),
),
[markers],
)
const hoverImages = useMemo(() => {
const images: LifelineEventImage[] = []
for (const marker of markers) {
for (const event of marker.events) {
const image = getLifelineEventImage(event)
if (image) images.push(image)
}
}
return images
}, [markers])
const intro = useLifelineIntro(widths)
const isIntroAnimating = intro.shouldPlay && intro.isPlaying
const {
sectionRef,
trackRef,
labelsRef,
setMarkerRef,
isLayoutReady,
isEmbed,
introArmed,
} = useLifelineScroll(markers.length, {
mode,
introLocked: isIntroAnimating,
introAnimating: isIntroAnimating,
introSkipped: !intro.shouldPlay,
introRailMs: intro.railDuration,
introGetTrackProgress: intro.getTrackProgressAtTime,
onIntroScrollStart: intro.startIntroTimer,
onIntroSettleComplete: intro.completeIntro,
})
const introWaitingInView = isEmbed && intro.shouldPlay && !introArmed
const showIntro = isIntroAnimating && isLayoutReady && !introWaitingInView
const trackWidth =
LIFELINE_STICKY_SHIELD_WIDTH + widths.reduce((sum, width) => sum + width, 0)
const introStyle = {
"--lifeline-labels-ms": `${intro.labelsDuration}ms`,
"--lifeline-rail-ms": `${intro.railDuration}ms`,
} as CSSProperties
return (
<section
ref={sectionRef}
data-lifeline-mode={isEmbed ? "embed" : "page"}
tabIndex={isEmbed ? 0 : undefined}
className={cn("relative h-full min-h-0 select-none overflow-hidden [&_a]:cursor-pointer",
isEmbed &&
"touch-pan-y focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
(!isLayoutReady || introWaitingInView) && "invisible",
className,
)}
aria-label={title}
style={showIntro ? introStyle : undefined}
>
<LifelineHoverImageProvider preload={hoverImages}>
<div
className="flex h-full items-center overflow-hidden"
style={{ alignItems: "safe center" }}
>
<div
ref={trackRef}
className="relative flex w-max items-start will-change-transform [--lifeline-global-events-top:calc(14.5rem+40px)] [--lifeline-rail:5rem]"
style={{ width: trackWidth }}
>
<div
ref={labelsRef}
className="lifeline-labels relative shrink-0 will-change-transform"
style={{ width: LIFELINE_STICKY_SHIELD_WIDTH }}
>
{/* Opaque backdrop for the pinned labels. Reaches past the left
edge so scrolled-away content never peeks through the gutter,
and dissolves on its right edge so markers slide behind it
instead of hitting a hard box seam. */}
<div
aria-hidden="true"
className="lifeline-labels-shield pointer-events-none absolute inset-y-0 -left-16 right-0 bg-background transition-colors duration-300"
/>
<div className={cn("relative", showIntro && "lifeline-labels-intro")}>
<LifelineStickyLabels />
</div>
</div>
<div className="relative">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-(--lifeline-rail) h-px overflow-hidden"
>
<div
className={cn("h-px w-full border-t border-dashed border-border transition-colors duration-300",
showIntro && "lifeline-rail-intro",
)}
/>
</div>
<div className="relative flex items-start">
{markers.map((marker, index) => (
<LifelineMarkerColumn
key={marker.id}
ref={(node) => setMarkerRef(index, node)}
marker={marker}
birthYear={birthYear}
minWidth={widths[index]}
animateIntro={showIntro}
introDelay={intro.getMarkerDelay(index)}
introDuration={intro.getMarkerFadeDuration(index)}
/>
))}
</div>
</div>
</div>
</div>
</LifelineHoverImageProvider>
</section>
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-event.tsx
import type {
LifelineEvent,
LifelineEventCategory,
LifelineEventClip,
LifelineEventImage,
LifelineEventSegment,
} from "./types"
export const LIFELINE_EVENT_CATEGORY_DOT_CLASS: Record<
LifelineEventCategory,
string
> = {
work: "bg-amber-500",
college: "bg-violet-500",
destination: "bg-teal-500",
}
function getEventContent(
event: LifelineEvent,
): string | LifelineEventSegment[] {
if (typeof event === "object" && !Array.isArray(event) && "text" in event) {
return event.text
}
return event
}
export function getLifelineEventImage(
event: LifelineEvent,
): LifelineEventImage | undefined {
if (typeof event === "object" && !Array.isArray(event) && "image" in event) {
return event.image
}
return undefined
}
export function getLifelineEventVideo(
event: LifelineEvent,
): LifelineEventClip | undefined {
if (typeof event === "object" && !Array.isArray(event) && "video" in event) {
return event.video
}
return undefined
}
export function getLifelineEventCategory(
event: LifelineEvent,
): LifelineEventCategory | undefined {
if (
typeof event === "object" &&
!Array.isArray(event) &&
"category" in event
) {
return event.category
}
return undefined
}
export function LifelineEventText({
event,
className,
}: {
event: LifelineEvent
className?: string
}) {
const content = getEventContent(event)
if (typeof content === "string") {
return <span className={className}>{content}</span>
}
return (
<span className={className}>
{content.map((segment, index) =>
segment.type === "link" ? (
<a
key={index}
href={segment.href}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-muted-foreground underline-offset-2 transition-colors duration-300 group-hover:text-foreground group-hover:decoration-foreground"
>
{segment.value}
</a>
) : (
<span key={index}>{segment.value}</span>
),
)}
</span>
)
}
export function LifelineEventMedia({
media,
className,
}: {
media: LifelineEventImage
className?: string
}) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={media.src} alt={media.alt} loading="lazy" className={className} />
)
}
export function getLifelineEventKey(event: LifelineEvent, index: number) {
const content = getEventContent(event)
if (typeof content === "string") return `${index}-${content}`
return `${index}-${content.map((segment) => segment.value).join("")}`
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-hover-image.tsx
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react"
import { clamp, snapToDevicePixel } from "./lifeline-utils"
import type { LifelineEventImage } from "./types"
const FOLLOW_EASE = 0.16
const TILT_FACTOR = 0.14
const TILT_MAX_DEG = 7
const TILT_EASE = 0.1
const CURSOR_OFFSET_X = 24
const CURSOR_OFFSET_Y = 96
interface LifelineHoverImageApi {
show: (image: LifelineEventImage) => void
hide: () => void
}
interface LifelineHoverImageProviderProps {
children: ReactNode
preload?: LifelineEventImage[]
}
const LifelineHoverImageContext = createContext<LifelineHoverImageApi | null>(
null,
)
export function useLifelineHoverImage() {
return useContext(LifelineHoverImageContext)
}
export function LifelineHoverImageProvider({
children,
preload,
}: LifelineHoverImageProviderProps) {
const containerRef = useRef<HTMLDivElement>(null)
const imageRef = useRef<HTMLImageElement>(null)
const state = useRef({
x: 0,
y: 0,
targetX: 0,
targetY: 0,
tilt: 0,
visible: false,
hoverCapable: false,
frame: 0,
})
useEffect(() => {
const s = state.current
s.hoverCapable = window.matchMedia(
"(hover: hover) and (pointer: fine)",
).matches
const onMouseMove = (event: MouseEvent) => {
s.targetX = event.clientX
s.targetY = event.clientY
}
window.addEventListener("mousemove", onMouseMove, { passive: true })
return () => {
window.removeEventListener("mousemove", onMouseMove)
cancelAnimationFrame(s.frame)
}
}, [])
useEffect(() => {
if (!preload?.length || !state.current.hoverCapable) return
const warm = () => {
preload.forEach(({ src }) => {
if (!src) return
const image = new window.Image()
image.src = src
})
}
if (typeof window.requestIdleCallback === "function") {
const handle = window.requestIdleCallback(warm)
return () => window.cancelIdleCallback(handle)
}
const timeout = window.setTimeout(warm, 2000)
return () => window.clearTimeout(timeout)
}, [preload])
const stepRef = useRef<() => void>(() => {})
const step = useCallback(() => {
const s = state.current
const container = containerRef.current
if (!container) return
const dx = s.targetX - s.x
s.x += dx * FOLLOW_EASE
s.y += (s.targetY - s.y) * FOLLOW_EASE
const targetTilt = clamp(dx * TILT_FACTOR, -TILT_MAX_DEG, TILT_MAX_DEG)
s.tilt += (targetTilt - s.tilt) * TILT_EASE
if (
Math.abs(s.targetX - s.x) < 0.1 &&
Math.abs(s.targetY - s.y) < 0.1 &&
Math.abs(s.tilt) < 0.05
) {
s.x = s.targetX
s.y = s.targetY
s.tilt = 0
}
const rotate = s.tilt === 0 ? "" : ` rotate(${s.tilt}deg)`
container.style.transform = `translate3d(${snapToDevicePixel(
s.x + CURSOR_OFFSET_X,
)}px, ${snapToDevicePixel(s.y - CURSOR_OFFSET_Y)}px, 0)${rotate}`
if (s.visible) {
s.frame = requestAnimationFrame(() => stepRef.current())
}
}, [])
useEffect(() => {
stepRef.current = step
}, [step])
const api = useMemo<LifelineHoverImageApi>(
() => ({
show(image) {
const s = state.current
const container = containerRef.current
const img = imageRef.current
if (!s.hoverCapable || !container || !img) return
const targetSrc = new URL(image.src, window.location.origin).href
if (img.src !== targetSrc) {
img.style.visibility = "hidden"
img.src = targetSrc
img.alt = image.alt
const reveal = () => {
if (img.src === targetSrc) {
img.style.visibility = "visible"
}
}
if (img.complete) {
reveal()
} else {
img.decode().then(reveal, reveal)
}
} else {
img.style.visibility = "visible"
}
if (!s.visible) {
s.x = s.targetX
s.y = s.targetY
s.tilt = 0
}
s.visible = true
container.style.opacity = "1"
img.style.transform = "scale(1)"
cancelAnimationFrame(s.frame)
s.frame = requestAnimationFrame(step)
},
hide() {
const s = state.current
const container = containerRef.current
const img = imageRef.current
if (!container || !img) return
s.visible = false
container.style.opacity = "0"
img.style.transform = "scale(0.94)"
},
}),
[step],
)
return (
<LifelineHoverImageContext.Provider value={api}>
{children}
<div
ref={containerRef}
aria-hidden="true"
className="pointer-events-none fixed left-0 top-0 z-60 opacity-0 transition-opacity duration-200 ease-out will-change-transform"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
alt=""
className="w-70 scale-95 rounded-xl shadow-2xl ring-1 ring-black/10 transition-[transform,box-shadow] duration-200 ease-out dark:ring-black/40"
decoding="async"
/>
</div>
</LifelineHoverImageContext.Provider>
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-inline-clip.tsx
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Play } from "lucide-react"
import { cn } from "@/lib/utils"
import {
LifelineLightbox,
type LifelineLightboxStart,
} from "./lifeline-lightbox"
import type { LifelineEventClip } from "./types"
export function LifelineInlineClip({
clip,
className,
}: {
clip: LifelineEventClip
className?: string
}) {
const cardRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const [lightboxStart, setLightboxStart] =
useState<LifelineLightboxStart | null>(null)
useEffect(() => {
const root = cardRef.current
const video = videoRef.current
if (!root || !video) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) video.preload = "auto"
},
{ rootMargin: "80px" },
)
observer.observe(root)
return () => observer.disconnect()
}, [])
const measure = useCallback((): LifelineLightboxStart | null => {
const el = cardRef.current
if (!el) return null
const rect = el.getBoundingClientRect()
return {
cx: rect.left + rect.width / 2,
cy: rect.top + rect.height / 2,
w: el.offsetWidth,
h: el.offsetHeight,
}
}, [])
return (
<>
<div
ref={cardRef}
data-lifeline-interactive=""
className={cn(
"relative mt-3 w-full max-w-[200px] cursor-zoom-in overflow-hidden rounded-xl shadow-lg ring-1 ring-black/10 dark:ring-black/40",
lightboxStart && "invisible",
className,
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
if (!lightboxStart) setLightboxStart(measure())
}}
>
<video
ref={videoRef}
src={clip.src}
playsInline
preload="metadata"
aria-label={clip.alt}
className="block aspect-video w-full bg-black object-cover"
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/25 text-white">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-black shadow">
<Play className="h-3.5 w-3.5 translate-x-px" fill="currentColor" />
</span>
</div>
</div>
{lightboxStart && (
<LifelineLightbox
clip={clip}
rotate={0}
start={lightboxStart}
getHome={measure}
onClosed={() => setLightboxStart(null)}
/>
)}
</>
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-inline-still.tsx
"use client"
import { useCallback, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import {
LifelineLightbox,
type LifelineLightboxStart,
} from "./lifeline-lightbox"
import type { LifelineEventImage } from "./types"
export function LifelineInlineStill({
image,
className,
}: {
image: LifelineEventImage
className?: string
}) {
const cardRef = useRef<HTMLDivElement>(null)
const [lightboxStart, setLightboxStart] =
useState<LifelineLightboxStart | null>(null)
const measure = useCallback((): LifelineLightboxStart | null => {
const el = cardRef.current
if (!el) return null
const rect = el.getBoundingClientRect()
return {
cx: rect.left + rect.width / 2,
cy: rect.top + rect.height / 2,
w: el.offsetWidth,
h: el.offsetHeight,
}
}, [])
return (
<>
<div
ref={cardRef}
data-lifeline-interactive=""
className={cn(
"relative mt-3 w-full max-w-[200px] cursor-zoom-in overflow-hidden rounded-xl shadow-lg ring-1 ring-black/10 dark:ring-black/40",
lightboxStart && "invisible",
className,
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
if (!lightboxStart) setLightboxStart(measure())
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.src}
alt={image.alt}
className="block aspect-video w-full object-cover"
/>
</div>
{lightboxStart && (
<LifelineLightbox
photo={image}
rotate={0}
start={lightboxStart}
getHome={measure}
onClosed={() => setLightboxStart(null)}
/>
)}
</>
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-labels.tsx
export const LIFELINE_LABEL_COLUMN_WIDTH = 56
export const LIFELINE_LABEL_GAP = 16
export const LIFELINE_STICKY_SHIELD_WIDTH =
LIFELINE_LABEL_COLUMN_WIDTH + LIFELINE_LABEL_GAP
export const LIFELINE_STICKY_LEFT = 12
export function LifelineStickyLabels() {
return (
<div
className="relative"
style={{ width: LIFELINE_LABEL_COLUMN_WIDTH }}
aria-hidden="true"
>
<div className="flex flex-col items-start text-left">
<p className="mb-5 h-4 whitespace-nowrap text-[15px] font-medium uppercase leading-4 tracking-[0.08em] text-muted-foreground transition-colors duration-300">
Age
</p>
<p className="mb-6 h-5 whitespace-nowrap text-[15px] font-medium uppercase leading-5 tracking-[0.08em] text-muted-foreground transition-colors duration-300">
Years
</p>
</div>
</div>
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-legend.tsx
import type { LifelineLegendItem } from "./types"
const LEGEND_DOT_CLASS: Record<LifelineLegendItem["type"], string> = {
globalEvent: "bg-pink-500",
work: "bg-amber-500",
college: "bg-violet-500",
destination: "bg-teal-500",
}
const DEFAULT_ITEMS: LifelineLegendItem[] = [
{ type: "globalEvent", label: "Global events" },
{ type: "destination", label: "Destinations" },
{ type: "work", label: "Work" },
{ type: "college", label: "College" },
]
export function LifelineLegend({
items = DEFAULT_ITEMS,
}: {
items?: LifelineLegendItem[]
}) {
return (
<ul className="flex flex-wrap items-center gap-x-6 gap-y-2 text-[13px] text-muted-foreground">
{items.map((item) => (
<li key={item.type} className="flex items-center gap-2">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${LEGEND_DOT_CLASS[item.type]}`}
aria-hidden="true"
/>
<span>{item.label}</span>
</li>
))}
</ul>
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-lightbox.tsx
"use client"
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import type { LifelineEventClip, LifelineEventImage } from "./types"
const OPEN_MS = 520
const EASE = "cubic-bezier(0.32, 0.72, 0, 1)"
const FIT = 0.85
interface Target {
left: number
top: number
width: number
height: number
}
export interface LifelineLightboxStart {
cx: number
cy: number
w: number
h: number
}
function homeRadius(home: LifelineLightboxStart, targetWidth: number) {
const scale = home.w / targetWidth
if (scale <= 0) return "var(--radius-xl)"
return `calc(var(--radius-xl) / ${scale})`
}
function LightboxMedia({
photo,
clip,
showControls,
}: {
photo?: LifelineEventImage
clip?: LifelineEventClip
showControls: boolean
}) {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
const video = videoRef.current
if (!clip || !video) return
video.muted = false
void video.play().catch(() => {})
}, [clip])
if (clip) {
return (
<video
ref={videoRef}
src={clip.src}
playsInline
controls={showControls}
autoPlay
aria-label={clip.alt}
className="block h-full w-full bg-black object-cover"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
/>
)
}
if (!photo) return null
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photo.src}
alt={photo.alt}
className="block h-full w-full object-cover"
/>
)
}
function computeTarget(start: LifelineLightboxStart): Target {
const vw = window.innerWidth
const vh = window.innerHeight
const aspect = start.h / start.w
const width = Math.min(vw * FIT, (vh * FIT) / aspect)
const height = width * aspect
return { left: (vw - width) / 2, top: (vh - height) / 2, width, height }
}
export function LifelineLightbox({
photo,
clip,
rotate,
start,
getHome,
onClosed,
}: {
photo?: LifelineEventImage
clip?: LifelineEventClip
rotate: number
start: LifelineLightboxStart
getHome: () => LifelineLightboxStart | null
onClosed: () => void
}) {
const [target] = useState<Target>(() => computeTarget(start))
const { left, top, width, height } = target
const figureRef = useRef<HTMLElement>(null)
const [reduceMotion] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
)
const toTransform = useCallback(
(home: LifelineLightboxStart) =>
`translate(${home.cx - (left + width / 2)}px, ${
home.cy - (top + height / 2)
}px) scale(${home.w / width}) rotate(${rotate}deg)`,
[left, top, width, height, rotate],
)
const [entered, setEntered] = useState(reduceMotion)
const [transform, setTransform] = useState(() =>
reduceMotion ? "none" : toTransform(start),
)
const [borderRadius, setBorderRadius] = useState(() =>
reduceMotion ? "var(--radius-xl)" : homeRadius(start, width),
)
const closing = useRef(false)
useLayoutEffect(() => {
if (reduceMotion) return
let inner = 0
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => {
setEntered(true)
setTransform("translate(0px, 0px) scale(1) rotate(0deg)")
setBorderRadius("var(--radius-xl)")
})
})
return () => {
cancelAnimationFrame(outer)
cancelAnimationFrame(inner)
}
}, [reduceMotion])
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
if (!root) return
const block = (event: TouchEvent) => event.preventDefault()
root.addEventListener("touchmove", block, { passive: false })
return () => root.removeEventListener("touchmove", block)
}, [])
useEffect(() => {
const swallow = (event: MouseEvent) => {
event.stopPropagation()
event.preventDefault()
}
window.addEventListener("click", swallow, { capture: true, once: true })
const timeout = window.setTimeout(() => {
window.removeEventListener("click", swallow, { capture: true })
}, 500)
return () => {
window.clearTimeout(timeout)
window.removeEventListener("click", swallow, { capture: true })
}
}, [])
const dismiss = useCallback(() => {
if (closing.current) return
closing.current = true
if (reduceMotion) {
onClosed()
return
}
const home = getHome() ?? start
setEntered(false)
setTransform(toTransform(home))
setBorderRadius(homeRadius(home, width))
window.setTimeout(onClosed, OPEN_MS + 120)
}, [reduceMotion, onClosed, toTransform, getHome, start, width])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") dismiss()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [dismiss])
return createPortal(
<div
ref={rootRef}
className="fixed inset-0 z-[999] touch-none overscroll-contain"
role="dialog"
aria-modal="true"
aria-label={photo?.alt ?? clip?.alt ?? "Media"}
onPointerDown={(event) => event.stopPropagation()}
onPointerMove={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div
className={cn(
"absolute inset-0 cursor-zoom-out bg-black/60 transition-opacity",
entered ? "opacity-100" : "opacity-0",
)}
style={{ transitionDuration: `${OPEN_MS}ms` }}
onClick={dismiss}
/>
<figure
ref={figureRef}
className={cn(
"absolute overflow-hidden shadow-2xl ring-1 ring-black/20",
clip ? "cursor-default" : "cursor-zoom-out",
)}
style={{
left,
top,
width,
height,
transform,
borderRadius,
transformOrigin: "center",
transition: reduceMotion
? undefined
: `transform ${OPEN_MS}ms ${EASE}, border-radius ${OPEN_MS}ms ${EASE}`,
willChange: "transform, border-radius",
}}
onClick={clip ? undefined : dismiss}
onTransitionEnd={(event) => {
if (event.propertyName !== "transform") return
if (closing.current) onClosed()
}}
>
<LightboxMedia photo={photo} clip={clip} showControls={entered} />
</figure>
</div>,
document.body,
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-marker.tsx
"use client"
import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type TransitionEvent as ReactTransitionEvent,
} from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { CompanyIcon } from "./company-icon"
import {
getLifelineEventCategory,
getLifelineEventImage,
getLifelineEventKey,
getLifelineEventVideo,
LIFELINE_EVENT_CATEGORY_DOT_CLASS,
LifelineEventText,
} from "./lifeline-event"
import { LifelineInlineClip } from "./lifeline-inline-clip"
import { LifelineInlineStill } from "./lifeline-inline-still"
import {
aggregateLifelineGlobalEvents,
LIFELINE_GLOBAL_EVENTS_VISIBLE,
LifelineGlobalEvents,
} from "./lifeline-global-events"
import {
clamp,
LIFELINE_EXPAND_GESTURE,
type LifelineExpandGestureDetail,
} from "./lifeline-utils"
import type { LifelineMarker } from "./types"
type ExpandPhase = "idle" | "opening" | "closing"
const SNAP_VELOCITY = 0.4
// Keep the rail itself fully opaque, then dissolve over a short band in the
// gap between the dotted timeline and the first title.
const RAIL_DISSOLVE =
"linear-gradient(to bottom, rgb(255 255 255 / 0) 0, rgb(255 255 255 / 0) 14px, #fff 56px)"
const RAIL_DISSOLVE_MASK: CSSProperties = {
maskImage: RAIL_DISSOLVE,
WebkitMaskImage: RAIL_DISSOLVE,
}
const MOTION =
"duration-[560ms] ease-[cubic-bezier(0.33,1,0.68,1)] motion-reduce:transition-none"
interface LifelineMarkerColumnProps {
marker: LifelineMarker
birthYear: number
minWidth: number
animateIntro?: boolean
introDelay?: number
introDuration?: number
}
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
export const LifelineMarkerColumn = forwardRef<
HTMLDivElement,
LifelineMarkerColumnProps
>(function LifelineMarkerColumn(
{
marker,
birthYear,
minWidth,
animateIntro = false,
introDelay = 0,
introDuration = 420,
},
ref,
) {
const age = marker.age ?? marker.year - birthYear
const globalEvents = aggregateLifelineGlobalEvents(marker)
// The timeline gives each year a fixed slot (navbar + footer pin it). Past
// the visible count, the extra global events are held just below the fold;
// grabbing up (or "More") scrolls the stack so they rise into view while
// the year's content parks in the gap under the rail. The column never grows.
const hiddenCount = globalEvents.length - LIFELINE_GLOBAL_EVENTS_VISIBLE
const isTruncated = hiddenCount > 0
const [expanded, setExpanded] = useState(false)
const [phase, setPhase] = useState<ExpandPhase>("idle")
const [windowHeight, setWindowHeight] = useState<number | null>(null)
const [extra, setExtra] = useState(0)
const [progress, setProgress] = useState(0)
const [scrubbing, setScrubbing] = useState(false)
const columnRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const extraRef = useRef(0)
const progressRef = useRef(0)
const originProgressRef = useRef(0)
const accYRef = useRef(0)
const scrubbingRef = useRef(false)
const assignRef = useCallback(
(node: HTMLDivElement | null) => {
columnRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref],
)
const setProgressValue = useCallback((next: number) => {
const clamped = clamp(next, 0, 1)
progressRef.current = clamped
setProgress(clamped)
setExpanded(clamped >= 0.5)
}, [])
const snapTo = useCallback(
(target: 0 | 1) => {
setScrubbing(false)
scrubbingRef.current = false
if (
prefersReducedMotion() ||
Math.abs(progressRef.current - target) < 0.001
) {
setProgressValue(target)
setPhase("idle")
return
}
setPhase(target === 1 ? "opening" : "closing")
setProgressValue(target)
},
[setProgressValue],
)
// Freeze the window to the collapsed stack's height so expanding shifts
// content inside it instead of growing the column (and recentering the
// rail off-screen). All events stay mounted so a vertical grab can scrub.
useLayoutEffect(() => {
if (!isTruncated) return
const track = trackRef.current
const list = listRef.current
if (!track || !list) return
const measure = () => {
const items = [...list.children] as HTMLElement[]
const lastVisible =
items[Math.min(LIFELINE_GLOBAL_EVENTS_VISIBLE, items.length) - 1]
if (!lastVisible) return
const fold = Math.round(
lastVisible.getBoundingClientRect().bottom -
track.getBoundingClientRect().top,
)
const nextExtra = Math.max(0, track.offsetHeight - fold)
extraRef.current = nextExtra
setWindowHeight((prev) => (prev === fold ? prev : fold))
setExtra((prev) => (prev === nextExtra ? prev : nextExtra))
}
measure()
let raf = 0
const scheduleMeasure = () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(measure)
}
const observer = new ResizeObserver(scheduleMeasure)
observer.observe(track)
observer.observe(list)
window.addEventListener("resize", scheduleMeasure)
return () => {
cancelAnimationFrame(raf)
observer.disconnect()
window.removeEventListener("resize", scheduleMeasure)
}
}, [isTruncated, globalEvents.length])
useEffect(() => {
if (!isTruncated) return
const node = columnRef.current
if (!node) return
const onGesture = (event: Event) => {
const { phase: gesturePhase, deltaY, velocityY } = (
event as CustomEvent<LifelineExpandGestureDetail>
).detail
if (gesturePhase === "start") {
originProgressRef.current = progressRef.current
accYRef.current = 0
setScrubbing(true)
scrubbingRef.current = true
setPhase("idle")
return
}
if (gesturePhase === "move") {
const span = extraRef.current
if (span <= 0) return
accYRef.current += deltaY
setProgressValue(originProgressRef.current - accYRef.current / span)
return
}
const current = progressRef.current
let target: 0 | 1
if (velocityY < -SNAP_VELOCITY) target = 1
else if (velocityY > SNAP_VELOCITY) target = 0
else target = current > 0.5 ? 1 : 0
snapTo(target)
}
node.addEventListener(LIFELINE_EXPAND_GESTURE, onGesture)
return () => node.removeEventListener(LIFELINE_EXPAND_GESTURE, onGesture)
}, [isTruncated, setProgressValue, snapTo])
// Safety net for reduced-motion (no transitionend) or a dropped event.
useEffect(() => {
if (phase === "idle") return
const id = window.setTimeout(() => setPhase("idle"), 720)
return () => window.clearTimeout(id)
}, [phase])
function toggle(event: ReactMouseEvent) {
event.stopPropagation()
if (scrubbing || windowHeight == null || extra <= 0) return
snapTo(progress > 0.5 ? 0 : 1)
}
function handleTrackTransitionEnd(
event: ReactTransitionEvent<HTMLDivElement>,
) {
if (event.target !== event.currentTarget || event.propertyName !== "transform") {
return
}
setPhase("idle")
}
const isOpen = progress > 0.02
const trackStyle: CSSProperties = {
transform: `translateY(${-progress * extra}px)`,
}
const innerContent = (
<>
<div
className={cn(
"flex w-full flex-col items-start pt-6",
globalEvents.length > 0 &&
"min-h-[var(--lifeline-global-events-top)] pb-6",
)}
>
{marker.badges && marker.badges.length > 0 && (
<div className="mb-3 flex items-center justify-start gap-2">
{marker.badges.map((badge) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={badge.src}
src={badge.src}
alt={badge.alt}
className="h-6 w-6 object-contain opacity-80 transition-opacity duration-300 group-hover:opacity-100"
/>
))}
</div>
)}
{marker.companies && marker.companies.length > 0 && (
<div className="mb-2 flex items-center justify-start gap-1.5">
{marker.companies.map((company) => (
<CompanyIcon
key={company.id}
id={company.id}
label={company.name}
className="opacity-70 transition-opacity duration-300 group-hover:opacity-100"
/>
))}
</div>
)}
<div className="min-h-[3.25rem] space-y-4">
{marker.events.map((event, index) => {
const image = getLifelineEventImage(event)
const clip = getLifelineEventVideo(event)
const category = getLifelineEventCategory(event)
return (
<div key={getLifelineEventKey(event, index)}>
<p className="flex max-w-[18rem] items-start gap-2 text-left text-[14px] leading-[1.55] tracking-[-0.01em]">
{category && (
<span
className={cn("mt-[7px] size-1.5 shrink-0 rounded-full",
LIFELINE_EVENT_CATEGORY_DOT_CLASS[category],
)}
aria-hidden="true"
/>
)}
<LifelineEventText event={event} />
</p>
{image && <LifelineInlineStill image={image} />}
{clip && <LifelineInlineClip clip={clip} />}
</div>
)
})}
</div>
</div>
{globalEvents.length > 0 && (
<div className="w-full">
<LifelineGlobalEvents ref={listRef} events={globalEvents} />
</div>
)}
</>
)
return (
<div
ref={assignRef}
className="group relative shrink-0 pr-8 transition-opacity duration-300 ease-out will-change-opacity"
style={{ width: minWidth }}
aria-label={marker.label ?? `${marker.year}`}
{...(isTruncated ? { "data-lifeline-expand": "" } : {})}
>
<div
className={cn("relative", animateIntro && "lifeline-marker-intro")}
style={{
animationDelay: animateIntro ? `${introDelay}ms` : undefined,
...(animateIntro
? ({
"--lifeline-marker-fade-ms": `${introDuration}ms`,
} as CSSProperties)
: {}),
}}
>
<span
className="absolute left-0 top-[var(--lifeline-rail)] z-10 h-[10px] w-px -translate-y-1/2 bg-muted-foreground transition-colors duration-300 group-hover:bg-muted-foreground"
aria-hidden="true"
/>
{/* Hairline over the pocket so the rail stays readable while content
dissolves in the gap below it. */}
{isTruncated && (
<div
aria-hidden="true"
className={cn("pointer-events-none absolute left-0 right-0 top-[var(--lifeline-rail)] z-30 border-t border-dashed border-border transition-opacity duration-300",
isOpen ? "opacity-100" : "opacity-0",
)}
/>
)}
<div className="flex w-full flex-col items-start text-left">
<p className="mb-5 h-4 text-[15px] font-medium leading-4 tabular-nums text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
{age}
</p>
<p className="mb-6 h-5 whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
{marker.label ?? marker.year}
</p>
<div className="relative w-full pb-10 text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
{isTruncated ? (
<div
className="relative overflow-hidden"
style={{
height: windowHeight ?? undefined,
...(isOpen ? RAIL_DISSOLVE_MASK : null),
}}
>
<div
ref={trackRef}
onTransitionEnd={handleTrackTransitionEnd}
className={cn(
"will-change-transform",
!scrubbing && "transition-transform",
!scrubbing && MOTION,
)}
style={trackStyle}
>
{innerContent}
</div>
{/* Soft lower edge only while the stack is in motion. */}
<div
aria-hidden="true"
className={cn("pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8 bg-gradient-to-t from-background to-transparent transition-opacity duration-200",
scrubbing || phase !== "idle" ? "opacity-100" : "opacity-0",
)}
/>
</div>
) : (
<div>{innerContent}</div>
)}
{isTruncated && (
<button
type="button"
data-lifeline-interactive=""
onClick={toggle}
disabled={scrubbing}
aria-expanded={expanded}
aria-label={
expanded
? `Collapse ${marker.label ?? marker.year}`
: `Show ${hiddenCount} more for ${
marker.label ?? marker.year
}`
}
className="group/more relative z-20 mt-4 flex w-full justify-center disabled:opacity-60"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground/40 text-muted-foreground transition-colors duration-300 group-hover/more:border-muted-foreground/70 group-hover/more:text-foreground">
<ChevronDown
className={cn("size-3.5 transition-transform duration-300 motion-reduce:transition-none",
progress < 0.5 && "rotate-180",
)}
/>
</span>
</button>
)}
</div>
</div>
</div>
</div>
)
})
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-global-events.tsx
import { forwardRef } from "react"
import { CompanyIcon } from "./company-icon"
import type { LifelineGlobalEvent, LifelineMarker } from "./types"
/** Max global events a single year lists before the rest collapse behind "More". */
export const LIFELINE_GLOBAL_EVENTS_VISIBLE = 4
export function aggregateLifelineGlobalEvents(
marker: LifelineMarker,
): LifelineGlobalEvent[] {
const map = new Map<string, LifelineGlobalEvent>()
marker.globalEvents?.forEach((item) => {
if (!map.has(item.name)) map.set(item.name, item)
})
return [...map.values()]
}
function getInitials(name: string) {
return name
.split(" ")
.filter(Boolean)
.map((part) => part[0])
.join("")
.slice(0, 2)
}
function LifelineGlobalEventPlace({ item }: { item: LifelineGlobalEvent }) {
const place = item.place?.trim()
const date = item.date?.trim()
const fallback = item.role?.trim()
if (!place && !date && !fallback) return null
const label = [place, date].filter(Boolean).join(" · ") || fallback
return (
<p
className="text-[11px] leading-snug text-pretty text-muted-foreground/80"
title={label}
>
{place && date ? (
<>
<span className="wrap-break-word">{place}</span>
<span className="text-muted-foreground/40"> · </span>
<span className="whitespace-nowrap tabular-nums">{date}</span>
</>
) : (
<span className="wrap-break-word">{place || date || fallback}</span>
)}
</p>
)
}
interface LifelineGlobalEventsProps {
events: LifelineGlobalEvent[]
}
export const LifelineGlobalEvents = forwardRef<
HTMLDivElement,
LifelineGlobalEventsProps
>(function LifelineGlobalEvents({ events }, ref) {
if (events.length === 0) return null
return (
<div ref={ref} className="flex w-full flex-col gap-3">
{events.map((item) => (
<div key={item.name} className="flex w-full items-start gap-2.5">
<div className="flex h-7 w-3 shrink-0 items-center justify-center">
<span
className="size-1.5 rounded-full bg-pink-500"
aria-hidden="true"
/>
</div>
{item.icon ? (
<span className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-foreground">
<CompanyIcon
id={item.icon}
label={item.name}
className="h-4 w-5"
/>
</span>
) : (
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-foreground text-[10px] font-medium text-background transition-colors duration-300">
{getInitials(item.name)}
</span>
)}
<div className="min-w-0 text-left leading-snug">
<p className="text-[13px] text-pretty text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
{item.name}
</p>
<LifelineGlobalEventPlace item={item} />
</div>
</div>
))}
</div>
)
})
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-vertical.tsx
"use client"
import { forwardRef, useEffect, useMemo, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
import { CompanyIcon } from "./company-icon"
import {
getLifelineEventCategory,
getLifelineEventImage,
getLifelineEventKey,
getLifelineEventVideo,
LIFELINE_EVENT_CATEGORY_DOT_CLASS,
LifelineEventText,
} from "./lifeline-event"
import { LifelineInlineClip } from "./lifeline-inline-clip"
import { LifelineInlineStill } from "./lifeline-inline-still"
import {
aggregateLifelineGlobalEvents,
LifelineGlobalEvents,
} from "./lifeline-global-events"
import type { LifelineEvent, LifelineMarker, LifelineProps } from "./types"
import { getMarkerHeight, hasMarkerContent } from "./lifeline-utils"
import { useLifelineIntro } from "./use-lifeline-intro"
import { useLifelineVerticalScroll } from "./use-lifeline-vertical-scroll"
const GRID_CLASS = "grid grid-cols-[2.5rem_1rem_1fr] gap-x-3"
const RAIL_LEFT = "calc(2.5rem + 0.75rem + 0.5rem)"
const MAX_ARMED_ENTRIES = 80
function RailTick() {
return (
<span
aria-hidden="true"
className="block h-px w-[10px] bg-muted-foreground transition-colors duration-300"
/>
)
}
function LifelineVerticalEvent({ event }: { event: LifelineEvent }) {
const image = getLifelineEventImage(event)
const clip = getLifelineEventVideo(event)
const category = getLifelineEventCategory(event)
return (
<div>
<p className="flex max-w-[18rem] items-start gap-2 text-left text-[14px] leading-[1.55] tracking-[-0.01em]">
{category && (
<span
className={cn("mt-[7px] size-1.5 shrink-0 rounded-full",
LIFELINE_EVENT_CATEGORY_DOT_CLASS[category],
)}
aria-hidden="true"
/>
)}
<LifelineEventText event={event} />
</p>
{image && <LifelineInlineStill image={image} />}
{clip && <LifelineInlineClip clip={clip} />}
</div>
)
}
const LifelineVerticalEntry = forwardRef<
HTMLLIElement,
{
marker: LifelineMarker
birthYear: number
animateIntro?: boolean
introDelay?: number
introDuration?: number
revealPending?: boolean
}
>(function LifelineVerticalEntry(
{
marker,
birthYear,
animateIntro = false,
introDelay = 0,
introDuration = 420,
revealPending = false,
},
ref,
) {
const age = marker.age ?? marker.year - birthYear
const globalEvents = aggregateLifelineGlobalEvents(marker)
const hasContent = hasMarkerContent(marker)
return (
<li
ref={ref}
className={hasContent ? "pb-10" : "pb-3"}
aria-label={marker.label ?? `${marker.year}`}
>
<div
className={cn(
animateIntro && "lifeline-marker-intro",
revealPending && "opacity-0",
)}
style={{
animationDelay: animateIntro ? `${introDelay}ms` : undefined,
...(animateIntro
? ({
"--lifeline-marker-fade-ms": `${introDuration}ms`,
} as CSSProperties)
: {}),
}}
>
<div className={`${GRID_CLASS} items-center`}>
<p className="text-right text-[15px] font-medium leading-5 tabular-nums text-muted-foreground transition-colors duration-300">
{age}
</p>
<div className="flex items-center justify-center">
<RailTick />
</div>
<p className="whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-muted-foreground transition-colors duration-300">
{marker.label ?? marker.year}
</p>
</div>
{hasContent && (
<div className={`${GRID_CLASS} mt-6`}>
<div aria-hidden="true" />
<div aria-hidden="true" />
<div className="min-w-0 text-muted-foreground transition-colors duration-300">
{marker.badges && marker.badges.length > 0 && (
<div className="mb-3 flex items-center justify-start gap-2">
{marker.badges.map((badge) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={badge.src}
src={badge.src}
alt={badge.alt}
className="h-6 w-6 object-contain opacity-80"
/>
))}
</div>
)}
{marker.companies && marker.companies.length > 0 && (
<div className="mb-2 flex items-center justify-start gap-1.5">
{marker.companies.map((company) => (
<CompanyIcon
key={company.id}
id={company.id}
label={company.name}
className="opacity-70"
/>
))}
</div>
)}
{marker.events.length > 0 && (
<div className="space-y-4">
{marker.events.map((event, index) => (
<LifelineVerticalEvent
key={getLifelineEventKey(event, index)}
event={event}
/>
))}
</div>
)}
{globalEvents.length > 0 && (
<div className="mt-6 border-t border-border/70 pt-5 transition-colors duration-300">
<LifelineGlobalEvents events={globalEvents} />
</div>
)}
</div>
</div>
)}
</div>
</li>
)
})
export function LifelineVertical({
markers,
birthYear,
title = "Lifeline",
mode = "auto",
}: LifelineProps) {
const isEmbed = mode === "embed"
const heights = useMemo(
() =>
markers.map((marker, index) =>
getMarkerHeight(marker, markers[index + 1]?.year),
),
[markers],
)
const intro = useLifelineIntro(heights)
const isIntroAnimating = intro.shouldPlay && intro.isPlaying
useEffect(() => {
const sources: string[] = []
for (const marker of markers) {
for (const event of marker.events) {
const image = getLifelineEventImage(event)
if (image?.src) sources.push(image.src)
}
}
if (sources.length === 0) return
const warm = () => {
sources.forEach((src) => {
const image = new window.Image()
image.src = src
})
}
if (typeof window.requestIdleCallback === "function") {
const handle = window.requestIdleCallback(warm)
return () => window.cancelIdleCallback(handle)
}
const timeout = window.setTimeout(warm, 2000)
return () => window.clearTimeout(timeout)
}, [markers])
const { sectionRef, setEntryRef, isLayoutReady } = useLifelineVerticalScroll(
markers.length,
{
isEmbed,
introLocked: isIntroAnimating,
introAnimating: isIntroAnimating,
introSkipped: !intro.shouldPlay || isEmbed,
introRailMs: intro.railDuration,
introGetTrackProgress: intro.getTrackProgressAtTime,
onIntroScrollStart: intro.startIntroTimer,
onIntroSettleComplete: intro.completeIntro,
},
)
const showIntro = isIntroAnimating && isLayoutReady && !isEmbed
const revealOnScroll = markers.length > MAX_ARMED_ENTRIES
const animateEntries = showIntro && !revealOnScroll
useEffect(() => {
if (!showIntro || !revealOnScroll) return
const section = sectionRef.current
const ol = section?.querySelector("ol")
if (!section || !ol) return
const entries = Array.from(ol.children) as HTMLElement[]
const targets = entries.map(
(li) => li.firstElementChild as HTMLElement | null,
)
const onAnimationEnd = (event: AnimationEvent) => {
if (event.animationName !== "lifeline-marker-in") return
;(event.target as HTMLElement).classList.remove("lifeline-marker-intro")
}
section.addEventListener("animationend", onAnimationEnd)
let next = 0
let frame = 0
const tick = () => {
const progress = parseFloat(
section.style.getPropertyValue("--lifeline-intro-progress") || "0",
)
const tip = progress * ol.offsetHeight
while (next < entries.length && entries[next].offsetTop <= tip) {
const el = targets[next]
if (el) {
el.classList.remove("opacity-0")
el.classList.add("lifeline-marker-intro")
}
next++
}
if (next < entries.length) {
frame = requestAnimationFrame(tick)
}
}
frame = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(frame)
section.removeEventListener("animationend", onAnimationEnd)
targets.forEach((el) => {
el?.classList.remove("opacity-0", "lifeline-marker-intro")
})
}
}, [showIntro, revealOnScroll, sectionRef])
const introStyle = {
"--lifeline-labels-ms": `${intro.labelsDuration}ms`,
"--lifeline-rail-ms": `${intro.railDuration}ms`,
} as CSSProperties
return (
<article
ref={sectionRef}
aria-label={title}
className={cn("relative select-none px-6 pb-10 pt-4 [&_a]:cursor-pointer",
!isLayoutReady && "invisible",
)}
style={showIntro ? introStyle : undefined}
>
<div className={cn(`${GRID_CLASS} mb-6 items-end`, showIntro && "lifeline-labels-intro")}>
<p className="text-right text-[15px] font-medium uppercase leading-5 tracking-[0.08em] text-muted-foreground transition-colors duration-300">
Age
</p>
<div aria-hidden="true" />
<p className="text-[15px] font-medium uppercase leading-5 tracking-[0.08em] text-muted-foreground transition-colors duration-300">
Years
</p>
</div>
<div className="relative">
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-0 top-0 overflow-hidden -translate-x-1/2"
style={{ left: RAIL_LEFT, width: 1 }}
>
<div
className={cn("h-full w-px border-l border-dashed border-border transition-colors duration-300",
showIntro && "lifeline-rail-intro-vertical",
)}
/>
</div>
<ol className="relative">
{markers.map((marker, index) => (
<LifelineVerticalEntry
key={marker.id}
ref={(node) => setEntryRef(index, node)}
marker={marker}
birthYear={birthYear}
animateIntro={animateEntries}
revealPending={showIntro && revealOnScroll}
introDelay={intro.getMarkerDelay(index)}
introDuration={intro.getMarkerFadeDuration(index)}
/>
))}
</ol>
</div>
</article>
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/use-lifeline-intro.ts
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
getTransitionMarkerFadeDuration,
LIFELINE_FAST_MARKER_FADE_MS,
timeAtTrackProgress,
trackProgressAtTime,
} from "./lifeline-intro-timing"
export const LIFELINE_LABELS_MS = 600
export const LIFELINE_RAIL_MS = 3200
export const LIFELINE_REFERENCE_TRACK = 9000
export const LIFELINE_RAIL_MAX_MS = 7200
export const LIFELINE_RAIL_SCALE_POWER = 0.45
export const LIFELINE_FADE_SCALE_MAX = 1.5
export function useLifelineIntro(markerWidths: number[]) {
const [shouldPlay] = useState(
() =>
typeof window === "undefined" ||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches,
)
const [isPlaying, setIsPlaying] = useState(true)
const [isComplete, setIsComplete] = useState(false)
const introTimeoutRef = useRef(0)
const totalMarkersWidth = useMemo(
() => markerWidths.reduce((sum, width) => sum + width, 0),
[markerWidths],
)
const railDuration = useMemo(() => {
if (totalMarkersWidth <= LIFELINE_REFERENCE_TRACK) return LIFELINE_RAIL_MS
const scale = Math.pow(
totalMarkersWidth / LIFELINE_REFERENCE_TRACK,
LIFELINE_RAIL_SCALE_POWER,
)
return Math.min(LIFELINE_RAIL_MAX_MS, Math.round(LIFELINE_RAIL_MS * scale))
}, [totalMarkersWidth])
const fadeScale = Math.min(
LIFELINE_FADE_SCALE_MAX,
railDuration / LIFELINE_RAIL_MS,
)
const introDuration =
railDuration + Math.round(LIFELINE_FAST_MARKER_FADE_MS * fadeScale)
const getTrackProgressAtTime = useCallback(
(elapsedMs: number) => {
if (!shouldPlay || totalMarkersWidth <= 0) {
return Math.min(elapsedMs / railDuration, 1)
}
return trackProgressAtTime(elapsedMs, markerWidths, railDuration)
},
[markerWidths, railDuration, shouldPlay, totalMarkersWidth],
)
const getMarkerDelay = useCallback(
(index: number) => {
if (!shouldPlay || totalMarkersWidth <= 0) return 0
const offset = markerWidths
.slice(0, index)
.reduce((sum, width) => sum + width, 0)
return timeAtTrackProgress(
offset / totalMarkersWidth,
markerWidths,
railDuration,
)
},
[markerWidths, railDuration, shouldPlay, totalMarkersWidth],
)
const getMarkerFadeDuration = useCallback(
(index: number) => {
return Math.round(getTransitionMarkerFadeDuration(index) * fadeScale)
},
[fadeScale],
)
const completeIntro = useCallback(() => {
setIsComplete(true)
}, [])
const startIntroTimer = useCallback(() => {
window.clearTimeout(introTimeoutRef.current)
setIsPlaying(true)
setIsComplete(false)
introTimeoutRef.current = window.setTimeout(() => {
setIsPlaying(false)
}, introDuration)
}, [introDuration])
useEffect(() => {
return () => window.clearTimeout(introTimeoutRef.current)
}, [])
return {
shouldPlay,
isPlaying,
isComplete,
labelsDuration: LIFELINE_LABELS_MS,
railDuration,
getTrackProgressAtTime,
getMarkerDelay,
getMarkerFadeDuration,
startIntroTimer,
completeIntro,
}
}
registry/new-york/templates/portfolio-v3/components/lifeline/use-lifeline-scroll.ts
"use client"
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import {
LIFELINE_STICKY_LEFT,
LIFELINE_STICKY_SHIELD_WIDTH,
} from "./lifeline-labels"
import {
clamp,
emitLifelineExpandGesture,
findLifelineExpandHost,
snapToDevicePixel,
} from "./lifeline-utils"
import type { LifelineMode } from "./types"
const FADE_ZONE = 200
const FADE_ZONE_COARSE = 72
const LEFT_EXIT_FADE_ZONE = 400
const LEFT_EXIT_FADE_ZONE_COARSE = 160
const WHEEL_SPEED = 1.4
const WHEEL_VELOCITY_FRAME_MS = 16.67
const WHEEL_MOMENTUM_BLEND = 0.65
const DRAG_SPEED = 1
const TOUCH_DRAG_SPEED = 1.15
const GESTURE_LOCK_PX = 8
const NAV_HORIZONTAL_PADDING = 24
const MOMENTUM_FRICTION = 0.94
const MOMENTUM_MIN_VELOCITY = 0.025
const MOMENTUM_MIN_START = 0.08
const LIFELINE_DEFAULT_START_INSET = 24
const PAGE_MODE_VIEWPORT_COVERAGE = 0.5
const PAGE_MODE_SCROLL_SLOP = 4
const MODE_RESOLVE_STALE_MS = 250
const WHEEL_GESTURE_QUIET_MS = 120
const EMBED_BOUNDARY_QUIET_MS = 260
const EMBED_BOUNDARY_MAX_HOLD_MS = 900
const EMBED_INTRO_ARM_MARGIN = "0px 0px 200px 0px"
function normalizeWheelDelta(event: WheelEvent) {
let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY
if (event.deltaMode === 1) delta *= 16
if (event.deltaMode === 2) delta *= window.innerHeight
return delta
}
function isInteractiveTarget(target: EventTarget | null) {
return (
target instanceof Element &&
Boolean(target.closest("a, button, [data-lifeline-interactive]"))
)
}
function isEditableTarget(target: EventTarget | null) {
return (
target instanceof Element &&
Boolean(target.closest("input, textarea, select, [contenteditable]"))
)
}
function getViewportCoverage(element: HTMLElement) {
const rect = element.getBoundingClientRect()
const visibleX =
Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0)
const visibleY =
Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)
if (visibleX <= 0 || visibleY <= 0) return 0
return (visibleX * visibleY) / (window.innerWidth * window.innerHeight)
}
function hasReleasableScroll(section: HTMLElement) {
let node = section.parentElement
while (node) {
const { overflowY } = window.getComputedStyle(node)
if (overflowY === "hidden" || overflowY === "clip") return false
if (
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&
node.scrollHeight > node.clientHeight + PAGE_MODE_SCROLL_SLOP
) {
return true
}
node = node.parentElement
}
const doc = document.scrollingElement
return Boolean(doc && doc.scrollHeight > doc.clientHeight + PAGE_MODE_SCROLL_SLOP)
}
interface LifelineScrollOptions {
mode?: LifelineMode
isCoarsePointer?: boolean
introLocked?: boolean
introAnimating?: boolean
introSkipped?: boolean
introRailMs?: number
introGetTrackProgress?: (elapsedMs: number) => number
onIntroSettleComplete?: () => void
onIntroScrollStart?: () => void
}
export function useLifelineScroll(
markerCount: number,
options: LifelineScrollOptions = {},
) {
const sectionRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const labelsRef = useRef<HTMLDivElement>(null)
const markerRefs = useRef<(HTMLDivElement | null)[]>([])
const maxTranslate = useRef(0)
const startInset = useRef(LIFELINE_DEFAULT_START_INSET)
const endInset = useRef(0)
const translatePx = useRef(0)
const initialized = useRef(false)
const dragging = useRef(false)
const gestureAxis = useRef<"x" | "y" | null>(null)
const gestureStart = useRef({ x: 0, y: 0 })
const dragOrigin = useRef({ x: 0, translate: 0 })
const dragVelocity = useRef(0)
const lastPointerSample = useRef({ x: 0, y: 0, t: 0 })
const expanding = useRef(false)
const expandHost = useRef<Element | null>(null)
const expandVelocityY = useRef(0)
const lastPointerY = useRef(0)
const activePointerId = useRef<number | null>(null)
const isCoarsePointerRef = useRef(options.isCoarsePointer ?? false)
const momentumId = useRef(0)
const settleId = useRef(0)
const introLockedRef = useRef(options.introLocked ?? false)
const introAnimatingRef = useRef(options.introAnimating ?? false)
const introSkippedRef = useRef(options.introSkipped ?? false)
const onIntroSettleCompleteRef = useRef(options.onIntroSettleComplete)
const onIntroScrollStartRef = useRef(options.onIntroScrollStart)
const settlingRef = useRef(false)
const introWasAnimatingRef = useRef(false)
const introScrollId = useRef(0)
const introScrollStart = useRef(0)
const introStartedRef = useRef(false)
const introGetTrackProgressRef = useRef(options.introGetTrackProgress)
const applyTranslateRef = useRef<(value: number) => void>(() => {})
const scheduleMeasureRef = useRef<() => void>(() => {})
const modeRef = useRef<LifelineMode>(options.mode ?? "auto")
const isEmbedRef = useRef(false)
const modeResolvedAt = useRef(0)
const prefersReducedMotionRef = useRef(false)
const wheelStreamAt = useRef(0)
const wheelGapMs = useRef(Number.POSITIVE_INFINITY)
const gestureStartedHere = useRef(false)
const gestureReleased = useRef(false)
const boundaryHitAt = useRef(0)
const [isLayoutReady, setIsLayoutReady] = useState(false)
const [isEmbed, setIsEmbed] = useState(false)
const [introArmed, setIntroArmed] = useState(false)
modeRef.current = options.mode ?? "auto"
isCoarsePointerRef.current = options.isCoarsePointer ?? false
introLockedRef.current = options.introLocked ?? false
introAnimatingRef.current = options.introAnimating ?? false
introSkippedRef.current = options.introSkipped ?? false
onIntroSettleCompleteRef.current = options.onIntroSettleComplete
onIntroScrollStartRef.current = options.onIntroScrollStart
introGetTrackProgressRef.current = options.introGetTrackProgress
const setMarkerRef = useCallback(
(index: number, node: HTMLDivElement | null) => {
markerRefs.current[index] = node
if (index === markerCount - 1 && node) {
scheduleMeasureRef.current()
}
},
[markerCount],
)
const getLabelStageLeft = useCallback((translate: number) => {
const section = sectionRef.current
if (!section) return { isSticky: false, labelLeft: 0 }
const naturalLeft = startInset.current - translate
const isSticky = naturalLeft <= LIFELINE_STICKY_LEFT
return {
isSticky,
labelLeft: isSticky ? LIFELINE_STICKY_LEFT : naturalLeft,
}
}, [])
const applyLabelSticky = useCallback(
(translate: number) => {
const labels = labelsRef.current
const { isSticky, labelLeft } = getLabelStageLeft(translate)
if (!labels) return { isSticky, labelLeft }
if (isSticky) {
const labelExtra =
LIFELINE_STICKY_LEFT -
snapToDevicePixel(startInset.current - translate)
labels.style.transform = `translate3d(${labelExtra}px, 0, 0)`
labels.classList.add("is-pinned")
} else {
labels.style.transform = ""
labels.classList.remove("is-pinned")
}
return { isSticky, labelLeft }
},
[getLabelStageLeft],
)
const isScrollLocked = useCallback(() => {
return (
(introLockedRef.current && introStartedRef.current) ||
settlingRef.current
)
}, [])
const updateFades = useCallback(() => {
if (settlingRef.current) return
const section = sectionRef.current
if (!section) return
const stageRect = section.getBoundingClientRect()
const isCoarse = isCoarsePointerRef.current
const fadeZone = isCoarse ? FADE_ZONE_COARSE : FADE_ZONE
const leftFadeZone = isCoarse
? LEFT_EXIT_FADE_ZONE_COARSE
: LEFT_EXIT_FADE_ZONE
markerRefs.current.forEach((marker) => {
if (!marker) return
const rect = marker.getBoundingClientRect()
const markerLeft = rect.left - stageRect.left
const center = markerLeft + rect.width / 2
let opacity = 1
const naturalLeft = markerLeft + translatePx.current
const restLeft = Math.min(naturalLeft, leftFadeZone)
if (markerLeft < restLeft) {
opacity = markerLeft <= 0 ? 0 : markerLeft / restLeft
}
if (center > stageRect.width - fadeZone) {
opacity = Math.min(opacity, (stageRect.width - center) / fadeZone)
}
if (isCoarse) {
const readableLeft = LIFELINE_STICKY_SHIELD_WIDTH
const readableRight = stageRect.width - 12
const markerRight = rect.right - stageRect.left
const visibleWidth =
Math.min(markerRight, readableRight) - Math.max(markerLeft, readableLeft)
const visibility = rect.width > 0 ? visibleWidth / rect.width : 0
if (visibility >= 0.5) {
opacity = 1
}
}
marker.style.opacity = String(clamp(opacity, 0, 1))
})
}, [])
const applyTranslate = useCallback(
(value: number) => {
const max = maxTranslate.current
const next = clamp(value, 0, max)
translatePx.current = next
if (trackRef.current) {
trackRef.current.style.transform = `translate3d(${snapToDevicePixel(
startInset.current - next,
)}px, 0, 0)`
}
applyLabelSticky(next)
updateFades()
},
[applyLabelSticky, updateFades],
)
applyTranslateRef.current = applyTranslate
const resolveMode = useCallback((force = false) => {
const section = sectionRef.current
if (!section) return isEmbedRef.current
const mode = modeRef.current
if (mode !== "auto") {
const embed = mode === "embed"
if (embed !== isEmbedRef.current) {
isEmbedRef.current = embed
setIsEmbed(embed)
}
return embed
}
const now = performance.now()
if (!force && now - modeResolvedAt.current < MODE_RESOLVE_STALE_MS) {
return isEmbedRef.current
}
modeResolvedAt.current = now
const isPage =
getViewportCoverage(section) >= PAGE_MODE_VIEWPORT_COVERAGE &&
!hasReleasableScroll(section)
if (isPage === !isEmbedRef.current) return isEmbedRef.current
isEmbedRef.current = !isPage
setIsEmbed(!isPage)
return isEmbedRef.current
}, [])
const measureLayout = useCallback(() => {
const track = trackRef.current
const section = sectionRef.current
if (!track || !section) return 0
const embed = resolveMode(true)
const stageRect = section.getBoundingClientRect()
const navLogo = document.querySelector("[data-site-nav-logo]")
const navInner = document.querySelector("[data-site-nav-inner]")
const logoLeft = navLogo
? navLogo.getBoundingClientRect().left - stageRect.left
: null
const navRight = navInner
? navInner.getBoundingClientRect().right -
stageRect.left -
NAV_HORIZONTAL_PADDING
: null
const followChrome =
!embed ||
(logoLeft !== null &&
navRight !== null &&
logoLeft >= 0 &&
navRight <= stageRect.width &&
navRight > logoLeft)
if (followChrome && logoLeft !== null) {
startInset.current = logoLeft
} else {
startInset.current = LIFELINE_DEFAULT_START_INSET
}
if (followChrome && navRight !== null) {
endInset.current = navRight
} else {
endInset.current = stageRect.width - NAV_HORIZONTAL_PADDING
}
const lastMarker = markerRefs.current[markerCount - 1]
const lastMarkerRight = lastMarker
? LIFELINE_STICKY_SHIELD_WIDTH +
lastMarker.offsetLeft +
lastMarker.offsetWidth
: track.scrollWidth
const max = Math.max(
0,
startInset.current + lastMarkerRight - endInset.current,
)
maxTranslate.current = max
return max
}, [markerCount, resolveMode])
useLayoutEffect(() => {
markerRefs.current.length = markerCount
}, [markerCount])
useLayoutEffect(() => {
const max = measureLayout()
if (!initialized.current) {
translatePx.current = introSkippedRef.current ? max : 0
initialized.current = true
}
applyTranslate(translatePx.current)
setIsLayoutReady(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!isEmbed) return
const section = sectionRef.current
if (!section || introArmed) return
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return
setIntroArmed(true)
observer.disconnect()
},
{ rootMargin: EMBED_INTRO_ARM_MARGIN },
)
observer.observe(section)
return () => observer.disconnect()
}, [isEmbed, introArmed])
useEffect(() => {
if (!isLayoutReady) return
if (isEmbed && !introArmed) return
if (options.introSkipped || !options.introAnimating) {
cancelAnimationFrame(introScrollId.current)
introScrollId.current = 0
introStartedRef.current = false
return
}
introWasAnimatingRef.current = true
const railMs = options.introRailMs ?? 3200
const step = (now: number) => {
const max = maxTranslate.current
if (max <= 0) {
if (!introStartedRef.current) {
introStartedRef.current = true
introScrollStart.current = now
onIntroScrollStartRef.current?.()
}
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
introScrollId.current = 0
return
}
if (!introStartedRef.current) {
introStartedRef.current = true
introScrollStart.current = now
onIntroScrollStartRef.current?.()
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "0")
applyTranslateRef.current(0)
}
const elapsed = now - introScrollStart.current
const progress = introGetTrackProgressRef.current
? clamp(introGetTrackProgressRef.current(elapsed), 0, 1)
: clamp(elapsed / railMs, 0, 1)
sectionRef.current?.style.setProperty(
"--lifeline-intro-progress",
String(progress),
)
applyTranslateRef.current(progress * max)
if (progress < 1) {
introScrollId.current = requestAnimationFrame(step)
return
}
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
applyTranslateRef.current(max)
introScrollId.current = 0
}
introScrollId.current = requestAnimationFrame(step)
return () => {
cancelAnimationFrame(introScrollId.current)
introScrollId.current = 0
introStartedRef.current = false
}
}, [
introArmed,
isEmbed,
isLayoutReady,
options.introAnimating,
options.introRailMs,
options.introSkipped,
])
useEffect(() => {
if (options.introSkipped) return
if (options.introAnimating) return
if (!introWasAnimatingRef.current) return
introWasAnimatingRef.current = false
sectionRef.current?.style.removeProperty("--lifeline-intro-progress")
markerRefs.current.forEach((marker) => {
if (marker) marker.style.opacity = ""
})
updateFades()
onIntroSettleCompleteRef.current?.()
}, [
options.introAnimating,
options.introSkipped,
updateFades,
])
useEffect(() => {
return () => {
initialized.current = false
}
}, [])
useEffect(() => {
const section = sectionRef.current
if (!section) return
let frameId = 0
let resizeObserver: ResizeObserver | null = null
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
const onMotionChange = () => {
prefersReducedMotionRef.current = motionQuery.matches
}
prefersReducedMotionRef.current = motionQuery.matches
motionQuery.addEventListener("change", onMotionChange)
const stopMomentum = () => {
cancelAnimationFrame(momentumId.current)
momentumId.current = 0
}
const startMomentum = () => {
if (Math.abs(dragVelocity.current) < MOMENTUM_MIN_START) return
stopMomentum()
let lastFrameTime = performance.now()
const step = (now: number) => {
const dt = Math.min(now - lastFrameTime, 32)
lastFrameTime = now
const velocity = dragVelocity.current
if (Math.abs(velocity) < MOMENTUM_MIN_VELOCITY) {
dragVelocity.current = 0
momentumId.current = 0
return
}
const max = maxTranslate.current
const next = clamp(translatePx.current + velocity * dt, 0, max)
if (next !== translatePx.current) {
applyTranslate(next)
}
if (next <= 0 || next >= max) {
if (isEmbedRef.current && boundaryHitAt.current === 0) {
boundaryHitAt.current = performance.now()
}
dragVelocity.current = 0
momentumId.current = 0
return
}
dragVelocity.current =
velocity * Math.pow(MOMENTUM_FRICTION, dt / 16.67)
momentumId.current = requestAnimationFrame(step)
}
momentumId.current = requestAnimationFrame(step)
}
const measure = () => {
const max = measureLayout()
translatePx.current = clamp(translatePx.current, 0, max)
if (!(introAnimatingRef.current && introStartedRef.current)) {
applyTranslate(translatePx.current)
}
setIsLayoutReady(true)
}
const scheduleMeasure = () => {
cancelAnimationFrame(frameId)
frameId = requestAnimationFrame(measure)
}
scheduleMeasureRef.current = scheduleMeasure
scheduleMeasure()
frameId = requestAnimationFrame(() => {
measure()
requestAnimationFrame(measure)
})
resizeObserver = new ResizeObserver(scheduleMeasure)
if (trackRef.current) resizeObserver.observe(trackRef.current)
window.addEventListener("resize", scheduleMeasure)
const scrub = (movement: number, target: number) => {
applyTranslate(target)
const impulse = (movement / WHEEL_VELOCITY_FRAME_MS) * 0.35
dragVelocity.current =
dragVelocity.current * (1 - WHEEL_MOMENTUM_BLEND) +
impulse * WHEEL_MOMENTUM_BLEND
if (prefersReducedMotionRef.current) return
if (momentumId.current === 0) {
startMomentum()
}
}
const release = () => {
gestureReleased.current = true
stopMomentum()
dragVelocity.current = 0
}
const onWheelStream = (event: WheelEvent) => {
const now = performance.now()
wheelGapMs.current = now - wheelStreamAt.current
wheelStreamAt.current = now
if (wheelGapMs.current <= WHEEL_GESTURE_QUIET_MS) return
gestureStartedHere.current =
event.target instanceof Node && section.contains(event.target)
gestureReleased.current = false
boundaryHitAt.current = 0
}
let expandWheelEndTimer = 0
let lastExpandWheelAt = 0
let expandWheelHost: Element | null = null
const onWheel = (event: WheelEvent) => {
if (isScrollLocked()) return
if (expanding.current || dragging.current) return
const horizontalIntent = Math.abs(event.deltaX) > Math.abs(event.deltaY)
const wheelExpandHost = findLifelineExpandHost(event.target)
if (wheelExpandHost && !horizontalIntent) {
event.preventDefault()
const now = performance.now()
if (
expandWheelHost !== wheelExpandHost ||
now - lastExpandWheelAt > WHEEL_GESTURE_QUIET_MS
) {
emitLifelineExpandGesture(wheelExpandHost, {
phase: "start",
deltaY: 0,
velocityY: 0,
})
expandWheelHost = wheelExpandHost
}
lastExpandWheelAt = now
emitLifelineExpandGesture(wheelExpandHost, {
phase: "move",
deltaY: event.deltaY,
velocityY: 0,
})
window.clearTimeout(expandWheelEndTimer)
expandWheelEndTimer = window.setTimeout(() => {
emitLifelineExpandGesture(wheelExpandHost, {
phase: "end",
deltaY: 0,
velocityY: 0,
})
expandWheelHost = null
}, WHEEL_GESTURE_QUIET_MS)
return
}
if (maxTranslate.current <= 0) {
scheduleMeasure()
return
}
const delta = normalizeWheelDelta(event)
const movement = (horizontalIntent ? delta : -delta) * WHEEL_SPEED
if (!resolveMode()) {
event.preventDefault()
scrub(movement, translatePx.current + movement)
return
}
if (!horizontalIntent) {
if (!gestureStartedHere.current) return
if (gestureReleased.current) return
}
const target = clamp(
translatePx.current + movement,
0,
maxTranslate.current,
)
if (target === translatePx.current && !horizontalIntent) {
const now = performance.now()
if (boundaryHitAt.current === 0) boundaryHitAt.current = now
if (
wheelGapMs.current < EMBED_BOUNDARY_QUIET_MS &&
now - boundaryHitAt.current < EMBED_BOUNDARY_MAX_HOLD_MS
) {
event.preventDefault()
stopMomentum()
dragVelocity.current = 0
return
}
release()
return
}
boundaryHitAt.current = 0
event.preventDefault()
scrub(movement, target)
}
const onSectionScroll = () => {
if (section.scrollLeft !== 0) section.scrollLeft = 0
if (section.scrollTop !== 0) section.scrollTop = 0
}
const capturePointer = (event: PointerEvent) => {
if (section.setPointerCapture) {
section.setPointerCapture(event.pointerId)
}
section.style.cursor = "grabbing"
section.style.touchAction = "none"
}
const beginDrag = (event: PointerEvent) => {
window.clearTimeout(expandWheelEndTimer)
expandWheelHost = null
stopMomentum()
dragVelocity.current = 0
dragging.current = true
activePointerId.current = event.pointerId
dragOrigin.current = { x: event.clientX, translate: translatePx.current }
lastPointerSample.current = {
x: event.clientX,
y: event.clientY,
t: performance.now(),
}
capturePointer(event)
}
const beginExpand = (event: PointerEvent) => {
window.clearTimeout(expandWheelEndTimer)
expandWheelHost = null
expanding.current = true
activePointerId.current = event.pointerId
lastPointerY.current = event.clientY
expandVelocityY.current = 0
lastPointerSample.current = {
x: event.clientX,
y: event.clientY,
t: performance.now(),
}
capturePointer(event)
emitLifelineExpandGesture(expandHost.current, {
phase: "start",
deltaY: 0,
velocityY: 0,
})
}
const onPointerDown = (event: PointerEvent) => {
if (isScrollLocked()) return
if (isInteractiveTarget(event.target)) return
if (activePointerId.current !== null) return
expandHost.current = findLifelineExpandHost(event.target)
if (maxTranslate.current <= 0 && !expandHost.current) return
gestureAxis.current = null
expanding.current = false
gestureStart.current = { x: event.clientX, y: event.clientY }
activePointerId.current = event.pointerId
}
const onPointerMove = (event: PointerEvent) => {
if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {
return
}
if (!dragging.current && !expanding.current) {
if (activePointerId.current === null) return
const deltaX = event.clientX - gestureStart.current.x
const deltaY = event.clientY - gestureStart.current.y
if (gestureAxis.current === null) {
if (
Math.abs(deltaX) < GESTURE_LOCK_PX &&
Math.abs(deltaY) < GESTURE_LOCK_PX
) {
return
}
gestureAxis.current =
Math.abs(deltaX) >= Math.abs(deltaY) ? "x" : "y"
if (gestureAxis.current === "x") {
if (maxTranslate.current <= 0) {
activePointerId.current = null
return
}
beginDrag(event)
} else if (expandHost.current) {
beginExpand(event)
} else {
activePointerId.current = null
return
}
}
}
if (expanding.current) {
event.preventDefault()
const now = performance.now()
const sample = lastPointerSample.current
const elapsed = now - sample.t
const deltaY = event.clientY - lastPointerY.current
if (elapsed > 0 && elapsed < 80) {
expandVelocityY.current =
((event.clientY - sample.y) / elapsed) * 0.65 +
expandVelocityY.current * 0.35
}
lastPointerY.current = event.clientY
lastPointerSample.current = { x: event.clientX, y: event.clientY, t: now }
emitLifelineExpandGesture(expandHost.current, {
phase: "move",
deltaY,
velocityY: expandVelocityY.current,
})
return
}
if (!dragging.current) return
if (event.pointerType === "touch") {
event.preventDefault()
}
const now = performance.now()
const sample = lastPointerSample.current
const elapsed = now - sample.t
const dragSpeed = event.pointerType === "touch" ? TOUCH_DRAG_SPEED : DRAG_SPEED
if (elapsed > 0 && elapsed < 80) {
const instantVelocity =
(-(event.clientX - sample.x) / elapsed) * dragSpeed
dragVelocity.current =
instantVelocity * 0.65 + dragVelocity.current * 0.35
}
lastPointerSample.current = { x: event.clientX, y: event.clientY, t: now }
const panDeltaX = event.clientX - dragOrigin.current.x
applyTranslate(dragOrigin.current.translate - panDeltaX * dragSpeed)
}
const endDrag = (event: PointerEvent) => {
if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {
return
}
const wasDragging = dragging.current
const wasExpanding = expanding.current
dragging.current = false
expanding.current = false
gestureAxis.current = null
activePointerId.current = null
if (section.hasPointerCapture(event.pointerId)) {
section.releasePointerCapture(event.pointerId)
}
section.style.cursor = ""
section.style.touchAction = ""
if (wasExpanding) {
emitLifelineExpandGesture(expandHost.current, {
phase: "end",
deltaY: 0,
velocityY: expandVelocityY.current,
})
expandHost.current = null
expandVelocityY.current = 0
return
}
expandHost.current = null
if (wasDragging) {
startMomentum()
}
}
const ownsKeyboard = () => {
const active = document.activeElement
if (active && section.contains(active)) return true
return !resolveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (maxTranslate.current <= 0) return
if (isScrollLocked()) return
if (isEditableTarget(event.target)) return
if (!ownsKeyboard()) return
stopMomentum()
dragVelocity.current = 0
if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault()
applyTranslate(translatePx.current - maxTranslate.current * 0.05)
}
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault()
applyTranslate(translatePx.current + maxTranslate.current * 0.05)
}
}
window.addEventListener("wheel", onWheelStream, {
passive: true,
capture: true,
})
section.addEventListener("scroll", onSectionScroll, { passive: true })
section.addEventListener("wheel", onWheel, { passive: false })
section.addEventListener("pointerdown", onPointerDown)
section.addEventListener("pointermove", onPointerMove, { passive: false })
section.addEventListener("pointerup", endDrag)
section.addEventListener("pointercancel", endDrag)
window.addEventListener("keydown", onKeyDown)
// Alias the ref (not its value): cleanup must cancel the latest settle frame.
const settle = settleId
return () => {
cancelAnimationFrame(frameId)
stopMomentum()
cancelAnimationFrame(settle.current)
settlingRef.current = false
resizeObserver?.disconnect()
motionQuery.removeEventListener("change", onMotionChange)
window.removeEventListener("resize", scheduleMeasure)
window.removeEventListener("wheel", onWheelStream, { capture: true })
section.removeEventListener("scroll", onSectionScroll)
section.removeEventListener("wheel", onWheel)
section.removeEventListener("pointerdown", onPointerDown)
section.removeEventListener("pointermove", onPointerMove)
section.removeEventListener("pointerup", endDrag)
section.removeEventListener("pointercancel", endDrag)
window.removeEventListener("keydown", onKeyDown)
dragging.current = false
expanding.current = false
expandHost.current = null
gestureAxis.current = null
activePointerId.current = null
gestureStartedHere.current = false
gestureReleased.current = false
boundaryHitAt.current = 0
wheelGapMs.current = Number.POSITIVE_INFINITY
window.clearTimeout(expandWheelEndTimer)
section.style.cursor = ""
section.style.touchAction = ""
}
}, [applyTranslate, isScrollLocked, markerCount, measureLayout, resolveMode])
return {
sectionRef,
trackRef,
labelsRef,
setMarkerRef,
isLayoutReady,
isEmbed,
introArmed,
}
}
registry/new-york/templates/portfolio-v3/components/lifeline/use-lifeline-vertical-scroll.ts
"use client"
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import { clamp } from "./lifeline-utils"
function getScrollParent(element: HTMLElement | null): HTMLElement | null {
let node = element?.parentElement ?? null
while (node) {
const { overflowY } = window.getComputedStyle(node)
if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
return node
}
node = node.parentElement
}
return (document.scrollingElement as HTMLElement | null) ?? null
}
interface LifelineVerticalScrollOptions {
isEmbed?: boolean
introLocked?: boolean
introAnimating?: boolean
introSkipped?: boolean
introRailMs?: number
introGetTrackProgress?: (elapsedMs: number) => number
onIntroSettleComplete?: () => void
onIntroScrollStart?: () => void
}
export function useLifelineVerticalScroll(
markerCount: number,
options: LifelineVerticalScrollOptions = {},
) {
const sectionRef = useRef<HTMLElement>(null)
const entryRefs = useRef<(HTMLLIElement | null)[]>([])
const maxScrollRef = useRef(0)
const scrollParentRef = useRef<HTMLElement | null>(null)
const initialized = useRef(false)
const introLockedRef = useRef(options.introLocked ?? false)
const introAnimatingRef = useRef(options.introAnimating ?? false)
const introSkippedRef = useRef(options.introSkipped ?? false)
const isEmbedRef = useRef(options.isEmbed ?? false)
const onIntroSettleCompleteRef = useRef(options.onIntroSettleComplete)
const onIntroScrollStartRef = useRef(options.onIntroScrollStart)
const introGetTrackProgressRef = useRef(options.introGetTrackProgress)
const introStartedRef = useRef(false)
const introScrollId = useRef(0)
const introScrollStart = useRef(0)
const introWasAnimatingRef = useRef(false)
const scheduleMeasureRef = useRef<() => void>(() => {})
const [isLayoutReady, setIsLayoutReady] = useState(false)
introLockedRef.current = options.introLocked ?? false
introAnimatingRef.current = options.introAnimating ?? false
introSkippedRef.current = options.introSkipped ?? false
isEmbedRef.current = options.isEmbed ?? false
onIntroSettleCompleteRef.current = options.onIntroSettleComplete
onIntroScrollStartRef.current = options.onIntroScrollStart
introGetTrackProgressRef.current = options.introGetTrackProgress
const setEntryRef = useCallback((index: number, node: HTMLLIElement | null) => {
entryRefs.current[index] = node
if (index === markerCount - 1 && node) {
scheduleMeasureRef.current()
}
}, [markerCount])
const applyScroll = useCallback((value: number) => {
const scrollParent = scrollParentRef.current
if (!scrollParent) return
scrollParent.scrollTop = clamp(value, 0, maxScrollRef.current)
}, [])
const measureLayout = useCallback(() => {
const section = sectionRef.current
if (!section) return 0
const scrollParent = getScrollParent(section)
scrollParentRef.current = scrollParent
if (!scrollParent) return 0
const heights = entryRefs.current.map((entry) => entry?.offsetHeight ?? 0)
if (heights.length < markerCount || heights.some((height) => height <= 0)) {
return 0
}
const max = Math.max(0, scrollParent.scrollHeight - scrollParent.clientHeight)
maxScrollRef.current = max
return max
}, [markerCount])
useLayoutEffect(() => {
entryRefs.current.length = markerCount
}, [markerCount])
useLayoutEffect(() => {
const max = measureLayout()
const scrollParent = scrollParentRef.current
if (!scrollParent) return
if (!initialized.current) {
scrollParent.scrollTop =
introSkippedRef.current && !isEmbedRef.current ? max : 0
initialized.current = true
}
setIsLayoutReady(entryRefs.current.every((entry) => Boolean(entry)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!isLayoutReady) return
if (options.introSkipped || !options.introAnimating) {
cancelAnimationFrame(introScrollId.current)
introScrollId.current = 0
introStartedRef.current = false
return
}
introWasAnimatingRef.current = true
const railMs = options.introRailMs ?? 3200
const step = (now: number) => {
const max = maxScrollRef.current
if (!introStartedRef.current) {
introStartedRef.current = true
introScrollStart.current = now
onIntroScrollStartRef.current?.()
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "0")
if (max > 0) applyScroll(0)
}
const elapsed = now - introScrollStart.current
const progress = introGetTrackProgressRef.current
? clamp(introGetTrackProgressRef.current(elapsed), 0, 1)
: clamp(elapsed / railMs, 0, 1)
sectionRef.current?.style.setProperty(
"--lifeline-intro-progress",
String(progress),
)
if (max > 0) {
applyScroll(progress * max)
}
if (progress < 1) {
introScrollId.current = requestAnimationFrame(step)
return
}
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
if (max > 0) applyScroll(max)
introScrollId.current = 0
}
introScrollId.current = requestAnimationFrame(step)
return () => {
cancelAnimationFrame(introScrollId.current)
introScrollId.current = 0
introStartedRef.current = false
}
}, [
applyScroll,
isLayoutReady,
options.introAnimating,
options.introRailMs,
options.introSkipped,
])
useEffect(() => {
if (options.introSkipped) return
if (options.introAnimating) return
if (!introWasAnimatingRef.current) return
introWasAnimatingRef.current = false
sectionRef.current?.style.removeProperty("--lifeline-intro-progress")
onIntroSettleCompleteRef.current?.()
}, [options.introAnimating, options.introSkipped])
useEffect(() => {
const section = sectionRef.current
if (!section) return
let frameId = 0
let resizeObserver: ResizeObserver | null = null
const measure = () => {
measureLayout()
const scrollParent = scrollParentRef.current
if (!scrollParent) return
if (!(introAnimatingRef.current && introStartedRef.current)) {
scrollParent.scrollTop = clamp(
scrollParent.scrollTop,
0,
maxScrollRef.current,
)
}
setIsLayoutReady(
entryRefs.current.length === markerCount &&
entryRefs.current.every((entry) => Boolean(entry)),
)
}
const scheduleMeasure = () => {
cancelAnimationFrame(frameId)
frameId = requestAnimationFrame(measure)
}
scheduleMeasureRef.current = scheduleMeasure
scheduleMeasure()
frameId = requestAnimationFrame(() => {
measure()
requestAnimationFrame(measure)
})
resizeObserver = new ResizeObserver(scheduleMeasure)
resizeObserver.observe(section)
window.addEventListener("resize", scheduleMeasure)
const isScrollLocked = () =>
introLockedRef.current && introStartedRef.current
const preventScroll = (event: Event) => {
if (!isScrollLocked()) return
event.preventDefault()
}
const scrollParent = getScrollParent(section)
scrollParentRef.current = scrollParent
scrollParent?.addEventListener("wheel", preventScroll, { passive: false })
scrollParent?.addEventListener("touchmove", preventScroll, { passive: false })
return () => {
cancelAnimationFrame(frameId)
resizeObserver?.disconnect()
window.removeEventListener("resize", scheduleMeasure)
scrollParent?.removeEventListener("wheel", preventScroll)
scrollParent?.removeEventListener("touchmove", preventScroll)
initialized.current = false
}
}, [markerCount, measureLayout])
return {
sectionRef,
setEntryRef,
isLayoutReady,
}
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-intro-timing.ts
import { clamp } from "./lifeline-utils"
export const LIFELINE_SLOW_YEARS = 5
export const LIFELINE_SLOW_TIME_RATIO = 0.38
export const LIFELINE_SLOW_MARKER_FADE_MS = 720
export const LIFELINE_FAST_MARKER_FADE_MS = 280
export const LIFELINE_EASE_POWER = 0
function smoothstep(value: number) {
const t = clamp(value, 0, 1)
return t * t * (3 - 2 * t)
}
export function getSlowTrackWidth(widths: number[]) {
return widths
.slice(0, LIFELINE_SLOW_YEARS)
.reduce((sum, width) => sum + width, 0)
}
export function getSlowTrackPortion(widths: number[]) {
const total = widths.reduce((sum, width) => sum + width, 0)
if (total <= 0) return 0
return getSlowTrackWidth(widths) / total
}
function getEasePower(widths: number[]) {
if (LIFELINE_EASE_POWER > 0) return LIFELINE_EASE_POWER
const slowPortion = getSlowTrackPortion(widths)
const softenedPivot = smoothstep(LIFELINE_SLOW_TIME_RATIO)
if (slowPortion <= 0 || softenedPivot <= 0 || softenedPivot >= 1) {
return 2.6
}
return Math.log(slowPortion) / Math.log(softenedPivot)
}
function softenedTime(elapsedMs: number, railMs: number) {
return smoothstep(elapsedMs / railMs)
}
export function trackProgressAtTime(
elapsedMs: number,
widths: number[],
railMs: number,
) {
if (railMs <= 0) return 0
const t = softenedTime(elapsedMs, railMs)
const power = getEasePower(widths)
return clamp(Math.pow(t, power), 0, 1)
}
export function timeAtTrackProgress(
progress: number,
widths: number[],
railMs: number,
) {
const clamped = clamp(progress, 0, 1)
if (clamped <= 0) return 0
if (clamped >= 1) return railMs
const power = getEasePower(widths)
const softened = Math.pow(clamped, 1 / power)
return invertSmoothstep(softened) * railMs
}
function invertSmoothstep(value: number) {
const target = clamp(value, 0, 1)
let lo = 0
let hi = 1
for (let i = 0; i < 20; i++) {
const mid = (lo + hi) / 2
if (smoothstep(mid) < target) lo = mid
else hi = mid
}
return (lo + hi) / 2
}
export function isSlowIntroYear(index: number) {
return index < LIFELINE_SLOW_YEARS
}
export function getTransitionMarkerFadeDuration(index: number) {
if (index < LIFELINE_SLOW_YEARS) return LIFELINE_SLOW_MARKER_FADE_MS
const rampYears = 3
const rampIndex = index - LIFELINE_SLOW_YEARS
if (rampIndex >= rampYears) return LIFELINE_FAST_MARKER_FADE_MS
const t = (rampIndex + 1) / rampYears
const blend = smoothstep(t)
return Math.round(
LIFELINE_SLOW_MARKER_FADE_MS +
(LIFELINE_FAST_MARKER_FADE_MS - LIFELINE_SLOW_MARKER_FADE_MS) * blend,
)
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-layout.ts
export const LIFELINE_MOBILE_BREAKPOINT = 768
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-utils.ts
import type { LifelineMarker } from "./types"
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
export function snapToDevicePixel(value: number) {
const dpr = window.devicePixelRatio || 1
return Math.round(value * dpr) / dpr
}
export const LIFELINE_EXPAND_SELECTOR = "[data-lifeline-expand]"
export const LIFELINE_EXPAND_GESTURE = "lifelineexpandgesture"
export interface LifelineExpandGestureDetail {
phase: "start" | "move" | "end"
deltaY: number
velocityY: number
}
export function emitLifelineExpandGesture(
host: Element | null,
detail: LifelineExpandGestureDetail,
) {
if (!host) return false
host.dispatchEvent(
new CustomEvent<LifelineExpandGestureDetail>(LIFELINE_EXPAND_GESTURE, {
detail,
}),
)
return true
}
export function dispatchLifelineExpandGesture(
target: EventTarget | null,
detail: LifelineExpandGestureDetail,
) {
return emitLifelineExpandGesture(findLifelineExpandHost(target), detail)
}
export function findLifelineExpandHost(target: EventTarget | null) {
return target instanceof Element
? target.closest(LIFELINE_EXPAND_SELECTOR)
: null
}
export function hasMarkerContent(marker: LifelineMarker) {
return (
marker.events.length > 0 ||
(marker.companies?.length ?? 0) > 0 ||
(marker.globalEvents?.length ?? 0) > 0
)
}
export function hasMarkerGlobalEvents(marker: LifelineMarker) {
return (marker.globalEvents?.length ?? 0) > 0
}
function markerInlineMediaCount(marker: LifelineMarker) {
return marker.events.filter(
(event) =>
typeof event === "object" &&
!Array.isArray(event) &&
Boolean(event.video || event.image),
).length
}
export function getMarkerHeight(marker: LifelineMarker, nextYear?: number) {
const hasContent = hasMarkerContent(marker)
const hasGlobalEvents = hasMarkerGlobalEvents(marker)
if (!hasContent) return 48
const globalEventsOnly =
hasGlobalEvents &&
marker.events.length === 0 &&
(marker.companies?.length ?? 0) === 0
let height = 96
if (marker.companies?.length) height += 28
height += marker.events.length * 44
height += markerInlineMediaCount(marker) * 150
if (globalEventsOnly) height += 88
else if (hasGlobalEvents) height += 108
if (!nextYear)
return Math.min(560, Math.max(globalEventsOnly ? 148 : 188, height))
const gap = Math.max(1, nextYear - marker.year)
height += Math.min(32, gap * 3)
return Math.min(560, Math.max(globalEventsOnly ? 148 : 188, height))
}
export function getMarkerWidth(marker: LifelineMarker, nextYear?: number) {
const hasContent = hasMarkerContent(marker)
const hasGlobalEvents = hasMarkerGlobalEvents(marker)
const hasInlineMedia = markerInlineMediaCount(marker) > 0
if (!nextYear) {
if (hasInlineMedia) return 300
return hasContent ? 360 : 80
}
if (!hasContent) return 80
const globalEventsOnly =
hasGlobalEvents &&
marker.events.length === 0 &&
(marker.companies?.length ?? 0) === 0
if (globalEventsOnly) return 220
const gap = Math.max(1, nextYear - marker.year)
const width = Math.min(420, Math.max(290, gap * 36))
return hasInlineMedia ? Math.max(width, 260) : width
}
registry/new-york/templates/portfolio-v3/components/lifeline/types.ts
import type { CompanyIconId } from "./company-icon"
export interface LifelineGlobalEvent {
name: string
/** Venue or city. Wraps onto the next line when it does not fit. */
place?: string
date?: string
/** Freeform subtitle if `place` / `date` are not split. */
role?: string
icon?: CompanyIconId
}
export interface LifelineCompany {
id: CompanyIconId
name: string
}
export type LifelineEventSegment =
| { type: "text"; value: string }
| { type: "link"; value: string; href: string }
export interface LifelineEventImage {
src: string
alt: string
}
export interface LifelineEventClip {
src: string
alt: string
}
export type LifelineEventCategory = "work" | "college" | "destination"
export interface LifelineEventObject {
text: string | LifelineEventSegment[]
image?: LifelineEventImage
video?: LifelineEventClip
category?: LifelineEventCategory
}
export type LifelineEvent =
| string
| LifelineEventSegment[]
| LifelineEventObject
export interface LifelineMarker {
id: string
year: number
age?: number | string
label?: string
events: LifelineEvent[]
badges?: { src: string; alt: string }[]
companies?: LifelineCompany[]
globalEvents?: LifelineGlobalEvent[]
}
export interface LifelineLegendItem {
type: "globalEvent" | LifelineEventCategory
label: string
}
export type LifelineMode = "auto" | "page" | "embed"
export interface LifelineProps {
markers: LifelineMarker[]
birthYear: number
className?: string
title?: string
mode?: LifelineMode
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline.css
@keyframes lifeline-rail-draw {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes lifeline-marker-in {
from {
opacity: 0;
transform: translate3d(0, 6px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes lifeline-labels-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.lifeline-rail-intro {
transform: scaleX(var(--lifeline-intro-progress, 0));
transform-origin: left center;
}
.lifeline-rail-intro-vertical {
transform: scaleY(var(--lifeline-intro-progress, 0));
transform-origin: top center;
}
.lifeline-marker-intro {
opacity: 0;
animation: lifeline-marker-in var(--lifeline-marker-fade-ms, 420ms)
cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.lifeline-labels-intro {
opacity: 0;
animation: lifeline-labels-in var(--lifeline-labels-ms, 600ms)
cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.lifeline-labels.is-pinned {
z-index: 20;
}
/* Fade the shield's trailing edge so horizontally scrolled markers dissolve
behind the pinned labels rather than clipping against a visible box edge. */
.lifeline-labels-shield {
-webkit-mask-image: linear-gradient(
to right,
#000 calc(100% - 18px),
transparent
);
mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
}
.lifeline-typeset {
font-family: var(
--lifeline-font,
var(--font-geist-sans),
ui-sans-serif,
system-ui,
sans-serif
);
}
@media (prefers-reduced-motion: reduce) {
.lifeline-marker-intro,
.lifeline-labels-intro {
animation: none;
opacity: 1;
}
.lifeline-rail-intro,
.lifeline-rail-intro-vertical {
transform: none;
}
}
registry/new-york/templates/portfolio-v3/components/theme-provider.tsx
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ReactNode } from "react"
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}
registry/new-york/templates/portfolio-v3/components/theme-switcher.tsx
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { useSyncExternalStore } from "react"
import { cn } from "@/lib/utils"
const emptySubscribe = () => () => {}
function useIsClient() {
return useSyncExternalStore(emptySubscribe, () => true, () => false)
}
export function ThemeSwitcher() {
const { setTheme, resolvedTheme } = useTheme()
const mounted = useIsClient()
if (!mounted) {
return <span className="inline-block h-4 w-4" aria-hidden="true" />
}
const isDark = resolvedTheme === "dark"
return (
<button
type="button"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
className={cn("inline-flex items-center p-0 text-muted-foreground transition-colors duration-300 hover:text-foreground",
)}
>
{isDark ? (
<Sun className="h-4 w-4" strokeWidth={1.75} />
) : (
<Moon className="h-4 w-4" strokeWidth={1.75} />
)}
</button>
)
}
registry/new-york/templates/portfolio-v3/components/ui/button.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
registry/new-york/templates/portfolio-v3/components/ui/svgs/cursorDark.tsx
import type { SVGProps } from "react"
const CursorDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09">
<path
fill="currentColor"
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
/>
</svg>
)
export { CursorDark }
registry/new-york/templates/portfolio-v3/components/ui/svgs/cursorLight.tsx
import type { SVGProps } from "react"
const CursorLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09">
<path
fill="currentColor"
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
/>
</svg>
)
export { CursorLight }
registry/new-york/templates/portfolio-v3/components/ui/svgs/nvidiaIconDark.tsx
import type { SVGProps } from "react"
const NvidiaIconDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlSpace="preserve" viewBox="35.188 31.512 351.46 258.785">
<path
fill="#fff"
d="M384.195 282.109c0 3.771-2.769 6.302-6.047 6.302v-.023c-3.371.023-6.089-2.508-6.089-6.278 0-3.769 2.718-6.293 6.089-6.293 3.279-.001 6.047 2.523 6.047 6.292zm2.453 0c0-5.175-4.02-8.179-8.5-8.179-4.511 0-8.531 3.004-8.531 8.179 0 5.172 4.021 8.188 8.531 8.188 4.481 0 8.5-3.016 8.5-8.188m-9.91.692h.91l2.109 3.703h2.316l-2.336-3.859c1.207-.086 2.2-.661 2.2-2.286 0-2.019-1.392-2.668-3.75-2.668h-3.411v8.813h1.961v-3.703m.001-1.492v-2.122h1.364c.742 0 1.753.06 1.753.965 0 .985-.523 1.157-1.398 1.157h-1.719M329.406 237.027l10.598 28.993H318.48l10.926-28.993zm-11.35-11.289-24.423 61.88h17.246l3.863-10.934h28.903l3.656 10.934h18.722l-24.605-61.888-23.362.008zm-49.033 61.903h17.497v-61.922l-17.5-.004.003 61.926zm-121.467-61.926-14.598 49.078-13.984-49.074-18.879-.004 19.972 61.926h25.207l20.133-61.926h-17.851zm70.725 13.484h7.52c10.91 0 17.966 4.898 17.966 17.609 0 12.714-7.056 17.613-17.966 17.613h-7.52v-35.222zm-17.35-13.484v61.926h28.366c15.113 0 20.048-2.512 25.384-8.148 3.769-3.957 6.207-12.641 6.207-22.134 0-8.707-2.063-16.468-5.66-21.304-6.481-8.649-15.817-10.34-29.75-10.34h-24.547zm-165.743-.086v62.012h17.645v-47.086l13.672.004c4.527 0 7.754 1.128 9.934 3.457 2.765 2.945 3.894 7.699 3.894 16.395v27.23h17.098v-34.262c0-24.453-15.586-27.75-30.836-27.75H35.188zm137.583.086.007 61.926h17.489v-61.926h-17.496z"
/>
<path
fill="#77B900"
d="M82.211 102.414s22.504-33.203 67.437-36.638V53.73c-49.769 3.997-92.867 46.149-92.867 46.149s24.41 70.565 92.867 77.026v-12.804c-50.237-6.32-67.437-61.687-67.437-61.687zm67.437 36.223v11.726c-37.968-6.769-48.507-46.237-48.507-46.237s18.23-20.195 48.507-23.47v12.867c-.023 0-.039-.007-.058-.007-15.891-1.907-28.305 12.938-28.305 12.938s6.958 24.991 28.363 32.183m0-107.125V53.73c1.461-.112 2.922-.207 4.391-.257 56.582-1.907 93.449 46.406 93.449 46.406s-42.343 51.488-86.457 51.488c-4.043 0-7.828-.375-11.383-1.005v13.739c3.04.386 6.192.613 9.481.613 41.051 0 70.738-20.965 99.484-45.778 4.766 3.817 24.278 13.103 28.289 17.168-27.332 22.883-91.031 41.329-127.144 41.329-3.481 0-6.824-.211-10.11-.528v19.306H305.68V31.512H149.648zm0 49.144V65.777c1.446-.101 2.903-.179 4.391-.226 40.688-1.278 67.382 34.965 67.382 34.965s-28.832 40.043-59.746 40.043c-4.449 0-8.438-.715-12.028-1.922V93.523c15.84 1.914 19.028 8.911 28.551 24.786l21.18-17.859s-15.461-20.277-41.524-20.277c-2.833-.001-5.544.198-8.206.483"
/>
</svg>
)
export { NvidiaIconDark }
registry/new-york/templates/portfolio-v3/components/ui/svgs/nvidiaIconLight.tsx
import type { SVGProps } from "react"
const NvidiaIconLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlSpace="preserve" viewBox="35.188 31.512 351.46 258.785">
<path d="M384.195 282.109c0 3.771-2.769 6.302-6.047 6.302v-.023c-3.371.023-6.089-2.508-6.089-6.278 0-3.769 2.718-6.293 6.089-6.293 3.279-.001 6.047 2.523 6.047 6.292zm2.453 0c0-5.175-4.02-8.179-8.5-8.179-4.511 0-8.531 3.004-8.531 8.179 0 5.172 4.021 8.188 8.531 8.188 4.481 0 8.5-3.016 8.5-8.188m-9.91.692h.91l2.109 3.703h2.316l-2.336-3.859c1.207-.086 2.2-.661 2.2-2.286 0-2.019-1.392-2.668-3.75-2.668h-3.411v8.813h1.961v-3.703m.001-1.492v-2.122h1.364c.742 0 1.753.06 1.753.965 0 .985-.523 1.157-1.398 1.157h-1.719M329.406 237.027l10.598 28.993H318.48l10.926-28.993zm-11.35-11.289-24.423 61.88h17.246l3.863-10.934h28.903l3.656 10.934h18.722l-24.605-61.888-23.362.008zm-49.033 61.903h17.497v-61.922l-17.5-.004.003 61.926zm-121.467-61.926-14.598 49.078-13.984-49.074-18.879-.004 19.972 61.926h25.207l20.133-61.926h-17.851zm70.725 13.484h7.52c10.91 0 17.966 4.898 17.966 17.609 0 12.714-7.056 17.613-17.966 17.613h-7.52v-35.222zm-17.35-13.484v61.926h28.366c15.113 0 20.048-2.512 25.384-8.148 3.769-3.957 6.207-12.641 6.207-22.134 0-8.707-2.063-16.468-5.66-21.304-6.481-8.649-15.817-10.34-29.75-10.34h-24.547zm-165.743-.086v62.012h17.645v-47.086l13.672.004c4.527 0 7.754 1.128 9.934 3.457 2.765 2.945 3.894 7.699 3.894 16.395v27.23h17.098v-34.262c0-24.453-15.586-27.75-30.836-27.75H35.188zm137.583.086.007 61.926h17.489v-61.926h-17.496z" />
<path
fill="#77B900"
d="M82.211 102.414s22.504-33.203 67.437-36.638V53.73c-49.769 3.997-92.867 46.149-92.867 46.149s24.41 70.565 92.867 77.026v-12.804c-50.237-6.32-67.437-61.687-67.437-61.687zm67.437 36.223v11.726c-37.968-6.769-48.507-46.237-48.507-46.237s18.23-20.195 48.507-23.47v12.867c-.023 0-.039-.007-.058-.007-15.891-1.907-28.305 12.938-28.305 12.938s6.958 24.991 28.363 32.183m0-107.125V53.73c1.461-.112 2.922-.207 4.391-.257 56.582-1.907 93.449 46.406 93.449 46.406s-42.343 51.488-86.457 51.488c-4.043 0-7.828-.375-11.383-1.005v13.739c3.04.386 6.192.613 9.481.613 41.051 0 70.738-20.965 99.484-45.778 4.766 3.817 24.278 13.103 28.289 17.168-27.332 22.883-91.031 41.329-127.144 41.329-3.481 0-6.824-.211-10.11-.528v19.306H305.68V31.512H149.648zm0 49.144V65.777c1.446-.101 2.903-.179 4.391-.226 40.688-1.278 67.382 34.965 67.382 34.965s-28.832 40.043-59.746 40.043c-4.449 0-8.438-.715-12.028-1.922V93.523c15.84 1.914 19.028 8.911 28.551 24.786l21.18-17.859s-15.461-20.277-41.524-20.277c-2.833-.001-5.544.198-8.206.483"
/>
</svg>
)
export { NvidiaIconLight }
registry/new-york/templates/portfolio-v3/components/ui/svgs/vercel.tsx
import type { SVGProps } from "react"
const Vercel = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 256 222" preserveAspectRatio="xMidYMid">
<path d="m128 0 128 221.705H0z" />
</svg>
)
export { Vercel }
registry/new-york/templates/portfolio-v3/components/ui/svgs/vercelDark.tsx
import type { SVGProps } from "react"
const VercelDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 256 222" preserveAspectRatio="xMidYMid">
<path fill="#fff" d="m128 0 128 221.705H0z" />
</svg>
)
export { VercelDark }
registry/new-york/templates/portfolio-v3/lib/jon-doe.ts
export type JonEventSegment =
| { type: "text"; value: string }
| { type: "link"; value: string; href: string }
export type JonEventCategory = "work" | "college" | "destination"
export type JonEvent =
| string
| JonEventSegment[]
| {
text: string | JonEventSegment[]
image?: string
video?: string
category?: JonEventCategory
}
export type JonGlobalEvent = {
name: string
place?: string
date?: string
role?: string
icon?: string
}
export type JonMilestone = {
id: string
events: JonEvent[]
companies?: { id: string; name: string }[]
globalEvents?: JonGlobalEvent[]
}
export const jonDoeMeta = {
slug: "jon-doe",
name: "Jon Doe",
birthYear: 1992,
endYear: 2026,
description:
"Fullstack developer — education, shipped projects, and the rooms where the work happens.",
legend: [
{ type: "globalEvent" as const, label: "Global events" },
{ type: "destination" as const, label: "Destinations" },
{ type: "work" as const, label: "Work" },
{ type: "college" as const, label: "College" },
],
}
export const jonDoeMilestones: Record<number, JonMilestone> = {
1992: {
id: "born",
events: ["I was born in Portland, Oregon."],
},
2010: {
id: "university",
events: [
{
text: [
{
type: "link",
value: "University of Washington",
href: "https://www.cs.washington.edu/",
},
],
category: "college",
},
],
},
2014: {
id: "japan",
events: [
{
text: "Japan",
image: "destinations/japan.jpg",
category: "destination",
},
],
},
2015: {
id: "neuralarc",
events: [
{
text: [
{ type: "text", value: "Joined " },
{
type: "link",
value: "NeuralArc",
href: "https://www.linkedin.com/",
},
{ type: "text", value: " as a Junior Developer." },
],
category: "work",
},
],
},
2016: {
id: "panfry",
events: [
{
text: [
{ type: "text", value: "Shipped " },
{
type: "link",
value: "Panfry",
href: "https://github.com/",
},
{
type: "text",
value: " — a small recipe box for the meals I actually cook on repeat.",
},
],
category: "work",
},
],
},
2017: {
id: "omscs-start",
events: [
{
text: [
{
type: "link",
value: "Georgia Tech",
href: "https://omscs.gatech.edu/",
},
],
category: "college",
},
],
},
2018: {
id: "synapse",
events: [
{
text: [
{ type: "text", value: "Joined " },
{
type: "link",
value: "Synapse Foundry",
href: "https://www.linkedin.com/",
},
{ type: "text", value: " as a Software Engineer." },
],
category: "work",
},
],
},
2019: {
id: "longmile",
events: [
{
text: [
{ type: "text", value: "Shipped " },
{
type: "link",
value: "Longmile",
href: "https://github.com/",
},
{
type: "text",
value:
" — tracks my slow, patient return to running after a bad knee.",
},
],
category: "work",
},
{
text: "Italy",
image: "destinations/italy.jpg",
category: "destination",
},
],
},
2021: {
id: "latentworks",
events: [
{
text: [
{ type: "text", value: "Joined " },
{
type: "link",
value: "Latentworks",
href: "https://www.linkedin.com/",
},
{ type: "text", value: " as a Full Stack Engineer." },
],
category: "work",
},
],
},
2022: {
id: "spinebox",
events: [
{
text: [
{ type: "text", value: "Shipped " },
{
type: "link",
value: "Spinebox",
href: "https://github.com/",
},
{
type: "text",
value: " — a shelf for the books I have actually finished this year.",
},
],
category: "work",
},
],
},
2023: {
id: "coinwell",
events: [
{
text: [
{ type: "text", value: "Shipped " },
{
type: "link",
value: "Coinwell",
href: "https://github.com/",
},
{
type: "text",
value: " — a simple, honest look at where my money actually goes.",
},
],
category: "work",
},
{
text: "Greece",
image: "destinations/greece.jpg",
category: "destination",
},
],
},
2024: {
id: "vercel-ship-2024",
events: [
{
text: [
{ type: "text", value: "Joined " },
{
type: "link",
value: "Cortexa Labs",
href: "https://www.linkedin.com/",
},
{ type: "text", value: " as a Senior Full Stack Engineer." },
],
category: "work",
},
{
text: "Attended Vercel Ship in New York.",
image: "vercel-ship-2024.jpg",
},
],
globalEvents: [
{
name: "Vercel Ship 2024",
place: "New York",
date: "May 23, 2024",
icon: "vercel",
},
],
},
2025: {
id: "vercel-ship-2025",
events: [
{
text: "Attended CES 2025 — NVIDIA keynote.",
video: "ces-2025-nvidia.mp4",
},
{
text: "Attended Vercel Ship 2025 in New York.",
image: "vercel-ship-2025.jpg",
},
],
globalEvents: [
{
name: "CES 2025 Keynote NVIDIA",
place: "Las Vegas",
date: "Jan 6, 2025",
icon: "nvidia",
},
{
name: "Vercel Ship 2025",
place: "New York",
date: "June 25, 2025",
icon: "vercel",
},
],
},
2026: {
id: "today",
events: [
{
text: "Attended Compile 26 — Michael Truell's opening keynote.",
video: "compile-26.mp4",
},
],
globalEvents: [
{
name: "Compile 26",
place: "Fort Mason, San Francisco",
date: "Jun 22, 2026",
icon: "cursor",
},
{
name: "Grok Bot Founder Build Night",
place: "Cursor HQ, San Francisco",
date: "Aug 20, 2026",
icon: "cursor",
},
{
name: "Cursor Conversations London",
place: "Central London",
date: "Sep 15, 2026",
icon: "cursor",
},
{
name: "Vercel Ship 2026",
place: "Palace of Fine Arts, San Francisco",
date: "Oct 15, 2026",
icon: "vercel",
},
{
name: "Next.js Conf 2026",
place: "Moscone West, San Francisco",
date: "Nov 5, 2026",
icon: "vercel",
},
{
name: "AI Engineer Summit",
place: "Marines' Memorial, San Francisco",
date: "Nov 19, 2026",
icon: "cursor",
},
],
},
}
registry/new-york/templates/portfolio-v3/lib/lifeline-data.ts
import type {
LifelineEvent,
LifelineLegendItem,
LifelineMarker,
} from "../components/lifeline/types"
export const LIFELINE_CURRENT_YEAR = 2026
export type LifelineMilestone = Omit<LifelineMarker, "year">
export type LifelineMilestones = Record<number, LifelineMilestone>
export interface LifelineBirthday {
month: number
day: number
}
export interface LifelineRecord {
slug: string
name: string
birthYear: number
birthday?: LifelineBirthday
endYear?: number
description: string
legend?: LifelineLegendItem[]
markers: LifelineMarker[]
}
interface DefineLifelineInput {
slug: string
name: string
birthYear: number
birthday?: LifelineBirthday
endYear?: number
description: string
legend?: LifelineLegendItem[]
milestones: LifelineMilestones
}
export type LifelineTextOverrides = Record<number, string[]>
export function localizeLifelineMarkers(
markers: LifelineMarker[],
texts: LifelineTextOverrides,
): LifelineMarker[] {
return markers.map((marker) => {
const translated = texts[marker.year]
if (!translated) return marker
const events: LifelineEvent[] = marker.events.map((event, index) => {
const text = translated[index]
if (text === undefined) return event
if (typeof event === "string") return text
if (Array.isArray(event)) return event
return { ...event, text }
})
return { ...marker, events }
})
}
export function defineLifeline(input: DefineLifelineInput): LifelineRecord {
const { milestones, ...record } = input
const lastYear = input.endYear ?? LIFELINE_CURRENT_YEAR
const markers: LifelineMarker[] = []
const today = new Date()
for (let year = input.birthYear; year <= lastYear; year++) {
const milestone = milestones[year]
const birthdayPending =
year === LIFELINE_CURRENT_YEAR &&
input.birthday &&
(today.getMonth() + 1 < input.birthday.month ||
(today.getMonth() + 1 === input.birthday.month &&
today.getDate() < input.birthday.day))
const age = birthdayPending ? year - input.birthYear - 1 : undefined
markers.push(
milestone
? { year, ...(age !== undefined && { age }), ...milestone }
: {
id: `year-${year}`,
year,
events: [],
...(age !== undefined && { age }),
},
)
}
return { ...record, markers }
}
registry/new-york/templates/portfolio-v3/lib/media.ts
import { existsSync } from "node:fs"
import { join } from "node:path"
import {
IMAGE_EXTENSIONS,
VIDEO_EXTENSIONS,
createTemplateImage,
createTemplateVideo,
resolveLocalMediaFilename,
} from "@/lib/block-media"
import type {
LifelineEvent,
LifelineMarker,
} from "../components/lifeline/types"
import { defineLifeline } from "./lifeline-data"
import {
jonDoeMeta,
jonDoeMilestones,
type JonEvent,
type JonMilestone,
} from "./jon-doe"
export {
IMAGE_EXTENSIONS,
VIDEO_EXTENSIONS,
resolveLocalMediaFilename,
} from "@/lib/block-media"
const TEMPLATE_ID = "portfolio-v3"
const CDN_ORIGIN = "https://uiception.com"
function resolveConsumerMediaUrl(
kind: "images" | "videos",
relPath: string,
allowedExtensions: readonly string[],
): string | null {
const normalized = relPath.replace(/^\/+/, "").replace(/\\/g, "/")
const slash = normalized.lastIndexOf("/")
const dirPart = slash >= 0 ? normalized.slice(0, slash) : ""
const filename = slash >= 0 ? normalized.slice(slash + 1) : normalized
const abs = join(process.cwd(), "public", kind, ...(dirPart ? [dirPart] : []))
const resolved = resolveLocalMediaFilename(abs, filename, allowedExtensions)
if (!resolved || resolved.endsWith(".gitkeep")) return null
const urlBase = dirPart ? `/${kind}/${dirPart}` : `/${kind}`
return `${urlBase}/${resolved}`
}
export function resolveTemplateImage(
relPath: string,
origin = CDN_ORIGIN,
): string {
return (
resolveConsumerMediaUrl("images", relPath, IMAGE_EXTENSIONS) ??
createTemplateImage(TEMPLATE_ID, origin)(relPath)
)
}
export function resolveTemplateVideo(
relPath: string,
origin = CDN_ORIGIN,
): string {
return (
resolveConsumerMediaUrl("videos", relPath, VIDEO_EXTENSIONS) ??
createTemplateVideo(TEMPLATE_ID, origin)(relPath)
)
}
function mediaRel(filename: string, fallbackFolder: string) {
return filename.includes("/") ? filename : `${fallbackFolder}/${filename}`
}
function resolveEvent(event: JonEvent, origin: string): LifelineEvent {
if (typeof event === "string" || Array.isArray(event)) return event
const imageSrc = event.image
? resolveTemplateImage(mediaRel(event.image, "moments"), origin)
: null
const videoSrc = event.video
? resolveTemplateVideo(mediaRel(event.video, "moments"), origin)
: null
const alt =
typeof event.text === "string" ? event.text : "Timeline moment"
if (!imageSrc && !videoSrc && !event.category) {
return typeof event.text === "string" || Array.isArray(event.text)
? (event.text as LifelineEvent)
: { text: event.text }
}
return {
text: event.text,
...(event.category ? { category: event.category } : {}),
...(imageSrc ? { image: { src: imageSrc, alt } } : {}),
...(videoSrc ? { video: { src: videoSrc, alt } } : {}),
}
}
function resolveMilestone(
milestone: JonMilestone,
origin: string,
): {
id: string
events: LifelineEvent[]
companies?: LifelineMarker["companies"]
globalEvents?: LifelineMarker["globalEvents"]
} {
const events = milestone.events.map((event) => resolveEvent(event, origin))
return {
id: milestone.id,
events,
...(milestone.companies ? { companies: milestone.companies } : {}),
...(milestone.globalEvents?.length
? { globalEvents: milestone.globalEvents }
: {}),
}
}
export function getJonDoeLifeline(origin = CDN_ORIGIN) {
const milestones: Record<number, ReturnType<typeof resolveMilestone>> = {}
for (const [year, milestone] of Object.entries(jonDoeMilestones)) {
milestones[Number(year)] = resolveMilestone(milestone, origin)
}
return defineLifeline({
slug: jonDoeMeta.slug,
name: jonDoeMeta.name,
birthYear: jonDoeMeta.birthYear,
endYear: jonDoeMeta.endYear,
description: jonDoeMeta.description,
legend: jonDoeMeta.legend,
milestones,
})
}
export function resolveAvatarSrc(origin = CDN_ORIGIN): string {
const installDir = join(process.cwd(), "public")
for (const ext of IMAGE_EXTENSIONS) {
const filename = `avatar${ext}`
if (existsSync(join(installDir, filename))) {
return `/${filename}`
}
}
return resolveTemplateImage("avatar.png", origin)
}
registry/new-york/templates/portfolio-v3/public/avatar.png.gitkeep
Place your avatar here as avatar.png (or avatar.jpg / avatar.webp). Delete this .gitkeep file after adding your photo.
registry/new-york/templates/portfolio-v3/public/images/destinations/japan.jpg.gitkeep
Place japan.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/images/destinations/italy.jpg.gitkeep
Place italy.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/images/destinations/greece.jpg.gitkeep
Place greece.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/images/moments/vercel-ship-2024.jpg.gitkeep
Place vercel-ship-2024.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/images/moments/vercel-ship-2025.jpg.gitkeep
Place vercel-ship-2025.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/videos/moments/ces-2025-nvidia.mp4.gitkeep
Place ces-2025-nvidia.mp4 here (or .webm / .mov / .mkv). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/videos/moments/compile-26.mp4.gitkeep
Place compile-26.mp4 here (or .webm / .mov / .mkv). Delete this .gitkeep after adding the file.