Files
mintel.me/apps/web/src/components/home/PathPicker.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

270 lines
10 KiB
TypeScript

"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { Button } from "@/src/components/Button";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes";
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
const STEP_MS = 4800;
/**
* PathStory: the four steps of one path, each with a drawing that explains it.
* The steps advance on their own until the visitor picks one.
*/
const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
path,
active,
}) => {
const [step, setStep] = React.useState(0);
const [auto, setAuto] = React.useState(true);
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(false);
React.useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => setInView(entry?.isIntersecting ?? false),
{ threshold: 0.35 },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (!active || !auto || !inView) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = window.setInterval(
() => setStep((current) => (current + 1) % path.steps.length),
STEP_MS,
);
return () => window.clearInterval(timer);
}, [active, auto, inView, path.steps.length]);
const current = path.steps[step];
return (
<div ref={ref} className="grid grid-cols-1 gap-8 md:grid-cols-12 md:gap-12">
<div className="md:order-2 md:col-span-7">
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<DecodeText
key={current?.title}
text={current?.title ?? ""}
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300"
/>
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
{step + 1} / {path.steps.length}
</span>
</div>
<div className="px-2 py-4 md:px-10 md:py-8">
<PathScene
pathId={path.id}
step={step}
title={current ? `${current.title}. ${current.text}` : path.need}
/>
</div>
</div>
</div>
<ol className="m-0 list-none p-0 md:order-1 md:col-span-5">
{path.steps.map((item, index) => {
const selected = index === step;
return (
<li key={item.title}>
<button
type="button"
aria-current={selected ? "step" : undefined}
onClick={() => {
setStep(index);
setAuto(false);
}}
className={`relative flex w-full gap-5 border-t py-6 text-left transition-colors duration-300 md:gap-6 ${
selected ? "border-slate-900" : "border-slate-200"
}`}
>
{selected && auto && inView && (
<motion.span
key={`${path.id}-${index}`}
className="absolute left-0 top-[-1px] h-px bg-slate-900"
initial={{ width: "0%" }}
animate={{ width: "100%" }}
transition={{ duration: STEP_MS / 1000, ease: "linear" }}
aria-hidden="true"
/>
)}
<span
className={`w-8 shrink-0 font-sans text-3xl font-black leading-none tracking-tighter transition-colors duration-300 md:text-4xl ${
selected ? "text-slate-900" : "text-slate-200"
}`}
>
{index + 1}
</span>
<span>
<span
className={`block font-sans text-lg font-bold tracking-tight transition-colors duration-300 md:text-xl ${
selected ? "text-slate-900" : "text-slate-500"
}`}
>
{item.title}
</span>
<span
className={`mt-1.5 block font-sans text-base leading-relaxed transition-colors duration-300 ${
selected ? "text-slate-600" : "text-slate-400"
}`}
>
{item.text}
</span>
</span>
</button>
</li>
);
})}
</ol>
</div>
);
};
/**
* PathPicker: the visitor says what they need and gets the answer for exactly that:
* what they get, how it runs, what it costs, what to do next.
* All three panels stay in the DOM so the content is readable without JavaScript.
*/
export const PathPicker: React.FC = () => {
const [activeId, setActiveId] = React.useState(SERVICE_PATHS[0]?.id ?? "");
return (
<div>
<div
role="tablist"
aria-label="What do you need?"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
>
{SERVICE_PATHS.map((path, index) => {
const selected = path.id === activeId;
return (
<button
key={path.id}
type="button"
role="tab"
id={`path-tab-${path.id}`}
aria-selected={selected}
aria-controls={`path-panel-${path.id}`}
onClick={() => setActiveId(path.id)}
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
}`}
>
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
0{index + 1}
</span>
<span className="mt-3 block font-sans text-xl font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
{path.need}
</span>
<span
className={`mt-2 block font-sans text-sm leading-relaxed md:mt-4 md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
>
{path.forWhom}
</span>
</button>
);
})}
</div>
{SERVICE_PATHS.map((path) => {
const selected = path.id === activeId;
return (
<div
key={path.id}
role="tabpanel"
id={`path-panel-${path.id}`}
aria-labelledby={`path-tab-${path.id}`}
hidden={!selected}
className="mt-12 md:mt-20"
>
<motion.div
key={selected ? "on" : "off"}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
>
<div className="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-12">
<div className="md:col-span-7">
<h3 className="m-0 font-sans text-3xl font-bold leading-[1.05] tracking-tighter text-slate-900 md:text-5xl">
{path.headline}
</h3>
<p className="m-0 mt-6 max-w-2xl font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
{path.intro}
</p>
</div>
<div className="md:col-span-5">
<p className={`${MONO} m-0`}>What you get</p>
<ul className="m-0 mt-4 list-none p-0">
{path.youGet.map((item) => (
<li
key={item}
className="flex gap-4 border-t border-slate-200 py-4 font-sans text-base text-slate-900 md:text-lg"
>
<span
className="mt-2.5 h-1.5 w-1.5 shrink-0 rounded-full bg-slate-900"
aria-hidden="true"
/>
{item}
</li>
))}
</ul>
</div>
</div>
<div className="mt-14 md:mt-24">
<p className={`${MONO} m-0 mb-6`}>How it runs</p>
<PathStory path={path} active={selected} />
</div>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>What it costs</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{path.pricingModel}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{path.pricingText}
</p>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 md:col-span-6">
{path.prices.map((price) => (
<div key={price.label}>
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
{price.label}
</p>
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
{price.amount}
</p>
<p className={`${MONO} m-0 mt-2`}>{price.note}</p>
</div>
))}
</div>
</div>
<div className="mt-10 flex flex-col gap-6 border-t border-slate-200 pt-8 md:flex-row md:items-center md:justify-between">
<p className="m-0 max-w-2xl font-sans text-base leading-relaxed text-slate-500">
{path.smallPrint}
</p>
<Button href="#contact" size="large">
{path.cta}
</Button>
</div>
</div>
</motion.div>
</div>
);
})}
</div>
);
};