# Photo Grid Leans With Scroll Speed

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/photo-grid-leans-with-scroll-speed).

- Page, with a live preview: https://shadcn.exchange/r/weinc/photo-grid-leans-with-scroll-speed
- Screenshot: https://shadcn.exchange/shot/weinc/photo-grid-leans-with-scroll-speed/light.png
- Registry item: https://we.inc/r/photo-grid-leans-with-scroll-speed.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/photo-grid-leans-with-scroll-speed.json
```

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

## Files

### `components/we-inc/photo-grid-leans-with-scroll-speed.tsx`

```tsx
import { useEffect, useRef } from "react";

const LEAN = 0.6; // degrees of lean per pixel scrolled in one frame
const MAX = 6; // the most a column may lean, in degrees
const EASE = 0.12; // how quickly columns follow the speed and settle back
const IDLE = 55; // the self-scroll demo swings this many px down and back every 4.8 s; 0 turns it off
const MIX = [1, 0.55, 0.85]; // each column leans by a different share, so the grid ripples

const COLUMNS = [
  [
    ["https://images.pexels.com/photos/15022475/pexels-photo-15022475.jpeg?auto=compress&cs=tinysrgb&w=700", "Harbour lighthouse", "01", "A stone lighthouse at the end of a rocky breakwater under a clear sky"],
    ["https://images.pexels.com/photos/8933075/pexels-photo-8933075.jpeg?auto=compress&cs=tinysrgb&w=700", "Dune grass", "04", "Pale dune grass bending over sand under an overcast sky"],
    ["https://images.pexels.com/photos/20799401/pexels-photo-20799401.jpeg?auto=compress&cs=tinysrgb&w=700", "Tide pool in a cove", "07", "Still water in a tide pool between rust coloured rocks"],
    ["https://images.pexels.com/photos/2233362/pexels-photo-2233362.jpeg?auto=compress&cs=tinysrgb&w=700", "Trawlers hauled out", "10", "Fishing trawlers hauled out on a harbour hardstand"],
  ],
  [
    ["https://images.pexels.com/photos/11363530/pexels-photo-11363530.jpeg?auto=compress&cs=tinysrgb&w=700", "Floats on the deck", "02", "Yellow floats and crates on the deck of a moored fishing boat"],
    ["https://images.pexels.com/photos/15379643/pexels-photo-15379643.jpeg?auto=compress&cs=tinysrgb&w=700", "Surf between rocks", "05", "White surf breaking in a gap between sandstone rocks"],
    ["https://images.pexels.com/photos/8970615/pexels-photo-8970615.jpeg?auto=compress&cs=tinysrgb&w=700", "Beach pebbles", "08", "Close view of smooth grey, green and brown beach pebbles"],
    ["https://images.pexels.com/photos/11207937/pexels-photo-11207937.jpeg?auto=compress&cs=tinysrgb&w=700", "Black rock shore", "11", "Black volcanic rocks along a shoreline with a strip of water between them"],
  ],
  [
    ["https://images.pexels.com/photos/16549060/pexels-photo-16549060.jpeg?auto=compress&cs=tinysrgb&w=700", "Headland, grey sea", "03", "A dark rocky headland running into a calm grey sea"],
    ["https://images.pexels.com/photos/9435233/pexels-photo-9435233.jpeg?auto=compress&cs=tinysrgb&w=700", "Path through the dunes", "06", "A sandy path running between green grassy dunes"],
    ["https://images.pexels.com/photos/4616317/pexels-photo-4616317.jpeg?auto=compress&cs=tinysrgb&w=700", "Wooded cliff", "09", "A wooded cliff dropping into still blue water"],
    ["https://images.pexels.com/photos/11468623/pexels-photo-11468623.jpeg?auto=compress&cs=tinysrgb&w=700", "Marina pontoon", "12", "Small boats tied along a marina pontoon on a cloudy day"],
  ],
] as const;

export default function PhotoGridLeansWithScrollSpeed() {
  const root = useRef<HTMLElement>(null);

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    // The box is the scroller here. On a normal page, listen on window and read scrollY instead.
    const sc = el;
    const cols = Array.from(el.querySelectorAll<HTMLElement>(".w3lgr-col"));
    let demo = IDLE > 0;
    const used = () => { demo = false; el.setAttribute("data-used", ""); };
    const inputs = ["wheel", "touchstart", "pointerdown", "keydown"];
    inputs.forEach((e) => el.addEventListener(e, used, { passive: true }));
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let last = sc.scrollTop;
    let cur = 0;
    let f = 0;
    let on = false;
    let t0 = 0;
    const tick = (now: number) => {
      if (demo && on) {
        sc.scrollTop = IDLE * (1 - Math.cos((now - t0) / 764));
      }
      const p = sc.scrollTop;
      const v = p - last;
      last = p;
      cur += (Math.max(-MAX, Math.min(MAX, v * LEAN)) - cur) * EASE;
      if (Math.abs(cur) < 0.01 && !v) cur = 0;
      cols.forEach((c, i) => { c.style.transform = cur ? `skewY(${(cur * MIX[i]).toFixed(2)}deg)` : ""; });
      f = on && (cur || v || demo) ? requestAnimationFrame(tick) : 0;
    };
    const run = () => { if (!f && on) f = requestAnimationFrame(tick); };
    const io = new IntersectionObserver(([e]) => {
      on = e.isIntersecting;
      if (on) { t0 = performance.now(); run(); }
      else { cancelAnimationFrame(f); f = 0; }
    });
    if (!reduce) {
      sc.addEventListener("scroll", run, { passive: true });
      io.observe(el);
    }
    return () => {
      io.disconnect();
      cancelAnimationFrame(f);
      sc.removeEventListener("scroll", run);
      inputs.forEach((e) => el.removeEventListener(e, used));
    };
  }, []);

  return (
    <section
      ref={root}
      tabIndex={0}
      aria-label="Saltline Print Room, coastal prints"
      className="w3lgr relative h-[640px] overflow-y-auto overscroll-contain bg-[#f4f5f6] text-[#1d232a] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-[#2f6f9f]"
    >
      <style>{`
        .w3lgr-hint { background: linear-gradient(#f4f5f6 60%, transparent); transition: opacity .5s ease; }
        .w3lgr[data-used] .w3lgr-hint { opacity: 0; }
        .w3lgr-col { will-change: transform; }
        @media (prefers-reduced-motion: reduce) { .w3lgr-col { transform: none !important; } .w3lgr-hint { transition: none; } }
      `}</style>
      <p aria-hidden="true" className="w3lgr-hint sticky top-0 z-[2] px-5 py-3 text-center text-[11px] font-semibold uppercase tracking-[0.22em] text-[#5b6570]">
        Scroll inside
      </p>
      <div className="mx-auto max-w-[68rem] px-5 pb-10 pt-5">
        <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[#2f6f9f]">Saltline Print Room</p>
        <h2 className="mt-3 max-w-[34rem] text-[clamp(28px,4vw,42px)] font-semibold leading-[1.12] tracking-[-0.025em]">
          Prints from a year of walking the north coast.
        </h2>
        <p className="mt-3.5 max-w-[34rem] text-base leading-relaxed text-[#4a535d]">
          Each photograph is printed on cotton rag paper in the studio and signed on the back. Pick a frame or take it rolled.
        </p>
      </div>
      <div className="mx-auto grid max-w-[68rem] grid-cols-2 gap-4 px-5 pb-20 min-[760px]:grid-cols-3 min-[760px]:gap-6">
        {COLUMNS.map((col, c) => (
          <div
            key={c}
            className={`w3lgr-col content-start gap-4 min-[760px]:gap-6 ${
              c === 1 ? "mt-14 grid" : c === 2 ? "hidden min-[760px]:mt-6 min-[760px]:grid" : "grid"
            }`}
          >
            {col.map(([src, label, no, alt]) => (
              <figure key={src}>
                <img
                  src={src}
                  alt={alt}
                  loading="lazy"
                  width={700}
                  height={875}
                  className="block aspect-[4/5] h-auto w-full rounded-[10px] bg-[#dde1e5] object-cover"
                />
                <figcaption className="flex justify-between gap-2 pt-2 text-sm font-medium">
                  {label} <span className="flex-none tabular-nums text-[#5b6570]">No. {no}</span>
                </figcaption>
              </figure>
            ))}
          </div>
        ))}
      </div>
    </section>
  );
}
```
