Projects & work
Projects Case Study
A project's overview, process and results, with parallax photos and photos that move into place as you scroll.
Layout and motion from the Nitip template — see it in context"use client";
import * as React from "react";
import { Parallax } from "@/components/ui/parallax";
import { ScrollReveal } from "@/components/ui/scroll-reveal";
import { cn } from "@/lib/utils";
/*
* Projects Case Study — the story of a project in three parts, each with its own media: an overview over a parallax
* photo, the process with two photos that move into place as you scroll, and the results over a second parallax photo.
* Source: marcframe/nitip → components/sections/ProjectContentSection.tsx (Overview, Process, Result) with
* design-system ParallaxImage and ScrollTransform.
*
* Layout kept from Framer: each part opens with a large heading and a divider; overview and process text sits at the
* right from 1024px (up to 56rem). Results: heading on the left and a 500px list on the right from 768px. Photos are
* 3:2 across the container; the process photos are 2:1 — the first right-aligned (85% wide from 768px, 75% from
* 1024px), the second left-aligned (80% / 60%), inset on phones and tablets. A divider closes the process.
* Motion kept, all tied to scroll and static with reduced motion:
* - Overview and result photos drift with parallax: Framer speed 80 (phone 95) moves them at 20% (5%) of the scroll,
* which over one pass is about 40% (20% below 768px) of the frame height — parallax's `speed`.
* - Process photo 1 slides in from +200px (phone +75px); photo 2 grows from 30% while sliding in from −100px
* (phone 80%, −20px) — linear, from the moment the frame enters until it is fully in view (scroll-reveal).
* The section heading is for screen readers; the three parts are h3, styled as the large headings they are on Framer.
*/
export type CaseStudyImage = { src: string; alt: string };
export type CaseStudyResult = { highlight?: string; text: string };
export type ProjectsCaseStudyProps = {
/** Section heading, visually hidden. */
title?: string;
overview?: { title: string; body: string; image: CaseStudyImage };
process?: { title: string; body: string; images: [CaseStudyImage, CaseStudyImage] };
result?: { title: string; items: CaseStudyResult[]; image: CaseStudyImage };
className?: string;
};
const photo = (id: string, w = 1800) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=${w}&q=80`;
const defaults = {
title: "Case study",
overview: {
title: "Overview",
body: "Harvest Kitchen, a growing restaurant group, had great food and quiet channels. We rebuilt its social presence around the kitchen itself: short videos of the team at work, a booking-first profile and a clear weekly rhythm of posts.",
image: { src: photo("photo-1517248135467-4c7edcad34c4"), alt: "Restaurant dining room with rows of dark tables and chairs" },
},
process: {
title: "Process",
body: "We began with an audit of every channel and a week in the kitchen. From there we mapped the guests worth reaching, wrote a content plan the team could keep up, and tested ads against real bookings rather than likes.",
images: [
{ src: photo("photo-1531403009284-440f080d1e12", 1600), alt: "Hands pointing at wireframe sketches pinned to a wall" },
{ src: photo("photo-1552664730-d307ca884978", 1400), alt: "Team at a table in front of a wall of sticky notes" },
] as [CaseStudyImage, CaseStudyImage],
},
result: {
title: "Result",
items: [
{ highlight: "Online bookings up 40%", text: " in the first three months." },
{ highlight: "Followers doubled", text: " across Instagram and TikTok, with steady engagement." },
{ highlight: "Launch menu sold out", text: " within a week of the campaign going live." },
],
image: { src: photo("photo-1504674900247-0877df9cc836"), alt: "Plates of grilled meat and salads on a table, seen from above" },
},
};
const phoneQuery = "(max-width: 767px)";
const subscribe = (onChange: () => void) => {
const query = window.matchMedia(phoneQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
export function ProjectsCaseStudy({
title = defaults.title,
overview = defaults.overview,
process = defaults.process,
result = defaults.result,
className,
}: ProjectsCaseStudyProps) {
const phone = React.useSyncExternalStore(subscribe, () => window.matchMedia(phoneQuery).matches, () => false);
const drift = phone ? 20 : 40;
return (
<section className={cn("py-(--section-y)", className)}>
<h2 className="sr-only">{title}</h2>
<div className="mx-auto flex max-w-(--container) flex-col gap-(--section-y) px-(--gutter)">
<div className="flex flex-col gap-12">
<PartHeading title={overview.title} body={overview.body} />
<Parallax speed={drift} className="aspect-[3/2] w-full rounded-xl bg-muted">
<img src={overview.image.src} alt={overview.image.alt} className="object-cover" />
</Parallax>
</div>
<div className="flex flex-col gap-12 border-b border-border pb-(--section-y)">
<PartHeading title={process.title} body={process.body} />
<div className="flex w-full justify-end overflow-hidden">
<ScrollReveal from={{ x: phone ? 75 : 200 }} className="w-full max-w-360 md:w-[85%] lg:w-3/4">
<img
src={process.images[0].src}
alt={process.images[0].alt}
className="block aspect-[2/1] w-full rounded-xl bg-muted object-cover"
/>
</ScrollReveal>
</div>
<div className="flex w-full justify-start overflow-hidden p-5 md:p-8 lg:p-0">
<ScrollReveal from={phone ? { scale: 0.8, x: -20 } : { scale: 0.3, x: -100 }} className="w-full md:w-4/5 lg:w-3/5">
<img
src={process.images[1].src}
alt={process.images[1].alt}
className="block aspect-[2/1] w-full rounded-xl bg-muted object-cover"
/>
</ScrollReveal>
</div>
</div>
<div className="flex flex-col gap-12">
<div className="flex flex-col gap-8 md:flex-row md:items-start md:justify-between md:gap-5">
<h3 className="font-heading text-h2 font-semibold tracking-tight">{result.title}</h3>
<ul className="w-full list-disc space-y-2 pl-5 text-muted-foreground marker:text-foreground md:w-125 md:shrink-0">
{result.items.map((item) => (
<li key={(item.highlight ?? "") + item.text}>
{item.highlight && <strong className="font-semibold text-foreground">{item.highlight}</strong>}
{item.text}
</li>
))}
</ul>
</div>
<Parallax speed={drift} className="aspect-[3/2] w-full rounded-xl bg-muted">
<img src={result.image.src} alt={result.image.alt} className="object-cover" />
</Parallax>
</div>
</div>
</section>
);
}
function PartHeading({ title, body }: { title: string; body: string }) {
return (
<div className="flex flex-col gap-5">
<h3 className="font-heading text-h2 font-semibold tracking-tight">{title}</h3>
<hr className="border-border" />
<p className="w-full max-w-4xl text-pretty text-muted-foreground lg:self-end">{body}</p>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/projects-case-study.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/parallax.json https://framebrick.co/r/scroll-reveal.json2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Copy the block into
components/projects-case-study.tsx."use client"; import * as React from "react"; import { Parallax } from "@/components/ui/parallax"; import { ScrollReveal } from "@/components/ui/scroll-reveal"; import { cn } from "@/lib/utils"; /* * Projects Case Study — the story of a project in three parts, each with its own media: an overview over a parallax * photo, the process with two photos that move into place as you scroll, and the results over a second parallax photo. * Source: marcframe/nitip → components/sections/ProjectContentSection.tsx (Overview, Process, Result) with * design-system ParallaxImage and ScrollTransform. * * Layout kept from Framer: each part opens with a large heading and a divider; overview and process text sits at the * right from 1024px (up to 56rem). Results: heading on the left and a 500px list on the right from 768px. Photos are * 3:2 across the container; the process photos are 2:1 — the first right-aligned (85% wide from 768px, 75% from * 1024px), the second left-aligned (80% / 60%), inset on phones and tablets. A divider closes the process. * Motion kept, all tied to scroll and static with reduced motion: * - Overview and result photos drift with parallax: Framer speed 80 (phone 95) moves them at 20% (5%) of the scroll, * which over one pass is about 40% (20% below 768px) of the frame height — parallax's `speed`. * - Process photo 1 slides in from +200px (phone +75px); photo 2 grows from 30% while sliding in from −100px * (phone 80%, −20px) — linear, from the moment the frame enters until it is fully in view (scroll-reveal). * The section heading is for screen readers; the three parts are h3, styled as the large headings they are on Framer. */ export type CaseStudyImage = { src: string; alt: string }; export type CaseStudyResult = { highlight?: string; text: string }; export type ProjectsCaseStudyProps = { /** Section heading, visually hidden. */ title?: string; overview?: { title: string; body: string; image: CaseStudyImage }; process?: { title: string; body: string; images: [CaseStudyImage, CaseStudyImage] }; result?: { title: string; items: CaseStudyResult[]; image: CaseStudyImage }; className?: string; }; const photo = (id: string, w = 1800) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=${w}&q=80`; const defaults = { title: "Case study", overview: { title: "Overview", body: "Harvest Kitchen, a growing restaurant group, had great food and quiet channels. We rebuilt its social presence around the kitchen itself: short videos of the team at work, a booking-first profile and a clear weekly rhythm of posts.", image: { src: photo("photo-1517248135467-4c7edcad34c4"), alt: "Restaurant dining room with rows of dark tables and chairs" }, }, process: { title: "Process", body: "We began with an audit of every channel and a week in the kitchen. From there we mapped the guests worth reaching, wrote a content plan the team could keep up, and tested ads against real bookings rather than likes.", images: [ { src: photo("photo-1531403009284-440f080d1e12", 1600), alt: "Hands pointing at wireframe sketches pinned to a wall" }, { src: photo("photo-1552664730-d307ca884978", 1400), alt: "Team at a table in front of a wall of sticky notes" }, ] as [CaseStudyImage, CaseStudyImage], }, result: { title: "Result", items: [ { highlight: "Online bookings up 40%", text: " in the first three months." }, { highlight: "Followers doubled", text: " across Instagram and TikTok, with steady engagement." }, { highlight: "Launch menu sold out", text: " within a week of the campaign going live." }, ], image: { src: photo("photo-1504674900247-0877df9cc836"), alt: "Plates of grilled meat and salads on a table, seen from above" }, }, }; const phoneQuery = "(max-width: 767px)"; const subscribe = (onChange: () => void) => { const query = window.matchMedia(phoneQuery); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }; export function ProjectsCaseStudy({ title = defaults.title, overview = defaults.overview, process = defaults.process, result = defaults.result, className, }: ProjectsCaseStudyProps) { const phone = React.useSyncExternalStore(subscribe, () => window.matchMedia(phoneQuery).matches, () => false); const drift = phone ? 20 : 40; return ( <section className={cn("py-(--section-y)", className)}> <h2 className="sr-only">{title}</h2> <div className="mx-auto flex max-w-(--container) flex-col gap-(--section-y) px-(--gutter)"> <div className="flex flex-col gap-12"> <PartHeading title={overview.title} body={overview.body} /> <Parallax speed={drift} className="aspect-[3/2] w-full rounded-xl bg-muted"> <img src={overview.image.src} alt={overview.image.alt} className="object-cover" /> </Parallax> </div> <div className="flex flex-col gap-12 border-b border-border pb-(--section-y)"> <PartHeading title={process.title} body={process.body} /> <div className="flex w-full justify-end overflow-hidden"> <ScrollReveal from={{ x: phone ? 75 : 200 }} className="w-full max-w-360 md:w-[85%] lg:w-3/4"> <img src={process.images[0].src} alt={process.images[0].alt} className="block aspect-[2/1] w-full rounded-xl bg-muted object-cover" /> </ScrollReveal> </div> <div className="flex w-full justify-start overflow-hidden p-5 md:p-8 lg:p-0"> <ScrollReveal from={phone ? { scale: 0.8, x: -20 } : { scale: 0.3, x: -100 }} className="w-full md:w-4/5 lg:w-3/5"> <img src={process.images[1].src} alt={process.images[1].alt} className="block aspect-[2/1] w-full rounded-xl bg-muted object-cover" /> </ScrollReveal> </div> </div> <div className="flex flex-col gap-12"> <div className="flex flex-col gap-8 md:flex-row md:items-start md:justify-between md:gap-5"> <h3 className="font-heading text-h2 font-semibold tracking-tight">{result.title}</h3> <ul className="w-full list-disc space-y-2 pl-5 text-muted-foreground marker:text-foreground md:w-125 md:shrink-0"> {result.items.map((item) => ( <li key={(item.highlight ?? "") + item.text}> {item.highlight && <strong className="font-semibold text-foreground">{item.highlight}</strong>} {item.text} </li> ))} </ul> </div> <Parallax speed={drift} className="aspect-[3/2] w-full rounded-xl bg-muted"> <img src={result.image.src} alt={result.image.alt} className="object-cover" /> </Parallax> </div> </div> </section> ); } function PartHeading({ title, body }: { title: string; body: string }) { return ( <div className="flex flex-col gap-5"> <h3 className="font-heading text-h2 font-semibold tracking-tight">{title}</h3> <hr className="border-border" /> <p className="w-full max-w-4xl text-pretty text-muted-foreground lg:self-end">{body}</p> </div> ); }
Usage
import { ProjectsCaseStudy } from "@/components/projects-case-study";
<ProjectsCaseStudy
overview={{ title: "Overview", body: "…", image: { src: "/work/overview.jpg", alt: "…" } }}
process={{ title: "Process", body: "…", images: [{ src: "/work/p1.jpg", alt: "…" }, { src: "/work/p2.jpg", alt: "…" }] }}
result={{ title: "Result", items: [{ highlight: "Bookings up 40%", text: " in three months." }], image: { src: "/work/result.jpg", alt: "…" } }}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | Case study | Section heading for screen readers (visually hidden); the parts are h3. |
| overview | { title, body, image } | — | Heading, text and a 3:2 parallax photo. |
| process | { title, body, images: [img, img] } | — | Heading, text and two 2:1 photos that move into place. |
| result | { title, items: { highlight?, text }[], image } | — | Heading beside a list (bold highlight, then text), then a parallax photo. |
| className | string | — | Classes for the <section>. |
- Overview and result photos drift with parallax: speed 40 (20 below 768px) — Framer's speed 80 (phone 95).
- Process photo 1 slides in from +200px (phone +75px); photo 2 grows from 30% while sliding in from −100px (phone 80%, −20px). Linear, tied to scroll.
- Everything is static with reduced motion. In the preview the frame scrolls on its own: scroll inside it.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called ProjectsCaseStudy (Next.js). - <section className="py-(--section-y)"> with a visually hidden h2; container mx-auto max-w-(--container) px-(--gutter); three parts gap-(--section-y). Each part heading is an h3 styled font-heading text-h2 semibold, then an <hr>, then muted text (max-w-4xl, aligned right from 1024px). - Overview and Result photos: 3:2 frames (rounded-xl, overflow-hidden) whose oversized image layer drifts against the scroll — offset (0.5 − progress) × 40% of the frame height (20% below 768px), progress from the frame entering to leaving the viewport, written to style.transform in a passive scroll listener. - Process: two 2:1 photos: the first right-aligned (85% wide from 768px, 75% from 1024px) starts 200px (75px on phones) to the right; the second left-aligned (80% / 60%, inset p-5 / md:p-8 until 1024px) starts at scale 0.3 and 100px to the left (phone 0.8, 20px). Both move to rest linearly as the frame scrolls from entering to fully in view. A border-b closes the part. - Result: heading on the left, a 500px bulleted list on the right from 768px (bold highlight in foreground, muted text). - Skip all motion with prefers-reduced-motion. Only shadcn variables and block tokens.











