Section headlines are written out behind a column of digits, mono labels resolve from binary, the portrait assembles itself from bits, section indexes rattle and keep one bit alive, cards get corner marks, the header shows the scroll position as an eight-bit number, pressed links and buttons shed a few digits, and the work stage leans towards the pointer. All effects respect reduced motion and leave the content readable without JavaScript. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
7.9 KiB
TypeScript
244 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import Image from "next/image";
|
|
import { motion, useMotionValue, useSpring } from "framer-motion";
|
|
|
|
export type WorkItem = {
|
|
readonly name: string;
|
|
readonly href: string;
|
|
readonly client: string;
|
|
readonly text: string;
|
|
readonly did: readonly string[];
|
|
readonly tech: readonly string[];
|
|
readonly desktop: {
|
|
readonly src: string;
|
|
readonly width: number;
|
|
readonly height: number;
|
|
};
|
|
readonly mobile: {
|
|
readonly src: string;
|
|
readonly width: number;
|
|
readonly height: number;
|
|
};
|
|
};
|
|
|
|
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
|
|
|
/** How far a tall screenshot has to move so its end reaches the bottom of the frame. */
|
|
const travel = (
|
|
shot: { width: number; height: number },
|
|
frameAspect: number,
|
|
): string => {
|
|
const visible = (shot.width / frameAspect / shot.height) * 100;
|
|
return `-${Math.max(0, 100 - visible).toFixed(2)}%`;
|
|
};
|
|
|
|
/** A screenshot that scrolls slowly through its frame while it is on screen. */
|
|
const ScrollingShot: React.FC<{
|
|
shot: { src: string; width: number; height: number };
|
|
frameAspect: number;
|
|
alt: string;
|
|
sizes: string;
|
|
active: boolean;
|
|
seconds: number;
|
|
}> = ({ shot, frameAspect, alt, sizes, active, seconds }) => (
|
|
<motion.div
|
|
animate={
|
|
active ? { y: ["0%", travel(shot, frameAspect), "0%"] } : { y: "0%" }
|
|
}
|
|
transition={
|
|
active
|
|
? {
|
|
duration: seconds,
|
|
repeat: Infinity,
|
|
ease: "easeInOut",
|
|
times: [0, 0.5, 1],
|
|
}
|
|
: { duration: 0.6 }
|
|
}
|
|
>
|
|
<Image
|
|
src={shot.src}
|
|
alt={alt}
|
|
width={shot.width}
|
|
height={shot.height}
|
|
sizes={sizes}
|
|
className="block h-auto w-full"
|
|
/>
|
|
</motion.div>
|
|
);
|
|
|
|
const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
const [inView, setInView] = React.useState(false);
|
|
const [still, setStill] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
setStill(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
const node = ref.current;
|
|
if (!node) return;
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => setInView(entry?.isIntersecting ?? false),
|
|
{ threshold: 0.3 },
|
|
);
|
|
observer.observe(node);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const active = inView && !still;
|
|
|
|
// The frames lean a few degrees towards the pointer.
|
|
const tiltX = useSpring(useMotionValue(0), { stiffness: 120, damping: 18 });
|
|
const tiltY = useSpring(useMotionValue(0), { stiffness: 120, damping: 18 });
|
|
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
if (still || event.pointerType !== "mouse") return;
|
|
const box = event.currentTarget.getBoundingClientRect();
|
|
tiltY.set(((event.clientX - box.left) / box.width - 0.5) * 7);
|
|
tiltX.set(-((event.clientY - box.top) / box.height - 0.5) * 5);
|
|
};
|
|
const onPointerLeave = () => {
|
|
tiltX.set(0);
|
|
tiltY.set(0);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
onPointerMove={onPointerMove}
|
|
onPointerLeave={onPointerLeave}
|
|
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 [perspective:1400px] md:px-12 md:pt-14"
|
|
>
|
|
<div
|
|
className="pointer-events-none absolute inset-0 opacity-60"
|
|
style={{
|
|
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
|
|
backgroundSize: "20px 20px",
|
|
}}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<motion.div
|
|
className="relative mx-auto max-w-4xl"
|
|
style={{
|
|
rotateX: tiltX,
|
|
rotateY: tiltY,
|
|
transformStyle: "preserve-3d",
|
|
}}
|
|
>
|
|
{/* Desktop */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 40 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true, margin: "-10%" }}
|
|
transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}
|
|
className="overflow-hidden rounded-t-2xl border border-b-0 border-slate-700 bg-slate-950"
|
|
>
|
|
<div className="flex items-center gap-1.5 border-b border-slate-800 px-4 py-3">
|
|
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
|
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
|
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
|
<span className="ml-3 rounded-full bg-slate-800 px-3 py-1 font-mono text-[10px] tracking-[0.1em] text-slate-400">
|
|
{item.name}
|
|
</span>
|
|
</div>
|
|
<div className="aspect-[16/10] overflow-hidden">
|
|
<ScrollingShot
|
|
shot={item.desktop}
|
|
frameAspect={16 / 10}
|
|
alt={`${item.name} on a desktop screen`}
|
|
sizes="(min-width: 1024px) 900px, 100vw"
|
|
active={active}
|
|
seconds={22}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Phone */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 60 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true, margin: "-10%" }}
|
|
transition={{ duration: 0.9, delay: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
|
className="absolute -right-2 bottom-0 w-[26%] min-w-[92px] overflow-hidden rounded-t-[1.25rem] border-[3px] border-b-0 border-slate-700 bg-slate-950 shadow-2xl shadow-black/60 md:-right-8 md:rounded-t-[2rem] md:border-[5px]"
|
|
>
|
|
<div className="aspect-[9/17] overflow-hidden">
|
|
<ScrollingShot
|
|
shot={item.mobile}
|
|
frameAspect={9 / 17}
|
|
alt={`${item.name} on a phone`}
|
|
sizes="240px"
|
|
active={active}
|
|
seconds={26}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* WorkShowcase: each live site on a dark stage, as a desktop and a phone view
|
|
* that scroll through the real pages.
|
|
*/
|
|
export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
|
|
items,
|
|
}) => (
|
|
<div className="flex flex-col gap-20 md:gap-32">
|
|
{items.map((item, index) => (
|
|
<article
|
|
key={item.name}
|
|
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
|
|
>
|
|
<div className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}>
|
|
<WorkStage item={item} />
|
|
</div>
|
|
|
|
<div className="md:col-span-4">
|
|
<p className={`${MONO} m-0 text-slate-500`}>
|
|
0{index + 1} · {item.client}
|
|
</p>
|
|
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
|
|
{item.name}
|
|
</h3>
|
|
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
|
|
{item.text}
|
|
</p>
|
|
|
|
<ul className="m-0 mt-8 list-none p-0">
|
|
{item.did.map((line) => (
|
|
<li
|
|
key={line}
|
|
className="border-t border-slate-200 py-3 font-sans text-base text-slate-900 md:text-lg"
|
|
>
|
|
{line}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
|
|
{item.tech.map((tech) => (
|
|
<li
|
|
key={tech}
|
|
className="rounded-full border border-slate-200 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-600"
|
|
>
|
|
{tech}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<a
|
|
href={item.href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-8 inline-flex items-center gap-3 rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
|
>
|
|
Open the live site <span aria-hidden="true">↗</span>
|
|
</a>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
);
|