168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* CASE CARD — replaces the rejected repetitive photos with a coded, animated
|
|
* data visual unique per case:
|
|
* - 3D tilt that tracks the pointer via a SOFT spring (momentum => the card
|
|
* floats and settles, never snaps). Transform-only.
|
|
* - A small lift + accent shadow on hover; a press dip (scale 0.985) so the
|
|
* whole card shares the page's tactile press language.
|
|
* - An animated bars + sparkline "result chart" (CSS + GSAP DrawSVG on the
|
|
* spark) growing into view — no two cards look alike.
|
|
* - A glare/sheen that follows the cursor across the surface.
|
|
* - The chart panel gets a clip-path inset() wipe on first view (premium).
|
|
* Reduced-motion / touch: flat card, bars still grow on view via CSS.
|
|
*/
|
|
import { useEffect, useRef } from "react";
|
|
import {
|
|
motion,
|
|
useMotionValue,
|
|
useSpring,
|
|
useTransform,
|
|
useReducedMotion,
|
|
} from "motion/react";
|
|
import { gsap } from "./gsap";
|
|
import { SPRING, EASE_OUT } from "./motion";
|
|
|
|
export type CaseData = {
|
|
tag: string;
|
|
problem: string;
|
|
result: string;
|
|
how: string;
|
|
metricNum: string;
|
|
metricLabel: string;
|
|
bars: number[]; // 0..100 heights — unique per case
|
|
accent: string; // brand accent for this card
|
|
};
|
|
|
|
export default function CaseCard({
|
|
data,
|
|
index,
|
|
}: {
|
|
data: CaseData;
|
|
index: number;
|
|
}) {
|
|
const reduce = useReducedMotion();
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const mx = useMotionValue(0.5);
|
|
const my = useMotionValue(0.5);
|
|
const rx = useSpring(useTransform(my, [0, 1], [6, -6]), SPRING.tilt);
|
|
const ry = useSpring(useTransform(mx, [0, 1], [-8, 8]), SPRING.tilt);
|
|
const glare = useTransform(
|
|
[mx, my],
|
|
([gx, gy]: number[]) =>
|
|
`radial-gradient(circle at ${gx * 100}% ${gy * 100}%, rgba(255,255,255,0.16), transparent 45%)`
|
|
);
|
|
|
|
const onMove = (e: React.PointerEvent) => {
|
|
if (reduce || e.pointerType !== "mouse") return;
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const r = el.getBoundingClientRect();
|
|
mx.set((e.clientX - r.left) / r.width);
|
|
my.set((e.clientY - r.top) / r.height);
|
|
};
|
|
const reset = () => {
|
|
mx.set(0.5);
|
|
my.set(0.5);
|
|
};
|
|
|
|
// DrawSVG the sparkline once the card scrolls in — a small, deliberate detail.
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el || reduce) return;
|
|
const ctx = gsap.context(() => {
|
|
gsap.from(el.querySelector(".case__spark polyline"), {
|
|
drawSVG: "0%",
|
|
duration: 1.1,
|
|
ease: "power2.out",
|
|
scrollTrigger: { trigger: el, start: "top 82%", once: true },
|
|
delay: 0.15 + index * 0.05,
|
|
});
|
|
}, el);
|
|
return () => ctx.revert();
|
|
}, [reduce, index]);
|
|
|
|
return (
|
|
<motion.article
|
|
ref={ref}
|
|
className="case"
|
|
onPointerMove={onMove}
|
|
onPointerLeave={reset}
|
|
style={
|
|
reduce
|
|
? undefined
|
|
: { rotateX: rx, rotateY: ry, transformPerspective: 1100 }
|
|
}
|
|
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 44, filter: "blur(4px)" }}
|
|
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
|
whileTap={reduce ? undefined : { scale: 0.985 }}
|
|
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
|
|
transition={{ duration: 0.85, delay: index * 0.08, ease: EASE_OUT }}
|
|
>
|
|
<div
|
|
className="case__inner"
|
|
style={{ ["--case-accent" as string]: data.accent }}
|
|
>
|
|
<div className="case__head">
|
|
<span className="case__no">{String(index + 1).padStart(2, "0")}</span>
|
|
<span className="case__tag">{data.tag}</span>
|
|
</div>
|
|
|
|
{/* coded data visual — unique bars per case, clip-wiped on first view */}
|
|
<motion.div
|
|
className="case__viz"
|
|
aria-hidden="true"
|
|
initial={reduce ? { opacity: 1 } : { clipPath: "inset(0 0 100% 0)" }}
|
|
whileInView={{ clipPath: "inset(0 0 0% 0)" }}
|
|
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
|
transition={{ duration: 0.9, delay: index * 0.08 + 0.1, ease: EASE_OUT }}
|
|
>
|
|
<div className="case__bars">
|
|
{data.bars.map((h, i) => (
|
|
<span
|
|
key={i}
|
|
className="case__bar"
|
|
style={{
|
|
["--h" as string]: `${h}%`,
|
|
["--d" as string]: `${i * 70}ms`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<svg className="case__spark" viewBox="0 0 120 40" preserveAspectRatio="none">
|
|
<polyline
|
|
points="0,34 24,30 48,22 72,18 96,8 120,4"
|
|
fill="none"
|
|
stroke="var(--case-accent)"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</motion.div>
|
|
|
|
<div className="case__body">
|
|
<p className="case__problem">
|
|
<span className="case__k">Before</span>
|
|
{data.problem}
|
|
</p>
|
|
<p className="case__result display">{data.result}</p>
|
|
<p className="case__how">{data.how}</p>
|
|
</div>
|
|
|
|
<div className="case__metric">
|
|
<span className="case__metric-num display">{data.metricNum}</span>
|
|
<span className="case__metric-lab">{data.metricLabel}</span>
|
|
</div>
|
|
|
|
{!reduce && (
|
|
<motion.span
|
|
className="case__glare"
|
|
aria-hidden="true"
|
|
style={{ background: glare }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</motion.article>
|
|
);
|
|
}
|