Files
mintel.me/apps/web/src/components/illustrations/WorkShowcase.tsx
T
mmintelandClaude Opus 5.5 dc4a01cbe4 feat: binary micro-interactions across the page
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>
2026-10-04 00:14:22 +02:00

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>
);