# Case Study Photo Cards Stacking With Settling Zoom

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/case-study-photo-cards-stacking-with-settling-zoom).

- Page, with a live preview: https://shadcn.exchange/r/weinc/case-study-photo-cards-stacking-with-settling-zoom
- Screenshot: https://shadcn.exchange/shot/weinc/case-study-photo-cards-stacking-with-settling-zoom/light.png
- Registry item: https://we.inc/r/case-study-photo-cards-stacking-with-settling-zoom.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/case-study-photo-cards-stacking-with-settling-zoom.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/case-study-photo-cards-stacking-with-settling-zoom.tsx`

```tsx
import { useEffect, useRef } from "react";
import { useReducedMotion } from "framer-motion";

const projects = [
  { kind: "Restaurant fit-out", title: "Cedar Row Kitchen", text: "A dark former print shop became a dining room of warm timber, with a sunken courtyard and a ceiling of slatted pine to soften the noise of a full Friday.", facts: ["Lisbon", "Interior and joinery", "2025"], img: "https://images.pexels.com/photos/14590691/pexels-photo-14590691.jpeg?auto=compress&cs=tinysrgb&w=1200", alt: "Restaurant interior with a slatted pine ceiling, round wooden tables and dark metal chairs" },
  { kind: "Workshop", title: "Kilnhouse Ceramics", text: "We fitted out a working pottery with deep oak shelving for drying ware, a wash station by the window and storage that keeps finished pieces out of the dust.", facts: ["Porto", "Shelving and layout", "2024"], img: "https://images.pexels.com/photos/15122649/pexels-photo-15122649.jpeg?auto=compress&cs=tinysrgb&w=1200", alt: "A potter placing a wrapped bowl on tall wooden shelves in a bright ceramics studio" },
  { kind: "Private house", title: "Ashgrove Residence", text: "A family home of pale stone and dark framing, set behind a low garden of clipped trees so the street side stays quiet and green through the year.", facts: ["Coimbra", "Facade and landscape", "2025"], img: "https://images.pexels.com/photos/18273285/pexels-photo-18273285.jpeg?auto=compress&cs=tinysrgb&w=1200", alt: "Modern two-storey house with stone cladding, a black gate and young trees in the front garden" },
];
const SCALE_STEP = 0.07; // shrink per covering card
const DIM_STEP = 0.3; // darkness per covering card
const ZOOM = 0.1; // extra photo zoom while a card is still arriving
const AUTO_PX_PER_SEC = 0; // demo scroll speed; 0 turns it off

export default function CaseStudyPhotoCardsStackingWithSettlingZoom() {
  const reduce = useReducedMotion();
  const boxRef = useRef<HTMLElement>(null);

  useEffect(() => {
    const box = boxRef.current;
    if (!box || reduce) return;
    const cards = Array.from(box.querySelectorAll<HTMLElement>(".w4ccp-card"));
    let raf = 0;
    const paint = () => {
      raf = 0;
      const top = box.getBoundingClientRect().top;
      const cover = cards.map((c) => {
        const d = c.getBoundingClientRect().top - top - parseFloat(getComputedStyle(c).top);
        return Math.min(1, Math.max(0, 1 - d / c.offsetHeight));
      });
      cards.forEach((c, i) => {
        let depth = 0;
        for (let j = i + 1; j < cards.length; j++) depth += cover[j];
        (c.firstElementChild as HTMLElement).style.transform = `scale(${1 - SCALE_STEP * depth})`;
        c.querySelector<HTMLElement>(".w4ccp-shade")!.style.opacity = String(Math.min(0.6, DIM_STEP * depth));
        c.querySelector<HTMLElement>("img")!.style.transform = `scale(${1 + ZOOM * (1 - cover[i])})`;
      });
      box.toggleAttribute("data-scrolled", box.scrollTop > 8);
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(paint); };
    box.addEventListener("scroll", onScroll, { passive: true });
    paint();

    let demo = 0, last = 0, pos = box.scrollTop, seen = true, done = !AUTO_PX_PER_SEC;
    const step = (now: number) => {
      const dt = last ? Math.min(now - last, 50) : 0;
      last = now;
      pos += (AUTO_PX_PER_SEC * dt) / 1000;
      if (pos >= box.scrollHeight - box.clientHeight) { done = true; demo = 0; return; }
      box.scrollTop = pos;
      demo = requestAnimationFrame(step);
    };
    const start = () => { if (!done && seen && !demo) { last = 0; demo = requestAnimationFrame(step); } };
    const stop = () => { cancelAnimationFrame(demo); demo = 0; };
    const handOver = () => { done = true; stop(); };
    const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; if (seen) start(); else stop(); });
    io.observe(box);
    const evs = ["wheel", "touchstart", "pointerdown", "keydown"] as const;
    evs.forEach((ev) => box.addEventListener(ev, handOver, { passive: true }));
    start();
    return () => {
      stop();
      cancelAnimationFrame(raf);
      io.disconnect();
      box.removeEventListener("scroll", onScroll);
      evs.forEach((ev) => box.removeEventListener(ev, handOver));
    };
  }, [reduce]);

  return (
    <section ref={boxRef} tabIndex={0} aria-label="Selected projects, scrollable" className="w4ccp-root">
      <style>{`
        .w4ccp-root { height: 660px; overflow-y: auto; position: relative; background: #ebe2d3; color: #2d221b; scrollbar-width: thin; scrollbar-color: #c7b79f transparent; }
        .w4ccp-root:focus-visible { outline: 3px solid #9c4a2c; outline-offset: -3px; }
        .w4ccp-hint { position: sticky; top: 0; z-index: 20; height: 0; display: flex; justify-content: center; pointer-events: none; transition: opacity .4s ease; }
        .w4ccp-hint span { margin-top: 10px; padding: 4px 12px; border-radius: 999px; background: rgba(45,34,27,.85); color: #faf6ef; font-size: 12px; letter-spacing: .04em; }
        .w4ccp-root[data-scrolled] .w4ccp-hint { opacity: 0; }
        .w4ccp-card { position: sticky; top: 28px; }
        .w4ccp-face { transform-origin: 50% 0; will-change: transform; }
        .w4ccp-face img { transform: scale(1.1); will-change: transform; }
        .w4ccp-shade { position: absolute; inset: 0; border-radius: inherit; background: #1d150f; opacity: 0; pointer-events: none; }
        @media (prefers-reduced-motion: reduce) { .w4ccp-face, .w4ccp-face img { transform: none !important; } .w4ccp-shade { opacity: 0 !important; } .w4ccp-hint { display: none; } }
      `}</style>
      <div className="w4ccp-hint" aria-hidden="true"><span>Scroll inside</span></div>
      <div className="mx-auto max-w-5xl px-5 pb-20 pt-10 sm:px-8">
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.2em] text-[#8a3f24]">Fenwick and Oduya, studio</p>
            <h2 className="mt-2 font-serif text-3xl leading-tight sm:text-4xl">Rooms we finished recently</h2>
          </div>
          <a href="#" className="inline-flex min-h-[44px] items-center rounded-full border border-[#2d221b] px-5 text-sm font-semibold transition hover:bg-[#2d221b] hover:text-[#faf6ef] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#9c4a2c] focus-visible:ring-offset-2 focus-visible:ring-offset-[#ebe2d3]">All projects</a>
        </div>
        <ul className="mt-8 grid gap-6">
          {projects.map((p, n) => (
            <li key={p.title} className="w4ccp-card">
              <article className="w4ccp-face relative grid overflow-hidden rounded-3xl bg-[#faf6ef] shadow-[0_30px_60px_-30px_rgba(45,34,27,0.55)] md:grid-cols-[1.15fr_1fr]">
                <div className="aspect-[16/10] overflow-hidden md:aspect-auto md:h-[440px]">
                  <img src={p.img} alt={p.alt} loading={n === 0 ? "eager" : "lazy"} width={1200} height={800} className="h-full w-full object-cover" />
                </div>
                <div className="flex flex-col p-6 sm:p-8">
                  <p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#8a3f24]">{p.kind}</p>
                  <h3 className="mt-2 font-serif text-2xl sm:text-3xl">{p.title}</h3>
                  <p className="mt-3 text-[15px] leading-relaxed text-[#5b4a3d]">{p.text}</p>
                  <dl className="mt-6 grid md:mt-auto grid-cols-3 gap-3 border-t border-[#e2d6c3] pt-5 text-sm">
                    {["Place", "Scope", "Year"].map((k, i) => (
                      <div key={k}><dt className="text-xs text-[#6b5847]">{k}</dt><dd className="mt-1 font-medium">{p.facts[i]}</dd></div>
                    ))}
                  </dl>
                </div>
                <span className="w4ccp-shade" aria-hidden="true" />
              </article>
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
}
```
