Skip to content

Commit 61514df

Browse files
committed
feat: build poster-style fork landing page
1 parent badb07b commit 61514df

6 files changed

Lines changed: 469 additions & 49 deletions

File tree

app/fork/fork-scroll.tsx

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
"use client";
2+
3+
import Image from "next/image";
4+
import Link from "next/link";
5+
import { motion, useMotionValue, useTransform } from "framer-motion";
6+
import type { MotionValue } from "framer-motion";
7+
import { useEffect } from "react";
8+
import type { ReactNode } from "react";
9+
10+
const howItWorks = [
11+
["01", "one city", "one fork"],
12+
["02", "student-led", "always"],
13+
["03", "local identity", "shared mission"],
14+
["04", "ship publicly", "within 90 days"],
15+
["05", "core support", "without hand-holding"],
16+
];
17+
18+
const benefits = [
19+
"brand resources",
20+
"fork playbook",
21+
"website structure",
22+
"council support",
23+
"launch kit",
24+
"templates + assets",
25+
];
26+
27+
function Texture() {
28+
return (
29+
<>
30+
<div className="absolute inset-0 bg-[#97192C]" aria-hidden />
31+
<div
32+
className="absolute inset-0 opacity-45 [background-image:radial-gradient(circle_at_18%_20%,rgba(18,15,10,0.2),transparent_22%),radial-gradient(circle_at_82%_76%,rgba(252,146,13,0.12),transparent_20%),linear-gradient(90deg,rgba(18,15,10,0.08),transparent_40%,rgba(208,207,206,0.05))]"
33+
aria-hidden
34+
/>
35+
<div className="absolute inset-0 bg-halftone opacity-25 mix-blend-soft-light" aria-hidden />
36+
<div className="absolute inset-0 bg-noise-texture opacity-55 mix-blend-overlay" aria-hidden />
37+
<div
38+
className="absolute inset-0 opacity-[0.22] [background-image:linear-gradient(106deg,transparent_0%,transparent_48%,rgba(208,207,206,0.24)_49%,transparent_50%,transparent_100%),linear-gradient(8deg,transparent_0%,transparent_59%,rgba(18,15,10,0.34)_60%,transparent_61%,transparent_100%)]"
39+
aria-hidden
40+
/>
41+
</>
42+
);
43+
}
44+
45+
function PosterWord({ progress }: { progress: MotionValue<number> }) {
46+
const y = useTransform(progress, [0, 0.45], ["0vh", "-22vh"]);
47+
const opacity = useTransform(progress, [0, 0.6], [0.56, 0.08]);
48+
const scale = useTransform(progress, [0, 0.5], [1, 1.08]);
49+
50+
return (
51+
<motion.div
52+
style={{ y, opacity, scale }}
53+
className="pointer-events-none absolute -left-[6vw] top-[8vh] z-0 font-display text-[24vw] font-black uppercase leading-[0.72] tracking-[-0.12em] text-[#120F0A]"
54+
aria-hidden
55+
>
56+
FORKS
57+
</motion.div>
58+
);
59+
}
60+
61+
function PaperCard({
62+
progress,
63+
start,
64+
end,
65+
className,
66+
children,
67+
rotateFrom = -2,
68+
}: {
69+
progress: MotionValue<number>;
70+
start: number;
71+
end: number;
72+
className: string;
73+
children: ReactNode;
74+
rotateFrom?: number;
75+
}) {
76+
const opacity = useTransform(progress, [start - 0.04, start, end, end + 0.04], [0, 1, 1, 0]);
77+
const y = useTransform(progress, [start - 0.04, start], ["12vh", "0vh"]);
78+
const rotate = useTransform(progress, [start - 0.04, start], [rotateFrom, 0]);
79+
const scale = useTransform(progress, [start - 0.04, start], [0.96, 1]);
80+
81+
return (
82+
<motion.div style={{ opacity, y, rotate, scale }} className={className}>
83+
<div className="absolute inset-0 bg-noise-texture opacity-25 mix-blend-multiply" aria-hidden />
84+
<div className="relative">{children}</div>
85+
</motion.div>
86+
);
87+
}
88+
89+
function PastedCard({
90+
number,
91+
title,
92+
line,
93+
index,
94+
exit,
95+
progress,
96+
}: {
97+
number: string;
98+
title: string;
99+
line: string;
100+
index: number;
101+
exit: number;
102+
progress: MotionValue<number>;
103+
}) {
104+
const start = 0.33 + index * 0.028;
105+
const opacity = useTransform(progress, [start, start + 0.035, exit, exit + 0.04], [0, 1, 1, 0]);
106+
const y = useTransform(progress, [start, start + 0.04], ["9vh", "0vh"]);
107+
const rotate = useTransform(progress, [start, start + 0.04], [index % 2 ? 3 : -3, index % 2 ? -1 : 1]);
108+
109+
return (
110+
<motion.div
111+
style={{ opacity, y, rotate }}
112+
className="relative min-h-[10.5rem] overflow-hidden bg-[#D0CFCE] p-4 text-[#120F0A] shadow-[9px_9px_0_#120F0A]"
113+
>
114+
<div className="absolute inset-0 bg-noise-texture opacity-25 mix-blend-multiply" aria-hidden />
115+
<div className="relative">
116+
<p className="font-mono text-xs font-black text-[#97192C]">{number}</p>
117+
<h3 className="mt-4 text-balance font-display text-[clamp(1.35rem,2.35vw,2.15rem)] font-black uppercase leading-[0.95] tracking-[-0.035em]">
118+
{title}
119+
</h3>
120+
<p className="mt-3 font-serif-brand text-base leading-tight text-[#120F0A]/78">{line}</p>
121+
</div>
122+
</motion.div>
123+
);
124+
}
125+
126+
function HowItWorksMobile({ progress }: { progress: MotionValue<number> }) {
127+
const opacity = useTransform(progress, [0.31, 0.36, 0.49, 0.54], [0, 1, 1, 0]);
128+
const y = useTransform(progress, [0.31, 0.36], ["8vh", "0vh"]);
129+
130+
return (
131+
<motion.div
132+
style={{ opacity, y }}
133+
className="absolute inset-x-4 top-[max(5rem,10svh)] z-40 bg-[#D0CFCE] p-4 text-[#120F0A] shadow-[9px_9px_0_#120F0A] md:hidden"
134+
>
135+
<div className="absolute inset-0 bg-noise-texture opacity-25 mix-blend-multiply" aria-hidden />
136+
<div className="relative">
137+
<p className="w-fit bg-[#120F0A] px-3 py-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[#FC920D]">
138+
how it works
139+
</p>
140+
<div className="mt-4 grid gap-2">
141+
{howItWorks.map(([number, title, line]) => (
142+
<div key={number} className="grid grid-cols-[2.2rem_1fr] gap-3 border-t border-[#120F0A]/20 py-3 first:border-t-0">
143+
<span className="font-mono text-xs font-black text-[#97192C]">{number}</span>
144+
<div>
145+
<h3 className="font-display text-[clamp(1.35rem,8vw,2rem)] font-black uppercase leading-[0.92] tracking-[-0.035em]">
146+
{title}
147+
</h3>
148+
<p className="mt-1 font-serif-brand text-base leading-tight text-[#120F0A]/75">{line}</p>
149+
</div>
150+
</div>
151+
))}
152+
</div>
153+
</div>
154+
</motion.div>
155+
);
156+
}
157+
158+
function BenefitLabel({ item, index, progress }: { item: string; index: number; progress: MotionValue<number> }) {
159+
const start = 0.67 + index * 0.022;
160+
const opacity = useTransform(progress, [start, start + 0.035, 0.81, 0.85], [0, 1, 1, 0]);
161+
const x = useTransform(progress, [start, start + 0.035], [index % 2 ? 60 : -60, 0]);
162+
const rotate = index % 2 ? -2 : 2;
163+
164+
return (
165+
<motion.div
166+
style={{ opacity, x, rotate }}
167+
className="bg-[#D0CFCE] px-4 py-3 font-display text-[clamp(1.15rem,2.35vw,2.15rem)] font-black uppercase leading-[0.95] tracking-[-0.035em] text-[#120F0A] shadow-[8px_8px_0_#120F0A]"
168+
>
169+
{item}
170+
</motion.div>
171+
);
172+
}
173+
174+
function BenefitsMobile({ progress }: { progress: MotionValue<number> }) {
175+
const opacity = useTransform(progress, [0.64, 0.68, 0.8, 0.84], [0, 1, 1, 0]);
176+
const y = useTransform(progress, [0.64, 0.68], ["8vh", "0vh"]);
177+
178+
return (
179+
<motion.div
180+
style={{ opacity, y }}
181+
className="absolute inset-x-4 top-[max(5rem,10svh)] z-40 bg-[#120F0A] p-4 text-[#D0CFCE] shadow-[9px_9px_0_#FC920D] md:hidden"
182+
>
183+
<p className="mb-4 w-fit bg-[#D0CFCE] px-3 py-2 font-mono text-[10px] uppercase tracking-[0.16em] text-[#120F0A]">
184+
what forks get
185+
</p>
186+
<div className="grid gap-2">
187+
{benefits.map((item) => (
188+
<div key={item} className="bg-[#D0CFCE] px-3 py-3 font-display text-[clamp(1.35rem,8vw,2.2rem)] font-black uppercase leading-[0.95] tracking-[-0.04em] text-[#120F0A]">
189+
{item}
190+
</div>
191+
))}
192+
</div>
193+
</motion.div>
194+
);
195+
}
196+
197+
export function ForkScroll({ applyUrl }: { applyUrl: string }) {
198+
const scrollYProgress = useMotionValue(0);
199+
200+
useEffect(() => {
201+
let frame = 0;
202+
203+
const updateProgress = () => {
204+
cancelAnimationFrame(frame);
205+
frame = requestAnimationFrame(() => {
206+
const page = document.documentElement;
207+
const maxScroll = Math.max(page.scrollHeight - window.innerHeight, 1);
208+
scrollYProgress.set(Math.min(window.scrollY / maxScroll, 1));
209+
});
210+
};
211+
212+
updateProgress();
213+
window.addEventListener("scroll", updateProgress, { passive: true });
214+
window.addEventListener("resize", updateProgress);
215+
216+
return () => {
217+
cancelAnimationFrame(frame);
218+
window.removeEventListener("scroll", updateProgress);
219+
window.removeEventListener("resize", updateProgress);
220+
};
221+
}, [scrollYProgress]);
222+
223+
const openingOpacity = useTransform(scrollYProgress, [0, 0.13, 0.22], [1, 1, 0]);
224+
const openingY = useTransform(scrollYProgress, [0, 0.22], ["0vh", "-16vh"]);
225+
const openingRotate = useTransform(scrollYProgress, [0, 0.22], [0, 5]);
226+
const logoReveal = useTransform(scrollYProgress, [0.16, 0.28], [0, 1]);
227+
const logoRevealY = useTransform(scrollYProgress, [0.16, 0.28], ["1.5rem", "0rem"]);
228+
const howTitleOpacity = useTransform(scrollYProgress, [0.3, 0.34, 0.48, 0.53], [0, 1, 1, 0]);
229+
const manifestoOpacity = useTransform(scrollYProgress, [0.52, 0.58, 0.64, 0.69], [0, 1, 1, 0]);
230+
const manifestoY = useTransform(scrollYProgress, [0.5, 0.58], ["12vh", "0vh"]);
231+
const finalOpacity = useTransform(scrollYProgress, [0.82, 0.94], [0, 1]);
232+
const finalY = useTransform(scrollYProgress, [0.82, 1], ["14vh", "0vh"]);
233+
234+
return (
235+
<main className="relative overflow-x-hidden bg-[#97192C] text-[#D0CFCE]" style={{ minHeight: "780svh" }}>
236+
<section className="fixed inset-0 h-[100svh] overflow-hidden bg-[#97192C]">
237+
<Texture />
238+
<PosterWord progress={scrollYProgress} />
239+
<div
240+
className="pointer-events-none absolute -bottom-[10vw] right-[-7vw] z-0 hidden font-display text-[31vw] font-black uppercase leading-none tracking-[-0.12em] text-[#120F0A]/12 md:block"
241+
aria-hidden
242+
>
243+
LEAD
244+
</div>
245+
246+
<motion.div
247+
style={{ opacity: openingOpacity, y: openingY, rotate: openingRotate }}
248+
className="absolute left-1/2 top-[max(5.25rem,18svh)] z-40 w-[min(90vw,52rem)] -translate-x-1/2 bg-[#D0CFCE] p-4 text-right text-[#120F0A] shadow-[10px_10px_0_#120F0A] sm:p-5 md:top-[30vh] md:p-8 md:shadow-[14px_14px_0_#120F0A]"
249+
>
250+
<div className="absolute inset-0 bg-noise-texture opacity-25 mix-blend-multiply" aria-hidden />
251+
<div className="relative">
252+
<h1 className="font-display text-[clamp(2.45rem,14vw,6.7rem)] font-black leading-[0.86] tracking-[-0.055em] md:leading-[0.82] md:tracking-[-0.07em]">
253+
no student
254+
<br />
255+
tech scene in
256+
<br />
257+
your city<span className="text-[#97192C]">?</span>
258+
<br />
259+
fork one<span className="text-[#97192C]">.</span>
260+
</h1>
261+
<p className="mt-4 font-serif-brand text-[clamp(1.2rem,6vw,2.55rem)] leading-tight tracking-[-0.03em] md:leading-none md:tracking-[-0.04em]">
262+
Build with people.
263+
<br />
264+
Ship publicly.
265+
</p>
266+
<div className="mt-5 bg-[#120F0A] px-4 py-3 text-left md:mt-7">
267+
<p className="whitespace-nowrap font-display text-[clamp(1.45rem,3.4vw,2.95rem)] font-black leading-none tracking-[-0.045em] text-[#D0CFCE]">
268+
bits&amp;bytes forks
269+
</p>
270+
</div>
271+
</div>
272+
</motion.div>
273+
274+
<PaperCard
275+
progress={scrollYProgress}
276+
start={0.17}
277+
end={0.29}
278+
className="absolute inset-x-4 top-[max(5rem,10svh)] z-40 mx-auto w-auto max-w-[39rem] overflow-hidden bg-[#D0CFCE] p-4 text-[#120F0A] shadow-[10px_10px_0_#120F0A] md:left-auto md:right-[6vw] md:top-[14vh] md:mx-0 md:w-[min(82vw,39rem)] md:p-7 md:shadow-[12px_12px_0_#120F0A]"
279+
rotateFrom={3}
280+
>
281+
<p className="w-fit bg-[#120F0A] px-3 py-2 font-mono text-xs uppercase tracking-[0.16em] text-[#FC920D]">
282+
identity reveal
283+
</p>
284+
<motion.div style={{ opacity: logoReveal, y: logoRevealY }} className="mt-5 flex min-w-0 flex-col items-start gap-4 sm:flex-row sm:items-center">
285+
<div className="flex h-16 w-16 shrink-0 items-center justify-center bg-[#120F0A] p-2.5 shadow-[6px_6px_0_#97192C] sm:h-20 sm:w-20 md:h-24 md:w-24 md:p-3">
286+
<Image src="/logo.svg" alt="bits&bytes logo" width={92} height={92} className="h-full w-full invert" />
287+
</div>
288+
<p className="min-w-0 whitespace-nowrap font-display text-[clamp(1.65rem,7vw,4rem)] font-black leading-none tracking-[-0.035em] text-[#120F0A] max-[390px]:text-[1.45rem]">
289+
bits&amp;bytes
290+
</p>
291+
</motion.div>
292+
<p className="mt-5 max-w-lg text-pretty font-serif-brand text-[clamp(1.08rem,5.1vw,2rem)] leading-tight text-[#120F0A]/82 md:text-[clamp(1.35rem,2.5vw,2rem)]">
293+
Not a chapter. Not a franchise. A fork: same mission, different build.
294+
</p>
295+
</PaperCard>
296+
297+
<motion.div
298+
style={{ opacity: howTitleOpacity }}
299+
className="absolute left-[4vw] top-[8vh] z-40 hidden bg-[#120F0A] px-4 py-2 font-mono text-xs uppercase tracking-[0.16em] text-[#FC920D] shadow-[6px_6px_0_rgba(18,15,10,0.35)] md:block"
300+
>
301+
how it works
302+
</motion.div>
303+
<div className="absolute inset-x-4 top-[18vh] z-30 mx-auto hidden max-w-6xl grid-cols-5 gap-4 md:grid">
304+
{howItWorks.map(([number, title, line], index) => (
305+
<PastedCard
306+
key={number}
307+
number={number}
308+
title={title}
309+
line={line}
310+
index={index}
311+
exit={0.49}
312+
progress={scrollYProgress}
313+
/>
314+
))}
315+
</div>
316+
<HowItWorksMobile progress={scrollYProgress} />
317+
318+
<motion.div
319+
style={{ opacity: manifestoOpacity, y: manifestoY }}
320+
className="absolute inset-x-4 top-[max(5rem,12svh)] z-40 mx-auto max-w-[62rem] md:left-[6vw] md:right-auto md:top-[14vh] md:mx-0"
321+
>
322+
<p className="max-w-[54rem] font-display text-[clamp(3.2rem,16vw,8.6rem)] font-black uppercase leading-[0.9] tracking-[-0.055em] text-[#120F0A] drop-shadow-[5px_5px_0_rgba(208,207,206,0.18)] md:leading-[0.82] md:tracking-[-0.075em]">
323+
own the
324+
<br />
325+
room.
326+
</p>
327+
<div className="mt-5 max-w-[38rem] bg-[#D0CFCE] p-4 text-[#120F0A] shadow-[10px_10px_0_#120F0A] md:mt-6 md:p-5 md:shadow-[12px_12px_0_#120F0A]">
328+
<p className="font-serif-brand text-[clamp(1.25rem,6vw,2.65rem)] leading-tight tracking-[-0.03em] md:leading-none md:tracking-[-0.045em]">
329+
A fork gives your city a flag, a reason to gather, and a public record of people building for real.
330+
</p>
331+
</div>
332+
</motion.div>
333+
334+
<PaperCard
335+
progress={scrollYProgress}
336+
start={0.64}
337+
end={0.82}
338+
className="absolute inset-x-4 top-[10vh] z-40 mx-auto hidden max-w-6xl bg-transparent text-[#D0CFCE] md:block"
339+
rotateFrom={0}
340+
>
341+
<p className="mb-8 w-fit bg-[#120F0A] px-4 py-2 font-mono text-xs uppercase tracking-[0.16em] text-[#FC920D] shadow-[6px_6px_0_rgba(18,15,10,0.3)]">
342+
what forks get
343+
</p>
344+
<div className="flex flex-wrap gap-4">
345+
{benefits.map((item, index) => (
346+
<BenefitLabel key={item} item={item} index={index} progress={scrollYProgress} />
347+
))}
348+
</div>
349+
</PaperCard>
350+
<BenefitsMobile progress={scrollYProgress} />
351+
352+
<motion.div
353+
style={{ opacity: finalOpacity, y: finalY }}
354+
className="absolute inset-x-4 bottom-[max(1.5rem,env(safe-area-inset-bottom))] z-50 mx-auto max-w-[58rem] bg-[#D0CFCE] p-4 text-right text-[#120F0A] shadow-[10px_10px_0_#120F0A] md:bottom-[7vh] md:p-8 md:shadow-[14px_14px_0_#120F0A]"
355+
>
356+
<div className="absolute inset-0 bg-noise-texture opacity-25 mix-blend-multiply" aria-hidden />
357+
<div className="relative">
358+
<h2 className="font-display text-[clamp(2.75rem,13vw,8.4rem)] font-black leading-[0.86] tracking-[-0.055em] md:leading-[0.76] md:tracking-[-0.09em]">
359+
lead your
360+
<br />
361+
city&apos;s fork.
362+
</h2>
363+
<p className="mt-4 font-serif-brand text-[clamp(1.15rem,5.5vw,2.6rem)] leading-tight tracking-[-0.03em] md:leading-none md:tracking-[-0.04em]">
364+
Apply if you can gather people, make noise, and ship in public.
365+
</p>
366+
<div className="mt-6 flex flex-col items-stretch gap-3 md:mt-8 md:flex-row md:items-center md:justify-between">
367+
<div className="bg-[#120F0A] px-5 py-3 text-left">
368+
<p className="whitespace-nowrap font-display text-[clamp(1.25rem,6vw,3.05rem)] font-black leading-none tracking-[-0.04em] text-[#D0CFCE]">
369+
bits&amp;bytes forks
370+
</p>
371+
</div>
372+
<Link
373+
href={applyUrl}
374+
className="inline-flex min-h-12 shrink-0 items-center justify-center bg-[#FC920D] px-7 py-4 font-display text-sm font-black uppercase tracking-[0.14em] text-[#120F0A] shadow-[6px_6px_0_#120F0A] outline-none transition-transform duration-200 ease-out hover:-translate-y-1 focus-visible:ring-4 focus-visible:ring-[#120F0A] focus-visible:ring-offset-4 focus-visible:ring-offset-[#D0CFCE] active:scale-[0.97]"
375+
>
376+
apply now
377+
</Link>
378+
</div>
379+
</div>
380+
</motion.div>
381+
</section>
382+
</main>
383+
);
384+
}

0 commit comments

Comments
 (0)