feat: add scroll-driven product storytelling

This commit is contained in:
2026-08-20 22:15:45 -04:00
parent e466b25837
commit 0a353afeb7
21 changed files with 128 additions and 14 deletions
+5 -2
View File
@@ -70,5 +70,8 @@ Export **in-app content only** from the simulator or device — no simulator chr
The CSS frame dimensions assume tall iPhone screenshots and are controlled in
`src/components/phone-mockup.tsx`. Use `captureMode="simulator"` only for old
captures that already include device chrome. Homepage product images are
composited separately at 810 × 1656 with transparent backgrounds and rendered
through `src/components/product-device.tsx`.
composited at the bezel source size of 1350 × 2760 with transparent
backgrounds and rendered responsively through
`src/components/product-device.tsx`. The homepage uses CSS view timelines for
scroll-scrubbed device and chapter motion, with a static fallback and a
reduced-motion override.
+5 -1
View File
@@ -5,6 +5,10 @@
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {};
const config = {
images: {
qualities: [75, 92],
},
};
export default config;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 185 KiB

+15 -8
View File
@@ -131,6 +131,7 @@ export default function HomePage() {
src="/screenshots/pitch/summary-natural.webp"
darkSrc="/screenshots/pitch/summary-natural-dark.webp"
alt="Medscribe visit summary in a natural titanium iPhone product bezel"
className="pitch-device-motion pitch-device-drift-left"
sizes="(min-width: 1024px) 416px, (min-width: 640px) 352px, 272px"
priority
/>
@@ -140,6 +141,7 @@ export default function HomePage() {
src="/screenshots/pitch/home-black.webp"
darkSrc="/screenshots/pitch/home-black-dark.webp"
alt="Medscribe home screen in a black titanium iPhone product bezel"
className="pitch-device-motion pitch-device-drift-right"
sizes="(min-width: 1024px) 448px, (min-width: 640px) 368px, 288px"
priority
/>
@@ -151,7 +153,7 @@ export default function HomePage() {
</section>
<section className="pitch-lazy border-border-soft bg-surface/80 flex min-h-[78svh] items-center border-y px-4 py-24 sm:px-6 lg:py-32">
<div className="mx-auto max-w-6xl text-center">
<div className="pitch-copy-motion mx-auto max-w-6xl text-center">
<p className="text-primary text-xs font-bold tracking-[0.2em] uppercase">
The missing picture
</p>
@@ -190,10 +192,10 @@ export default function HomePage() {
</div>
<ol className="space-y-8 sm:space-y-12">
{visitMoments.map((moment) => (
{visitMoments.map((moment, index) => (
<li
key={moment.number}
className="border-border-soft bg-surface relative overflow-hidden rounded-[2.5rem] border px-5 pt-8 shadow-[0_30px_90px_rgba(15,23,42,0.08)] sm:px-10 sm:pt-10 dark:shadow-[0_30px_90px_rgba(0,0,0,0.25)]"
className="pitch-card-motion border-border-soft bg-surface relative overflow-hidden rounded-[2.5rem] border px-5 pt-8 shadow-[0_30px_90px_rgba(15,23,42,0.08)] sm:px-10 sm:pt-10 dark:shadow-[0_30px_90px_rgba(0,0,0,0.25)]"
>
<div className="relative z-10 max-w-xl">
<div className="flex items-center gap-3">
@@ -217,6 +219,7 @@ export default function HomePage() {
src={moment.image}
darkSrc={moment.darkImage}
alt={moment.alt}
className={`pitch-device-motion ${index % 2 === 0 ? "pitch-device-drift-right" : "pitch-device-drift-left"}`}
sizes="(min-width: 1024px) 336px, (min-width: 640px) 320px, 72vw"
/>
</div>
@@ -229,7 +232,7 @@ export default function HomePage() {
<section className="pitch-lazy relative isolate overflow-hidden bg-[#082f2c] px-4 py-24 text-white sm:px-6 lg:py-36">
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_78%_18%,rgba(45,212,191,0.25),transparent_32%),radial-gradient(circle_at_16%_80%,rgba(20,184,166,0.15),transparent_28%)]" />
<div className="mx-auto max-w-7xl">
<div className="mx-auto max-w-4xl text-center">
<div className="pitch-copy-motion mx-auto max-w-4xl text-center">
<p className="text-xs font-bold tracking-[0.2em] text-teal-200 uppercase">
Between visits
</p>
@@ -249,6 +252,7 @@ export default function HomePage() {
src="/screenshots/pitch/medication-detail-natural.webp"
darkSrc="/screenshots/pitch/medication-detail-natural-dark.webp"
alt="Medscribe medication detail in a natural titanium iPhone product bezel"
className="pitch-device-motion pitch-device-drift-left"
sizes="(min-width: 1024px) 368px, (min-width: 640px) 336px, 272px"
/>
</div>
@@ -257,6 +261,7 @@ export default function HomePage() {
src="/screenshots/pitch/medications-black.webp"
darkSrc="/screenshots/pitch/medications-black-dark.webp"
alt="Medscribe medication list in a black titanium iPhone product bezel"
className="pitch-device-motion pitch-device-drift-right"
sizes="(min-width: 1024px) 384px, (min-width: 640px) 352px, 288px"
/>
</div>
@@ -297,7 +302,7 @@ export default function HomePage() {
<section className="pitch-lazy bg-[#f0ece4] px-4 py-24 sm:px-6 lg:py-36 dark:bg-[#111827]">
<div className="mx-auto grid max-w-7xl items-center gap-14 lg:grid-cols-[1fr_0.9fr] lg:gap-24">
<div className="order-2 lg:order-1">
<div className="pitch-copy-motion order-2 lg:order-1">
<p className="text-primary text-xs font-bold tracking-[0.2em] uppercase">
Ask from the record
</p>
@@ -319,6 +324,7 @@ export default function HomePage() {
src="/screenshots/pitch/ask-black.webp"
darkSrc="/screenshots/pitch/ask-black-dark.webp"
alt="Medscribe Ask response grounded in local health context, shown in a black titanium iPhone product bezel"
className="pitch-device-motion pitch-device-drift-right"
sizes="(min-width: 1024px) 432px, (min-width: 640px) 384px, 78vw"
/>
</div>
@@ -334,10 +340,11 @@ export default function HomePage() {
src="/screenshots/pitch/know-me-natural.webp"
darkSrc="/screenshots/pitch/know-me-natural-dark.webp"
alt="Medscribe Know Me handoff record in a natural titanium iPhone product bezel"
className="pitch-device-motion pitch-device-drift-left"
sizes="(min-width: 1024px) 448px, (min-width: 640px) 400px, 80vw"
/>
</div>
<div>
<div className="pitch-copy-motion">
<p className="text-primary text-xs font-bold tracking-[0.2em] uppercase">
At the handoff
</p>
@@ -425,7 +432,7 @@ export default function HomePage() {
<section className="pitch-lazy relative isolate overflow-hidden bg-[#070d15] px-4 py-24 text-white sm:px-6 lg:py-36">
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_0%,rgba(45,212,191,0.13),transparent_36%)]" />
<div className="mx-auto max-w-7xl">
<div className="mx-auto max-w-4xl text-center">
<div className="pitch-copy-motion mx-auto max-w-4xl text-center">
<p className="text-xs font-bold tracking-[0.2em] text-teal-300 uppercase">
A precise privacy boundary
</p>
@@ -486,7 +493,7 @@ export default function HomePage() {
</section>
<section className="pitch-lazy flex min-h-[82svh] items-center px-4 py-24 sm:px-6 lg:py-32">
<div className="mx-auto w-full max-w-6xl overflow-hidden rounded-[3rem] border border-teal-900/10 bg-[linear-gradient(145deg,#ccfbf1_0%,#f0fdfa_44%,#ffffff_100%)] px-6 py-16 text-center shadow-[0_35px_110px_rgba(15,118,110,0.15)] sm:px-10 lg:px-20 lg:py-24 dark:border-teal-300/10 dark:bg-[linear-gradient(145deg,#134e4a_0%,#0f2930_48%,#111827_100%)]">
<div className="pitch-copy-motion mx-auto w-full max-w-6xl overflow-hidden rounded-[3rem] border border-teal-900/10 bg-[linear-gradient(145deg,#ccfbf1_0%,#f0fdfa_44%,#ffffff_100%)] px-6 py-16 text-center shadow-[0_35px_110px_rgba(15,118,110,0.15)] sm:px-10 lg:px-20 lg:py-24 dark:border-teal-300/10 dark:bg-[linear-gradient(145deg,#134e4a_0%,#0f2930_48%,#111827_100%)]">
<p className="text-primary text-xs font-bold tracking-[0.2em] uppercase">
The right beta participant
</p>
+4 -3
View File
@@ -26,12 +26,13 @@ export function ProductDevice({
}: ProductDeviceProps) {
const shared = {
alt,
width: 810,
height: 1656,
width: 1350,
height: 2760,
sizes,
priority,
quality: 92,
className: cn(
"h-auto w-full drop-shadow-[0_35px_35px_rgba(15,23,42,0.22)] dark:drop-shadow-[0_38px_42px_rgba(0,0,0,0.5)]",
"h-auto w-full translate-z-0 drop-shadow-[0_35px_35px_rgba(15,23,42,0.22)] dark:drop-shadow-[0_38px_42px_rgba(0,0,0,0.5)]",
className,
),
};
+99
View File
@@ -99,6 +99,97 @@
contain-intrinsic-size: auto 1000px;
}
/*
* Scroll-scrubbed product motion. This stays CSS-only so the long homepage
* remains a server component and ships no scroll listener or animation
* runtime. Browsers without view timelines simply render the resting layout.
*/
.pitch-device-motion {
--pitch-enter-x: 0px;
--pitch-enter-rotate: 0deg;
--pitch-exit-x: 0px;
--pitch-exit-rotate: 0deg;
backface-visibility: hidden;
transform-origin: center;
}
.pitch-device-drift-left {
--pitch-enter-x: -2.25rem;
--pitch-enter-rotate: -2deg;
--pitch-exit-x: 1.5rem;
--pitch-exit-rotate: 1.25deg;
}
.pitch-device-drift-right {
--pitch-enter-x: 2.25rem;
--pitch-enter-rotate: 2deg;
--pitch-exit-x: -1.5rem;
--pitch-exit-rotate: -1.25deg;
}
@keyframes pitch-device-scroll {
0% {
opacity: 0.42;
transform: translate3d(var(--pitch-enter-x), 7rem, 0) scale(0.92)
rotate(var(--pitch-enter-rotate));
}
38% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1) rotate(0deg);
}
72% {
opacity: 1;
transform: translate3d(0, -1.5rem, 0) scale(1.018) rotate(0deg);
}
100% {
opacity: 0.78;
transform: translate3d(var(--pitch-exit-x), -5rem, 0) scale(0.97)
rotate(var(--pitch-exit-rotate));
}
}
@keyframes pitch-copy-scroll {
from {
opacity: 0.2;
transform: translate3d(0, 3.5rem, 0) scale(0.985);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes pitch-card-scroll {
from {
opacity: 0.58;
transform: translate3d(0, 4rem, 0) scale(0.97);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@supports (animation-timeline: view()) {
.pitch-device-motion {
animation: pitch-device-scroll linear both;
animation-timeline: view(block);
animation-range: entry 0% exit 100%;
}
.pitch-copy-motion {
animation: pitch-copy-scroll linear both;
animation-timeline: view(block);
animation-range: entry 5% cover 38%;
}
.pitch-card-motion {
animation: pitch-card-scroll linear both;
animation-timeline: view(block);
animation-range: entry 0% cover 32%;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
@@ -116,6 +207,14 @@
.card-hover:hover {
transform: none;
}
.pitch-device-motion,
.pitch-copy-motion,
.pitch-card-motion {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
/*