Enhance project transitions and add photo modal
Build and Deploy / build-deploy (push) Successful in 49s

This commit is contained in:
2026-06-19 22:30:14 +01:00
parent cad7ba2aa4
commit aa14f5d820
27 changed files with 812 additions and 210 deletions
+4
View File
@@ -23,6 +23,10 @@ html {
overflow-y: overlay;
}
html.snap-active {
scroll-snap-type: y mandatory;
}
body {
background: var(--background);
color: var(--foreground);
+28
View File
@@ -1,11 +1,14 @@
"use client";
import { m, type Variants } from "motion/react";
import ArrowUpRightIcon from "@/components/icons/arrow-up-right.svg";
import DocumentIcon from "@/components/icons/document.svg";
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
type Fact = {
label: string;
value: string;
cta?: { label: string; href: string };
};
type FactsRevealProps = {
@@ -31,6 +34,30 @@ const itemVariants: Variants = {
},
};
const FactCta = ({ cta }: { cta: NonNullable<Fact["cta"]> }) => {
const hover = useMagneticSpringHover<HTMLAnchorElement>({
magnetStrength: 0.18,
scaleAmount: 1.04,
shadowElevation: 10,
});
return (
<m.a
ref={hover.ref}
href={cta.href}
target="_blank"
rel="noreferrer noopener"
style={hover.style}
{...hover.handlers}
className="group mt-3 inline-flex cursor-pointer items-center gap-2 rounded-full border border-foreground/15 bg-foreground/3 px-4 py-2 font-heading text-base backdrop-blur-sm"
>
<DocumentIcon className="size-4" />
<span>{cta.label}</span>
<ArrowUpRightIcon className="group-hover:-translate-y-0.5 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5" />
</m.a>
);
};
const FactItem = ({ fact }: { fact: Fact }) => {
const hover = useMagneticSpringHover<HTMLDivElement>({
magnetStrength: 0.12,
@@ -48,6 +75,7 @@ const FactItem = ({ fact }: { fact: Fact }) => {
>
<p className="font-body text-sm uppercase tracking-wider opacity-60">{fact.label}</p>
<p className="font-body text-lg">{fact.value}</p>
{fact.cta && <FactCta cta={fact.cta} />}
</m.div>
</m.div>
);
+4 -4
View File
@@ -22,12 +22,12 @@ export const Threshold = ({ children }: ThresholdProps) => {
const clipPath = useTransform(
scrollYProgress,
[0, 1],
[0, 0.6],
["inset(0 50% 0 50%)", "inset(0 0% 0 0%)"],
);
const leftEdge = useTransform(scrollYProgress, [0, 1], ["50%", "0%"]);
const rightEdge = useTransform(scrollYProgress, [0, 1], ["50%", "100%"]);
const edgeOpacity = useTransform(scrollYProgress, [0, 0.12, 0.88, 1], [0, 1, 1, 0]);
const leftEdge = useTransform(scrollYProgress, [0, 0.6], ["50%", "0%"]);
const rightEdge = useTransform(scrollYProgress, [0, 0.6], ["50%", "100%"]);
const edgeOpacity = useTransform(scrollYProgress, [0, 0.08, 0.5, 0.6], [0, 1, 1, 0]);
return (
<div ref={ref} className="relative">
+1 -1
View File
@@ -19,7 +19,7 @@ const Digit = ({ value }: DigitProps) => {
if (value === renderedValue || animatingRef.current) return;
const delta = value - renderedValue;
// delta === 1: normal increment; delta === -9: rollover (9 → 0).
// delta === 1: normal increment; delta === -9: rollover from 9 to 0.
const isSingleForwardStep = delta === 1 || delta === -9;
if (!isSingleForwardStep || shouldReduce) {
+14 -2
View File
@@ -1,8 +1,13 @@
"use client";
import { m } from "motion/react";
import type { FC, SVGProps } from "react";
import DocumentIcon from "@/components/icons/document.svg";
import GiteaIcon from "@/components/icons/gitea.svg";
import GitHubIcon from "@/components/icons/github.svg";
import LinkedInIcon from "@/components/icons/linkedin.svg";
import SourceIcon from "@/components/icons/source.svg";
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
import { resolveHost } from "@/lib/host-icons";
type Link = {
platform: string;
@@ -13,13 +18,20 @@ type SocialLinksProps = {
links: readonly Link[];
};
const PLATFORM_ICONS: Record<string, FC<SVGProps<SVGSVGElement>>> = {
GitHub: GitHubIcon,
Gitea: GiteaIcon,
LinkedIn: LinkedInIcon,
CV: DocumentIcon,
};
const SocialLink = ({ link }: { link: Link }) => {
const hover = useMagneticSpringHover<HTMLAnchorElement>({
magnetStrength: 0.22,
scaleAmount: 1.05,
shadowElevation: 0,
});
const { Icon } = resolveHost(link.url);
const Icon = PLATFORM_ICONS[link.platform] ?? SourceIcon;
return (
<m.a
+19
View File
@@ -0,0 +1,19 @@
import type { PhotoItem } from "./types";
const formatExposureTime = (s: number): string => {
if (s >= 1) return `${s}s`;
const denom = Math.round(1 / s);
return `1/${denom}s`;
};
export const formatExifLine = (photo: PhotoItem): string | null => {
const { exif } = photo;
const parts: string[] = [];
if (exif.focalLength) parts.push(`${exif.focalLength}mm`);
if (exif.fNumber) parts.push(`ƒ/${exif.fNumber}`);
if (exif.exposureTime) parts.push(formatExposureTime(exif.exposureTime));
if (exif.iso) parts.push(`${exif.iso / 100}\u00A0hISO`);
return parts.length > 0 ? parts.join(" · ") : null;
};
@@ -0,0 +1,200 @@
"use client";
import { AnimatePresence, m } from "motion/react";
import { useEffect, useRef } from "react";
import ArrowLeftIcon from "@/components/icons/arrow-left.svg";
import ArrowRightIcon from "@/components/icons/arrow-right.svg";
import ArrowUpRightIcon from "@/components/icons/arrow-up-right.svg";
import CloseIcon from "@/components/icons/close.svg";
import { formatExifLine } from "./exif";
import type { PhotoItem } from "./types";
type PhotoModalProps = {
photos: PhotoItem[];
selectedIndex: number | null;
apiUrl: string;
onClose: () => void;
onSelectIndex: (index: number) => void;
};
const chromeButtonClasses =
"inline-flex size-12 cursor-pointer items-center justify-center rounded-full border border-foreground/15 bg-foreground/3 text-foreground/80 backdrop-blur-sm transition-colors hover:border-accent-cyan/60 hover:text-accent-cyan disabled:cursor-default disabled:opacity-30 disabled:hover:border-foreground/15 disabled:hover:text-foreground/80";
const FOCUSABLE_SELECTOR = 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
export const PhotoModal = ({
photos,
selectedIndex,
apiUrl,
onClose,
onSelectIndex,
}: PhotoModalProps) => {
const isOpen = selectedIndex !== null;
const photo = selectedIndex !== null ? photos[selectedIndex] : null;
const hasPrev = selectedIndex !== null && selectedIndex > 0;
const hasNext = selectedIndex !== null && selectedIndex < photos.length - 1;
const exifLine = photo ? formatExifLine(photo) : null;
const lensName = photo?.exif.lensDisplayName ?? null;
const dialogRef = useRef<HTMLDivElement>(null);
const lastFocusedRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen || selectedIndex === null) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusables = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && (active === first || !dialogRef.current.contains(active))) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || !dialogRef.current.contains(active))) {
e.preventDefault();
first.focus();
}
return;
}
if (e.key === "ArrowLeft" && selectedIndex > 0) {
e.preventDefault();
onSelectIndex(selectedIndex - 1);
return;
}
if (e.key === "ArrowRight" && selectedIndex < photos.length - 1) {
e.preventDefault();
onSelectIndex(selectedIndex + 1);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [isOpen, selectedIndex, photos.length, onClose, onSelectIndex]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
lastFocusedRef.current = document.activeElement as HTMLElement | null;
const id = requestAnimationFrame(() => {
const focusables = dialogRef.current
? Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
: [];
focusables[0]?.focus();
});
return () => {
cancelAnimationFrame(id);
lastFocusedRef.current?.focus();
};
}, [isOpen]);
return (
<AnimatePresence>
{isOpen && photo ? (
<m.div
ref={dialogRef}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
role="dialog"
aria-modal="true"
aria-label="Photo viewer"
onClick={onClose}
className="fixed inset-0 z-200 flex items-center justify-center bg-background/95 pb-4 backdrop-blur-sm md:pb-6"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label="Close"
className={`absolute top-4 right-4 ${chromeButtonClasses}`}
>
<CloseIcon className="size-5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (selectedIndex !== null && hasPrev) onSelectIndex(selectedIndex - 1);
}}
aria-label="Previous photo"
disabled={!hasPrev}
className={`-translate-y-1/2 absolute top-1/2 left-4 ${chromeButtonClasses}`}
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (selectedIndex !== null && hasNext) onSelectIndex(selectedIndex + 1);
}}
aria-label="Next photo"
disabled={!hasNext}
className={`-translate-y-1/2 absolute top-1/2 right-4 ${chromeButtonClasses}`}
>
<ArrowRightIcon className="size-5" />
</button>
<div className="flex h-full w-full flex-col items-center gap-4">
<div className="flex min-h-0 w-full flex-1 items-center justify-center">
<a
href={`${apiUrl}/assets/${photo.id}`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
aria-label="Open photo details"
className="contents"
>
{/* biome-ignore lint/performance/noImgElement: viewer image is served pre-optimized by rate-my-shots */}
<img
key={photo.id}
src={`${apiUrl}/img/${photo.id}?size=preview`}
alt=""
className="max-h-full max-w-full cursor-pointer object-contain"
/>
</a>
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stops clicks on chrome from closing the modal */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: defensive click absorber, no keyboard semantics needed */}
<div onClick={(e) => e.stopPropagation()} className="flex flex-col items-center gap-1">
{lensName && <p className="font-mono text-foreground/80 text-xs">{lensName}</p>}
{exifLine && <p className="font-mono text-foreground/55 text-xs">{exifLine}</p>}
</div>
<a
href={`${apiUrl}/assets/${photo.id}`}
target="_blank"
rel="noreferrer noopener"
onClick={(e) => e.stopPropagation()}
className="group inline-flex items-center gap-1.5 font-body text-foreground/70 text-sm transition-colors hover:text-accent-cyan"
>
<span>Details</span>
<ArrowUpRightIcon className="group-hover:-translate-y-0.5 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5" />
</a>
</div>
</m.div>
) : null}
</AnimatePresence>
);
};
+10 -26
View File
@@ -3,32 +3,16 @@
import { m, useReducedMotion } from "motion/react";
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
import { springGentle } from "@/lib/motion";
import { formatExifLine } from "./exif";
import type { PhotoItem } from "./types";
type PhotoTileProps = {
photo: PhotoItem;
apiUrl: string;
onSelect: () => void;
};
const formatExposureTime = (s: number): string => {
if (s >= 1) return `${s}s`;
const denom = Math.round(1 / s);
return `1/${denom}s`;
};
const formatExifLine = (photo: PhotoItem): string | null => {
const { exif } = photo;
const parts: string[] = [];
if (exif.focalLength) parts.push(`${exif.focalLength}mm`);
if (exif.fNumber) parts.push(`ƒ/${exif.fNumber}`);
if (exif.exposureTime) parts.push(formatExposureTime(exif.exposureTime));
if (exif.iso) parts.push(`${exif.iso / 100}\u00A0hISO`);
return parts.length > 0 ? parts.join(" · ") : null;
};
export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => {
export const PhotoTile = ({ photo, apiUrl, onSelect }: PhotoTileProps) => {
const shouldReduceMotion = useReducedMotion();
const hover = useMagneticSpringHover<HTMLDivElement>({
magnetStrength: 0.15,
@@ -51,11 +35,11 @@ export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => {
className="group relative overflow-hidden rounded-lg shadow-sm transition-shadow duration-200 hover:z-10 hover:shadow-2xl hover:shadow-black/30"
{...hover.handlers}
>
<a
href={`${apiUrl}/assets/${photo.id}`}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer"
<button
type="button"
onClick={onSelect}
aria-label="Open photo"
className="block w-full cursor-pointer"
>
{/* biome-ignore lint/performance/noImgElement: thumbnails are pre-optimized upstream */}
<img
@@ -64,7 +48,7 @@ export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => {
width={photo.width}
height={photo.height}
loading="lazy"
className="w-full"
className="block w-full"
/>
{hasOverlay && (
<div className="pointer-events-none absolute inset-0 flex flex-col justify-end gap-1 bg-linear-to-t from-black/70 via-black/20 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
@@ -72,7 +56,7 @@ export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => {
{exifLine && <p className="font-mono text-white/60 text-xs">{exifLine}</p>}
</div>
)}
</a>
</button>
</m.div>
);
};
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import useSWRInfinite from "swr/infinite";
import ArrowUpRightIcon from "@/components/icons/arrow-up-right.svg";
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
import { PhotoModal } from "./photo-modal";
import { PhotoTile } from "./photo-tile";
import type { PhotoItem } from "./types";
@@ -100,6 +101,7 @@ export const PhotographyClient = ({ apiUrl }: PhotographyClientProps) => {
const [columnCount, setColumnCount] = useState(MIN_COLUMNS);
const [columnWidth, setColumnWidth] = useState(0);
const [gap, setGap] = useState(GAP_DESKTOP);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const cacheRef = useRef<ColumnCache | null>(null);
const ctaHover = useMagneticSpringHover<HTMLAnchorElement>({
@@ -124,6 +126,14 @@ export const PhotographyClient = ({ apiUrl }: PhotographyClientProps) => {
const reachedCap = (data?.length ?? 0) >= MAX_PAGES;
const autoLoad = upstreamHasMore && !reachedCap;
const indexById = useMemo(() => {
const map = new Map<string, number>();
photos.forEach((p, i) => {
map.set(p.id, i);
});
return map;
}, [photos]);
const columns = useMemo(() => {
const result = assignPhotosToColumns(cacheRef.current, photos, columnCount, columnWidth, gap);
cacheRef.current = result;
@@ -174,12 +184,24 @@ export const PhotographyClient = ({ apiUrl }: PhotographyClientProps) => {
{columns.map((column, colIdx) => (
<div key={`${colIdx}/${columnCount}`} className="flex flex-col" style={{ gap }}>
{column.photos.map((photo) => (
<PhotoTile key={photo.id} photo={photo} apiUrl={apiUrl} />
<PhotoTile
key={photo.id}
photo={photo}
apiUrl={apiUrl}
onSelect={() => setSelectedIndex(indexById.get(photo.id) ?? 0)}
/>
))}
</div>
))}
{autoLoad && <div ref={loadMoreRef} className="col-span-full h-1" />}
</div>
<PhotoModal
photos={photos}
selectedIndex={selectedIndex}
apiUrl={apiUrl}
onClose={() => setSelectedIndex(null)}
onSelectIndex={setSelectedIndex}
/>
{reachedCap && upstreamHasMore && (
<div className="mt-8 flex justify-center">
<m.a
@@ -1,29 +1,33 @@
"use client";
import { type MotionValue, m, useTransform } from "motion/react";
import { PHASES } from "./lifecycle";
import { easeInOut, type MotionValue, m, useTransform } from "motion/react";
import { type Anchors, linear, phaseFor } from "./lifecycle";
type DescriptionRevealProps = {
text: string;
localProgress: MotionValue<number>;
anchors: Anchors;
};
export const DescriptionReveal = ({ text, localProgress }: DescriptionRevealProps) => {
const { enterStart, enterEnd, exitStart, exitEnd } = PHASES.description;
export const DescriptionReveal = ({ text, localProgress, anchors }: DescriptionRevealProps) => {
const { enterStart, enterEnd, exitStart, exitEnd } = phaseFor("description", anchors);
const opacity = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[0, 1, 1, 0],
{ ease: [easeInOut, linear, easeInOut] },
);
const y = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[28, 0, 0, -16],
{ ease: [easeInOut, linear, easeInOut] },
);
const filter = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
["blur(10px)", "blur(0px)", "blur(0px)", "blur(8px)"],
{ ease: [easeInOut, linear, easeInOut] },
);
return (
+4 -1
View File
@@ -2,10 +2,12 @@
import type { MotionValue } from "motion/react";
import { HookWord } from "./hook-word";
import type { Anchors } from "./lifecycle";
type HookRevealProps = {
text: string;
localProgress: MotionValue<number>;
anchors: Anchors;
};
type Token = { kind: "word"; text: string; wordIndex: number } | { kind: "space"; text: string };
@@ -24,7 +26,7 @@ const tokenize = (text: string): { tokens: Token[]; wordCount: number } => {
return { tokens, wordCount: wordIndex };
};
export const HookReveal = ({ text, localProgress }: HookRevealProps) => {
export const HookReveal = ({ text, localProgress, anchors }: HookRevealProps) => {
const { tokens, wordCount } = tokenize(text);
return (
@@ -42,6 +44,7 @@ export const HookReveal = ({ text, localProgress }: HookRevealProps) => {
index={token.wordIndex}
total={wordCount}
localProgress={localProgress}
anchors={anchors}
/>
);
})}
+9 -8
View File
@@ -1,17 +1,18 @@
"use client";
import { easeIn, easeOut, type MotionValue, m, useTransform } from "motion/react";
import { linear, PHASES, stagger } from "./lifecycle";
import { easeInOut, type MotionValue, m, useTransform } from "motion/react";
import { type Anchors, linear, phaseFor, stagger } from "./lifecycle";
type HookWordProps = {
word: string;
index: number;
total: number;
localProgress: MotionValue<number>;
anchors: Anchors;
};
export const HookWord = ({ word, index, total, localProgress }: HookWordProps) => {
const phase = stagger(PHASES.hook, index, total, 0.55, true);
export const HookWord = ({ word, index, total, localProgress, anchors }: HookWordProps) => {
const phase = stagger(phaseFor("hook", anchors), index, total, 0.55, true);
const { enterStart, enterEnd, exitStart, exitEnd } = phase;
const exitX = index % 2 === 0 ? -48 : 48;
@@ -22,25 +23,25 @@ export const HookWord = ({ word, index, total, localProgress }: HookWordProps) =
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[0, 1, 1, 0],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
const y = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[88, 0, 0, exitY],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
const x = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[0, 0, 0, exitX],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
const rotate = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[4, 0, 0, exitRotate],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
return (
+84 -9
View File
@@ -5,22 +5,97 @@ export type Phase = {
exitEnd: number;
};
export const PHASES = {
export type Anchors = { pFirst: number; pLast: number };
// Reference window used to normalize RAW_PHASES into [0, 1].
const ENTER_REF = 0.36;
const EXIT_REF = 0.64;
const RAW_PHASES = {
placard: { enterStart: 0.0, enterEnd: 0.08, exitStart: 0.92, exitEnd: 1.0 },
hook: { enterStart: 0.0, enterEnd: 0.16, exitStart: 0.84, exitEnd: 1.0 },
description: { enterStart: 0.08, enterEnd: 0.24, exitStart: 0.78, exitEnd: 0.94 },
visual: { enterStart: 0.02, enterEnd: 0.22, exitStart: 0.82, exitEnd: 1.0 },
visual: { enterStart: 0.0, enterEnd: 0.36, exitStart: 0.64, exitEnd: 1.0 },
techKeys: { enterStart: 0.12, enterEnd: 0.34, exitStart: 0.66, exitEnd: 0.9 },
links: { enterStart: 0.18, enterEnd: 0.36, exitStart: 0.64, exitEnd: 0.82 },
} satisfies Record<string, Phase>;
} as const satisfies Record<string, Phase>;
export const VISUAL_BODY = { start: 0.22, end: 0.82 } as const;
// Invariant: every phase's enterEnd must sit at or before ENTER_REF, and exitStart at or after
// EXIT_REF. Otherwise normalization produces out-of-range values and the choreography breaks
// silently. Enforced in dev so edits to RAW_PHASES that violate the contract surface immediately.
if (process.env.NODE_ENV !== "production") {
for (const [key, p] of Object.entries(RAW_PHASES) as [keyof typeof RAW_PHASES, Phase][]) {
if (p.enterEnd > ENTER_REF) {
throw new Error(
`RAW_PHASES.${key}.enterEnd (${p.enterEnd}) exceeds ENTER_REF (${ENTER_REF}).`,
);
}
if (p.exitStart < EXIT_REF) {
throw new Error(
`RAW_PHASES.${key}.exitStart (${p.exitStart}) is below EXIT_REF (${EXIT_REF}).`,
);
}
}
}
const allPhases = Object.values(PHASES);
/** Highest enterEnd across all elements — the moment everything has finished entering. */
export const MAX_ENTER_END = Math.max(...allPhases.map((p) => p.enterEnd));
/** Lowest exitStart across all elements — the moment the first element starts exiting. */
export const MIN_EXIT_START = Math.min(...allPhases.map((p) => p.exitStart));
export type PhaseKey = keyof typeof RAW_PHASES;
const PHASE_NORM: Record<PhaseKey, Phase> = (
Object.entries(RAW_PHASES) as [PhaseKey, Phase][]
).reduce(
(acc, [k, p]) => {
acc[k] = {
enterStart: p.enterStart / ENTER_REF,
enterEnd: p.enterEnd / ENTER_REF,
exitStart: (p.exitStart - EXIT_REF) / (1 - EXIT_REF),
exitEnd: (p.exitEnd - EXIT_REF) / (1 - EXIT_REF),
};
return acc;
},
{} as Record<PhaseKey, Phase>,
);
/**
* Project-local phase for a given element under a project's anchors.
* - Entries are squeezed into [0, pFirst]; exits into [pLast, 1].
* - No plateau between pFirst and pLast for elements whose normalized enterEnd/exitStart
* are both at the window boundary (e.g., visual). Chrome elements with smaller normalized
* windows finish earlier within the entry budget and start exiting later within the exit
* budget, preserving stagger.
*/
export const phaseFor = (key: PhaseKey, anchors: Anchors): Phase => {
const n = PHASE_NORM[key];
return {
enterStart: n.enterStart * anchors.pFirst,
enterEnd: n.enterEnd * anchors.pFirst,
exitStart: anchors.pLast + n.exitStart * (1 - anchors.pLast),
exitEnd: anchors.pLast + n.exitEnd * (1 - anchors.pLast),
};
};
/**
* Project anchors derived from its scroll-pixel budget.
* - Entry budget = exit budget = basePx / 2 (in pixels).
* - Cycle (slide-to-slide travel) consumes the remaining (N-1) * extraPx.
* - Result: peaks are always separated by extraPx px, regardless of slide count.
*/
export const anchorsFor = (slideCount: number, basePx: number, extraPx: number): Anchors => {
const N = Math.max(1, slideCount);
if (N === 1) return { pFirst: 0.5, pLast: 0.5 };
const total = basePx + (N - 1) * extraPx;
const entry = basePx / 2 / total;
return { pFirst: entry, pLast: 1 - entry };
};
/** Local progress at which slide j is fully visible (its single stable point). */
export const peakProgressFor = (
slideIndex: number,
slideCount: number,
anchors: Anchors,
): number => {
if (slideCount <= 1) return (anchors.pFirst + anchors.pLast) / 2;
return anchors.pFirst + (slideIndex * (anchors.pLast - anchors.pFirst)) / (slideCount - 1);
};
export const linear = (t: number) => t;
+17 -7
View File
@@ -1,15 +1,16 @@
"use client";
import { easeIn, easeOut, type MotionValue, m, useTransform } from "motion/react";
import { easeInOut, type MotionValue, m, useTransform } from "motion/react";
import type { ReactNode } from "react";
import type { Project } from "@/content/projects";
import { linear, PHASES, stagger } from "./lifecycle";
import { type Anchors, linear, phaseFor, stagger } from "./lifecycle";
import { LiveLink, SourceLink } from "./source-link";
type LinksRowProps = {
project: Project;
localProgress: MotionValue<number>;
sideSign: -1 | 1;
anchors: Anchors;
};
type LinkWrapperProps = {
@@ -17,11 +18,19 @@ type LinkWrapperProps = {
total: number;
localProgress: MotionValue<number>;
sideSign: -1 | 1;
anchors: Anchors;
children: ReactNode;
};
const LinkWrapper = ({ index, total, localProgress, sideSign, children }: LinkWrapperProps) => {
const phase = stagger(PHASES.links, index, total, 0.5, true);
const LinkWrapper = ({
index,
total,
localProgress,
sideSign,
anchors,
children,
}: LinkWrapperProps) => {
const phase = stagger(phaseFor("links", anchors), index, total, 0.5, true);
const { enterStart, enterEnd, exitStart, exitEnd } = phase;
const offEdge = -40 * sideSign;
@@ -29,13 +38,13 @@ const LinkWrapper = ({ index, total, localProgress, sideSign, children }: LinkWr
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[0, 1, 1, 0],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
const x = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[offEdge, 0, 0, offEdge],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
return (
@@ -45,7 +54,7 @@ const LinkWrapper = ({ index, total, localProgress, sideSign, children }: LinkWr
);
};
export const LinksRow = ({ project, localProgress, sideSign }: LinksRowProps) => {
export const LinksRow = ({ project, localProgress, sideSign, anchors }: LinksRowProps) => {
type Item = { key: string; node: ReactNode };
const items: Item[] = [];
if (project.liveUrl) {
@@ -66,6 +75,7 @@ export const LinksRow = ({ project, localProgress, sideSign }: LinksRowProps) =>
total={items.length}
localProgress={localProgress}
sideSign={sideSign}
anchors={anchors}
>
{item.node}
</LinkWrapper>
+7 -4
View File
@@ -1,25 +1,28 @@
"use client";
import { type MotionValue, m, useTransform } from "motion/react";
import { PHASES } from "./lifecycle";
import { easeInOut, type MotionValue, m, useTransform } from "motion/react";
import { type Anchors, linear, phaseFor } from "./lifecycle";
type PlacardProps = {
index: number;
total: number;
localProgress: MotionValue<number>;
anchors: Anchors;
};
export const Placard = ({ index, total, localProgress }: PlacardProps) => {
const { enterStart, enterEnd, exitStart, exitEnd } = PHASES.placard;
export const Placard = ({ index, total, localProgress, anchors }: PlacardProps) => {
const { enterStart, enterEnd, exitStart, exitEnd } = phaseFor("placard", anchors);
const opacity = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[0, 1, 1, 0],
{ ease: [easeInOut, linear, easeInOut] },
);
const y = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[-12, 0, 0, 12],
{ ease: [easeInOut, linear, easeInOut] },
);
return (
+22 -5
View File
@@ -5,6 +5,7 @@ import { type CSSProperties, useState } from "react";
import type { Project } from "@/content/projects";
import { DescriptionReveal } from "./description-reveal";
import { HookReveal } from "./hook-reveal";
import type { Anchors } from "./lifecycle";
import { LinksRow } from "./links-row";
import { Placard } from "./placard";
import { TechKeyDeck } from "./tech-key-deck";
@@ -16,6 +17,7 @@ type ProjectLayerProps = {
total: number;
inputRange: number[];
outputRange: number[];
anchors: Anchors;
sectionProgress: MotionValue<number>;
};
@@ -25,6 +27,7 @@ export const ProjectLayer = ({
total,
inputRange,
outputRange,
anchors,
sectionProgress,
}: ProjectLayerProps) => {
const localProgress = useTransform(sectionProgress, inputRange, outputRange, {
@@ -58,11 +61,24 @@ export const ProjectLayer = ({
<div className="mx-auto grid w-full max-w-7xl desktop:grid-cols-2 grid-cols-1 items-center desktop:gap-12 gap-10 lg:gap-20">
<div className={visualOnLeft ? "desktop:order-2" : "desktop:order-1"}>
<div className="space-y-6">
<Placard index={index} total={total} localProgress={localProgress} />
<HookReveal text={project.hook} localProgress={localProgress} />
<DescriptionReveal text={project.description} localProgress={localProgress} />
<TechKeyDeck techStack={project.techStack} localProgress={localProgress} />
<LinksRow project={project} localProgress={localProgress} sideSign={contentSideSign} />
<Placard index={index} total={total} localProgress={localProgress} anchors={anchors} />
<HookReveal text={project.hook} localProgress={localProgress} anchors={anchors} />
<DescriptionReveal
text={project.description}
localProgress={localProgress}
anchors={anchors}
/>
<TechKeyDeck
techStack={project.techStack}
localProgress={localProgress}
anchors={anchors}
/>
<LinksRow
project={project}
localProgress={localProgress}
sideSign={contentSideSign}
anchors={anchors}
/>
</div>
</div>
@@ -73,6 +89,7 @@ export const ProjectLayer = ({
priority={index === 0}
sideSign={visualSideSign}
active={active}
anchors={anchors}
/>
</div>
</div>
+131 -29
View File
@@ -1,22 +1,17 @@
"use client";
import { useScroll } from "motion/react";
import { useMemo, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import type { Project } from "@/content/projects";
import { MAX_ENTER_END, MIN_EXIT_START } from "./lifecycle";
import { type Anchors, anchorsFor, peakProgressFor } from "./lifecycle";
import { ProjectLayer } from "./project-layer";
const BASE_VH = 160;
const EXTRA_VH = 80;
const OVERLAP_VH = 40;
/**
* Pre-/post-engagement zones, in vh. Equal to one viewport (sticky engages when
* section.top reaches viewport.top, disengages when section.bottom reaches viewport.bottom).
* Used to allocate scroll space for the first project's entrance and the last project's exit
* so that their per-element choreography plays exactly across the section's approach/departure.
*/
const PRE_BUFFER_VH = 150;
const POST_BUFFER_VH = 150;
// All values are in viewports (1.0 = 100vh). Dimensionless ratios.
const BASE = 0.95;
const EXTRA = 0.6;
const OVERLAP = 0.2;
const PRE_BUFFER = 1.5;
const POST_BUFFER = 1.5;
type ProjectsStageProps = {
projects: readonly Project[];
@@ -27,48 +22,83 @@ type LayerRange = {
outputRange: number[];
};
type Snap = {
topVh: number;
label: string;
};
type Layout = {
layers: LayerRange[];
anchors: Anchors[];
sectionVh: number;
snaps: Snap[];
};
/**
* Invert a layer's piecewise-linear local-progress map back to a widened coordinate (in viewports).
* Mid projects use a single linear segment; first/last projects have a knot at
* (preBuffer, pFirst) / (totalWidened - postBuffer, pLast).
*/
const localToWidened = (
local: number,
rawStart: number,
rawEnd: number,
knot: { atProgress: number; atLocal: number } | null,
): number => {
if (!knot) {
return rawStart + local * (rawEnd - rawStart);
}
if (local <= knot.atLocal) {
const t = knot.atLocal === 0 ? 0 : local / knot.atLocal;
return rawStart + t * (knot.atProgress - rawStart);
}
const t = (local - knot.atLocal) / (1 - knot.atLocal);
return knot.atProgress + t * (rawEnd - knot.atProgress);
};
const computeLayout = (projects: readonly Project[]): Layout => {
const lastIndex = projects.length - 1;
const anchors = projects.map((p) => anchorsFor(p.media.length, BASE, EXTRA));
const weights = projects.map((p, i) => {
let w = BASE_VH + (p.media.length - 1) * EXTRA_VH;
if (i === 0) w += PRE_BUFFER_VH;
if (i === lastIndex) w += POST_BUFFER_VH;
let w = BASE + (p.media.length - 1) * EXTRA;
if (i === 0) w += PRE_BUFFER;
if (i === lastIndex) w += POST_BUFFER;
return w;
});
const rawRanges: { start: number; end: number }[] = [];
let cursor = 0;
for (let i = 0; i < weights.length; i++) {
if (i > 0) cursor -= OVERLAP_VH;
if (i > 0) cursor -= OVERLAP;
rawRanges.push({ start: cursor, end: cursor + weights[i] });
cursor += weights[i];
}
// Total widened scroll = pre + content + post = section + viewport
const totalWidenedVh = cursor;
const sectionVh = totalWidenedVh - 100;
const stickyEngageProgress = PRE_BUFFER_VH / totalWidenedVh;
const stickyDisengageProgress = (totalWidenedVh - POST_BUFFER_VH) / totalWidenedVh;
const totalWidened = cursor;
// sticky-engaged section height = totalWidened - 1 viewport (the section, when pinned,
// occupies one viewport, and scrolls the remaining "widened" budget).
const sectionVh = Math.max(0, totalWidened - 1);
const stickyEngage = PRE_BUFFER;
const stickyDisengage = totalWidened - POST_BUFFER;
const stickyEngageProgress = stickyEngage / totalWidened;
const stickyDisengageProgress = stickyDisengage / totalWidened;
const layers: LayerRange[] = rawRanges.map((r, i) => {
const startProgress = r.start / totalWidenedVh;
const endProgress = r.end / totalWidenedVh;
const startProgress = r.start / totalWidened;
const endProgress = r.end / totalWidened;
if (i === 0) {
return {
inputRange: [startProgress, stickyEngageProgress, endProgress],
outputRange: [0, MAX_ENTER_END, 1],
outputRange: [0, anchors[0].pFirst, 1],
};
}
if (i === lastIndex) {
return {
inputRange: [startProgress, stickyDisengageProgress, endProgress],
outputRange: [0, MIN_EXIT_START, 1],
outputRange: [0, anchors[lastIndex].pLast, 1],
};
}
return {
@@ -77,12 +107,75 @@ const computeLayout = (projects: readonly Project[]): Layout => {
};
});
return { layers, sectionVh };
const snaps: Snap[] = [];
rawRanges.forEach((r, i) => {
const project = projects[i];
const slideCount = Math.max(1, project.media.length);
const projectAnchors = anchors[i];
const knot =
i === 0
? { atProgress: stickyEngage, atLocal: projectAnchors.pFirst }
: i === lastIndex
? { atProgress: stickyDisengage, atLocal: projectAnchors.pLast }
: null;
for (let j = 0; j < slideCount; j++) {
const peakLocal = peakProgressFor(j, slideCount, projectAnchors);
const widened = localToWidened(peakLocal, r.start, r.end, knot);
const topVh = Math.max(0, Math.min(sectionVh, widened - 0.5));
snaps.push({
topVh,
label: slideCount > 1 ? `${project.name} · slide ${j + 1}/${slideCount}` : project.name,
});
}
});
return { layers, anchors, sectionVh, snaps };
};
export const ProjectsStage = ({ projects }: ProjectsStageProps) => {
const { layers, sectionVh } = useMemo(() => computeLayout(projects), [projects]);
const sectionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const state = { topPassed: false, bottomActive: false };
const sync = () => {
document.documentElement.classList.toggle(
"snap-active",
state.topPassed && state.bottomActive,
);
};
const topObserver = new IntersectionObserver(
([entry]) => {
state.topPassed = entry.isIntersecting;
sync();
},
{ rootMargin: "0px 0px -100% 0px" },
);
const bottomObserver = new IntersectionObserver(
([entry]) => {
state.bottomActive = entry.isIntersecting;
sync();
},
{ rootMargin: "-100% 0px 0px 0px" },
);
topObserver.observe(el);
bottomObserver.observe(el);
return () => {
topObserver.disconnect();
bottomObserver.disconnect();
document.documentElement.classList.remove("snap-active");
};
}, []);
const { layers, anchors, sectionVh, snaps } = useMemo(() => computeLayout(projects), [projects]);
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start end", "end start"],
@@ -92,9 +185,17 @@ export const ProjectsStage = ({ projects }: ProjectsStageProps) => {
<div
ref={sectionRef}
className="relative"
style={{ height: `${sectionVh}vh` }}
style={{ height: `${sectionVh * 100}vh` }}
data-projects-stage
>
{snaps.map((snap, i) => (
<div
key={`snap-${i}-${snap.label}`}
aria-hidden="true"
className="pointer-events-none absolute left-0 h-px w-px"
style={{ top: `${snap.topVh * 100}vh`, scrollSnapAlign: "center" }}
/>
))}
<div className="sticky top-0 h-screen overflow-hidden">
{projects.map((project, i) => (
<ProjectLayer
@@ -104,6 +205,7 @@ export const ProjectsStage = ({ projects }: ProjectsStageProps) => {
total={projects.length}
inputRange={layers[i].inputRange}
outputRange={layers[i].outputRange}
anchors={anchors[i]}
sectionProgress={scrollYProgress}
/>
))}
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useRef, useState } from "react";
type ScaledIframeProps = {
src: string;
alt: string;
simulatedWidth: number;
aspectRatio?: number;
};
export const ScaledIframe = ({
src,
alt,
simulatedWidth,
aspectRatio = 16 / 10,
}: ScaledIframeProps) => {
const wrapperRef = useRef<HTMLAnchorElement>(null);
const [scale, setScale] = useState(0);
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const update = () => setScale(el.offsetWidth / simulatedWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [simulatedWidth]);
const simulatedHeight = simulatedWidth / aspectRatio;
return (
<a
ref={wrapperRef}
href={src}
target="_blank"
rel="noreferrer noopener"
aria-label={alt}
className="absolute inset-0 block overflow-hidden"
>
<iframe
src={src}
title={alt}
loading="lazy"
tabIndex={-1}
className="pointer-events-none origin-top-left border-0 bg-background"
style={{
width: `${simulatedWidth}px`,
height: `${simulatedHeight}px`,
transform: `scale(${scale})`,
}}
/>
</a>
);
};
+11 -8
View File
@@ -1,12 +1,13 @@
"use client";
import { backOut, easeIn, easeOut, type MotionValue, m, useTransform } from "motion/react";
import { linear, PHASES, stagger } from "./lifecycle";
import { backOut, easeInOut, type MotionValue, m, useTransform } from "motion/react";
import { type Anchors, linear, phaseFor, stagger } from "./lifecycle";
import { TechKey } from "./tech-key";
type TechKeyDeckProps = {
techStack: readonly string[];
localProgress: MotionValue<number>;
anchors: Anchors;
};
type TechKeyWrapperProps = {
@@ -14,29 +15,30 @@ type TechKeyWrapperProps = {
index: number;
total: number;
localProgress: MotionValue<number>;
anchors: Anchors;
};
const TechKeyWrapper = ({ techKey, index, total, localProgress }: TechKeyWrapperProps) => {
const phase = stagger(PHASES.techKeys, index, total, 0.6, true);
const TechKeyWrapper = ({ techKey, index, total, localProgress, anchors }: TechKeyWrapperProps) => {
const phase = stagger(phaseFor("techKeys", anchors), index, total, 0.6, true);
const { enterStart, enterEnd, exitStart, exitEnd } = phase;
const opacity = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[0, 1, 1, 0],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
const y = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[-140, 0, 0, 140],
{ ease: [backOut, linear, easeIn] },
{ ease: [backOut, linear, easeInOut] },
);
const rotate = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[index % 2 === 0 ? -18 : 18, 0, 0, index % 2 === 0 ? -32 : 32],
{ ease: [backOut, linear, easeIn] },
{ ease: [backOut, linear, easeInOut] },
);
return (
@@ -46,7 +48,7 @@ const TechKeyWrapper = ({ techKey, index, total, localProgress }: TechKeyWrapper
);
};
export const TechKeyDeck = ({ techStack, localProgress }: TechKeyDeckProps) => {
export const TechKeyDeck = ({ techStack, localProgress, anchors }: TechKeyDeckProps) => {
return (
<div className="flex flex-wrap items-center gap-2 pt-2">
{techStack.map((tech, i) => (
@@ -56,6 +58,7 @@ export const TechKeyDeck = ({ techStack, localProgress }: TechKeyDeckProps) => {
index={i}
total={techStack.length}
localProgress={localProgress}
anchors={anchors}
/>
))}
</div>
+138 -91
View File
@@ -1,66 +1,12 @@
"use client";
import {
AnimatePresence,
easeIn,
easeOut,
type MotionValue,
m,
useMotionValueEvent,
useTransform,
} from "motion/react";
import { easeInOut, type MotionValue, m, useMotionValueEvent, useTransform } from "motion/react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import ArrowUpRightIcon from "@/components/icons/arrow-up-right.svg";
import type { ProjectMedia } from "@/content/projects";
import { linear, PHASES, VISUAL_BODY } from "./lifecycle";
type ScaledIframeProps = {
src: string;
alt: string;
simulatedWidth: number;
aspectRatio?: number;
};
const ScaledIframe = ({ src, alt, simulatedWidth, aspectRatio = 16 / 10 }: ScaledIframeProps) => {
const wrapperRef = useRef<HTMLAnchorElement>(null);
const [scale, setScale] = useState(0);
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const update = () => setScale(el.offsetWidth / simulatedWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [simulatedWidth]);
const simulatedHeight = simulatedWidth / aspectRatio;
return (
<a
ref={wrapperRef}
href={src}
target="_blank"
rel="noreferrer noopener"
aria-label={alt}
className="absolute inset-0 block overflow-hidden"
>
<iframe
src={src}
title={alt}
loading="lazy"
tabIndex={-1}
className="pointer-events-none origin-top-left border-0 bg-background"
style={{
width: `${simulatedWidth}px`,
height: `${simulatedHeight}px`,
transform: `scale(${scale})`,
}}
/>
</a>
);
};
import { type Anchors, linear, peakProgressFor, phaseFor } from "./lifecycle";
import { ScaledIframe } from "./scaled-iframe";
type VisualShowcaseProps = {
media: readonly ProjectMedia[];
@@ -68,6 +14,7 @@ type VisualShowcaseProps = {
priority?: boolean;
sideSign: -1 | 1;
active: boolean;
anchors: Anchors;
};
const renderMedia = (item: ProjectMedia, priority: boolean, active: boolean) => {
@@ -104,9 +51,10 @@ const renderMedia = (item: ProjectMedia, priority: boolean, active: boolean) =>
href={item.src}
target="_blank"
rel="noreferrer noopener"
className="flex size-full items-center justify-center bg-foreground/[0.04] font-mono text-foreground/60 text-sm"
className="flex size-full items-center justify-center gap-2 bg-foreground/[0.04] font-mono text-foreground/60 text-sm"
>
Open live demo
<span>Open live demo</span>
<ArrowUpRightIcon className="size-4" />
</a>
</noscript>
);
@@ -141,53 +89,152 @@ const renderMedia = (item: ProjectMedia, priority: boolean, active: boolean) =>
}
};
type SlideLayerProps = {
item: ProjectMedia;
index: number;
totalSlides: number;
peaks: number[];
localProgress: MotionValue<number>;
priority: boolean;
active: boolean;
};
const SlideLayer = ({
item,
index,
totalSlides,
peaks,
localProgress,
priority,
active,
}: SlideLayerProps) => {
const [input, output] = useMemo<[number[], number[]]>(() => {
if (totalSlides === 1)
return [
[0, 1],
[1, 1],
];
if (index === 0)
return [
[peaks[0], peaks[1]],
[1, 0],
];
if (index === totalSlides - 1) {
return [
[peaks[totalSlides - 2], peaks[totalSlides - 1]],
[0, 1],
];
}
return [
[peaks[index - 1], peaks[index], peaks[index + 1]],
[0, 1, 0],
];
}, [index, totalSlides, peaks]);
const opacity = useTransform(localProgress, input, output, {
ease: easeInOut,
clamp: true,
});
return (
<m.div style={{ opacity }} className="absolute inset-0">
{renderMedia(item, priority, active)}
</m.div>
);
};
export const VisualShowcase = ({
media,
localProgress,
priority = false,
sideSign,
active,
anchors,
}: VisualShowcaseProps) => {
const bodyProgress = useTransform(localProgress, [VISUAL_BODY.start, VISUAL_BODY.end], [0, 1], {
clamp: true,
// biome-ignore lint/correctness/useExhaustiveDependencies: media.map identity is irrelevant; media.length covers array changes
const peaks = useMemo(
() => media.map((_, j) => peakProgressFor(j, media.length, anchors)),
[media.length, anchors],
);
// Slide is mountable when L lies inside its visible window [peak_{j-1}, peak_{j+1}],
// with a small margin so the slide is already mounted by the time its opacity becomes non-zero.
const MARGIN = 0.01;
// biome-ignore lint/correctness/useExhaustiveDependencies: media.map identity is irrelevant; media.length covers array changes
const windowFor = useMemo(
() =>
media.map((_, j) => ({
from: j > 0 ? peaks[j - 1] - MARGIN : Number.NEGATIVE_INFINITY,
to: j < media.length - 1 ? peaks[j + 1] + MARGIN : Number.POSITIVE_INFINITY,
})),
[media.length, peaks],
);
const compute = (L: number) => {
const mounted: number[] = [];
let dominantJ = 0;
let dominantDist = Number.POSITIVE_INFINITY;
for (let j = 0; j < media.length; j++) {
const w = windowFor[j];
if (L >= w.from && L <= w.to) mounted.push(j);
const dist = Math.abs(L - peaks[j]);
if (dist < dominantDist) {
dominantDist = dist;
dominantJ = j;
}
}
return { mounted, dominantJ };
};
const [mountedIndices, setMountedIndices] = useState<number[]>(media.length > 0 ? [0] : []);
const [dominantIndex, setDominantIndex] = useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: one-shot initial sync; subsequent updates flow through useMotionValueEvent
useEffect(() => {
const { mounted, dominantJ } = compute(localProgress.get());
setMountedIndices(mounted);
setDominantIndex(dominantJ);
}, []);
useMotionValueEvent(localProgress, "change", (L) => {
const { mounted, dominantJ } = compute(L);
setMountedIndices((prev) =>
prev.length === mounted.length && prev.every((v, i) => v === mounted[i]) ? prev : mounted,
);
setDominantIndex((prev) => (prev === dominantJ ? prev : dominantJ));
});
const [index, setIndex] = useState(0);
useMotionValueEvent(bodyProgress, "change", (value) => {
if (media.length <= 1) return;
const next = Math.max(0, Math.min(Math.floor(value * media.length), media.length - 1));
setIndex((prev) => (prev === next ? prev : next));
});
const { enterStart, enterEnd, exitStart, exitEnd } = PHASES.visual;
const { enterStart, enterEnd, exitStart, exitEnd } = phaseFor("visual", anchors);
const opacity = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[0, 1, 1, 0],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
const scale = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[0.88, 1, 1, 0.84],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
const rotate = useTransform(
localProgress,
[enterStart, enterEnd, exitStart, exitEnd],
[sideSign * -3, 0, 0, sideSign * 3],
{ ease: [easeOut, linear, easeIn] },
{ ease: [easeInOut, linear, easeInOut] },
);
const filter = useTransform(localProgress, [exitStart, exitEnd], ["blur(0px)", "blur(10px)"], {
clamp: true,
ease: easeInOut,
});
const enterClip = useTransform(localProgress, [enterStart, enterEnd], [0, 110], {
clamp: true,
ease: easeInOut,
});
const enterClip = useTransform(localProgress, [enterStart, enterEnd], [0, 110], { clamp: true });
const clipPath = useTransform(enterClip, (v) => `circle(${v}% at 50% 50%)`);
const item = media[index];
if (!item) return null;
if (media.length === 0) return null;
return (
<m.div style={{ opacity, scale, rotate, filter }} className="relative w-full">
@@ -195,18 +242,18 @@ export const VisualShowcase = ({
style={{ clipPath }}
className="relative aspect-16/10 w-full overflow-hidden rounded-lg bg-foreground/4 ring-1 ring-foreground/10 ring-inset"
>
<AnimatePresence mode="sync">
<m.div
key={index}
initial={{ opacity: 0, scale: 1.03 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.55, ease: [0.22, 0.61, 0.36, 1] }}
className="absolute inset-0"
>
{renderMedia(item, priority, active)}
</m.div>
</AnimatePresence>
{mountedIndices.map((j) => (
<SlideLayer
key={j}
item={media[j]}
index={j}
totalSlides={media.length}
peaks={peaks}
localProgress={localProgress}
priority={priority && j === 0}
active={active}
/>
))}
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-lg ring-1 ring-[oklch(var(--project-accent)/0.25)] ring-inset"
@@ -220,7 +267,7 @@ export const VisualShowcase = ({
// biome-ignore lint/suspicious/noArrayIndexKey: media list is static and positional
key={i}
className={
i === index
i === dominantIndex
? "h-0.5 w-8 bg-[oklch(var(--project-accent))] transition-all duration-500"
: "h-0.5 w-4 bg-foreground/20 transition-all duration-500"
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</svg>

After

Width:  |  Height:  |  Size: 241 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>

After

Width:  |  Height:  |  Size: 240 B

+3 -2
View File
@@ -1,3 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>

Before

Width:  |  Height:  |  Size: 788 B

After

Width:  |  Height:  |  Size: 489 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" aria-hidden="true" viewBox="0 0 24 24">
<path d="M21.34.6H2.66A2.06 2.06 0 0 0 .6 2.66v18.68a2.06 2.06 0 0 0 2.06 2.06h18.68a2.06 2.06 0 0 0 2.06-2.06V2.66A2.06 2.06 0 0 0 21.34.6M7.65 20.29a.6.6 0 0 1-.6.6H4.5a.6.6 0 0 1-.6-.6V9.58c0-.34.27-.6.6-.6h2.55c.33 0 .6.26.6.6zM5.78 7.97a2.43 2.43 0 1 1 0-4.86 2.43 2.43 0 0 1 0 4.86M21 20.34a.55.55 0 0 1-.55.55H17.7a.55.55 0 0 1-.55-.55V15.3c0-.75.22-3.28-1.96-3.28-1.69 0-2.03 1.73-2.1 2.51v5.8a.55.55 0 0 1-.55.55H9.9a.55.55 0 0 1-.55-.55V9.53c0-.3.24-.55.55-.55h2.65c.3 0 .55.24.55.55v.93c.63-.94 1.56-1.66 3.54-1.66 4.4 0 4.37 4.1 4.37 6.35z" />
</svg>

After

Width:  |  Height:  |  Size: 746 B

+2 -3
View File
@@ -1,4 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m3 11 18-5v12L3 14v-3z" />
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" aria-hidden="true" viewBox="0 0 24 24">
<path d="m3 11 18-5v12L3 14zm8.6 5.3a3 3 0 1 1-5.8-1.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 248 B

+6
View File
@@ -6,6 +6,7 @@ export type AboutContent = {
personalFacts: readonly {
label: string;
value: string;
cta?: { label: string; href: string };
}[];
transitionalStatement: string;
};
@@ -23,6 +24,11 @@ export const about = {
],
},
personalFacts: [
{
label: "What I do",
value: "Senior frontend engineer, six years in the trade",
cta: { label: "CV", href: "/luisdralves-cv.pdf" },
},
{
label: "Based in",
value: "Viana do Castelo, Portugal. Looking to relocate.",
-5
View File
@@ -1,5 +1,4 @@
import type { FC, SVGProps } from "react";
import DocumentIcon from "@/components/icons/document.svg";
import GiteaIcon from "@/components/icons/gitea.svg";
import GitHubIcon from "@/components/icons/github.svg";
import SourceIcon from "@/components/icons/source.svg";
@@ -21,10 +20,6 @@ export type HostInfo = {
};
export const resolveHost = (url: string): HostInfo => {
if (url.toLowerCase().endsWith(".pdf")) {
return { name: "Document", Icon: DocumentIcon };
}
let hostname: string;
try {
hostname = new URL(url).hostname.toLowerCase();