Maged Faiz

Case study · Mogz Visuals

The Download That Fought Back: Building the Mogz Visuals Studio Site

A journey through parallax effects, smooth scrolling, and creative problem-solving as we craft a cutting-edge digital presence for Juba's premier media studio.

Filed
Jul, 2024 · completed
Stack
Next.js / Sanity / GSAP / Redis / TypeScript / Tailwind CSS
Live
mogz.studio
Source
github.com

01The client and the brief

Mogz Visuals is a photography and media studio in Juba, led by Jacob Mogga Kei. The work is good. The website in front of it did not say so. Jacob wanted a site that felt like the photography: immersive to move through, with a private side where clients could view their own shoots and download them without trading file links back and forth.

That second half is what made it interesting to build. A pretty marketing site is one thing. A pretty marketing site that also has to hand a client a few hundred high-resolution photos as a single secure download is a different problem.

02Smooth scrolling against a server-first framework

I wanted the heavy, smooth scroll feel you get from Locomotive Scroll, the kind of momentum that makes a portfolio feel considered. The trouble: Locomotive is a browser library that takes over the page's scroll container, and Next.js with the App Router renders on the server first, where there is no window to take over.

The fix is to keep the library entirely on the client and give it a clear lifecycle. A scroll provider imports Locomotive only in the browser, after the component has mounted, and tunes its smoothness to the viewport. The App Router keeps components alive across navigations, so the provider tears the instance down and rebuilds it whenever the path changes. Each page gets a clean scroll context instead of a stale one.

On the other side of that contract, a thin LocomotiveScrollSection wrapper stamps a data-scroll-section attribute onto whatever it renders. Any section can opt into the smooth scroll just by being wrapped in it, rather than reaching for the attribute by hand.

src/lib/context/scrollContext.tsxview source
const getLerpForViewport = () => {
  const width = window.innerWidth;
  if (width < 768) return 0.14;  // lighter smoothing on phones
  if (width < 1024) return 0.08; // a touch heavier on tablets
  return 0.05;                   // full weight on desktop
};

const getTouchMultiplierForViewport = () => {
  const width = window.innerWidth;
  if (width < 768) return 3.25;
  if (width < 1024) return 3.5;
  return 3;
};

const initializeScroll = async () => {
  if (scrollRef.current) scrollRef.current.destroy();

  // import only in the browser, never during server render
  const LocomotiveScroll = (await import('locomotive-scroll')).default;
  const scroll = new LocomotiveScroll({
    el: document.querySelector('[data-scroll-container]') as HTMLElement,
    lerp: getLerpForViewport(), // lighter smoothing on small screens
    smooth: true,
    reloadOnContextChange: true,
    tablet: { smooth: true, breakpoint: 1024 },
    smartphone: { smooth: true },
    touchMultiplier: getTouchMultiplierForViewport(),
  });

  scrollRef.current = scroll;
  setScrollInstance(scroll);
};

useEffect(() => {
  initializeScroll();
  return () => {
    // rebuild a fresh instance on every route change
    scrollRef.current?.destroy();
    scrollRef.current = null;
  };
}, [pathname]);

03An about section that drifts past you

For the about section I did not want a grid. I wanted photos scattered the way they might be on a table, then sliding past at different speeds as you scroll. To place them I borrowed the golden angle, the same 137.5 degrees plants use to space leaves so they do not shadow each other. It spreads images evenly around the center without clumping them, and a little random jitter keeps it from looking like a math exercise. Each image then gets its own parallax speed and scale.

src/lib/hooks/useImmersiveImages.tsview source
const angle = i * 137.5 * (Math.PI / 180); // the golden angle
const normalizedIndex = i / Math.max(images.length - 1, 1);

// radius grows with index so images spiral outward,
// with a base offset that keeps the center clear for text
const radiusX = 30 + normalizedIndex * 15;
const radiusY = 35 + normalizedIndex * 20;

// jitter so it doesn't read as a perfect spiral
const randomX = (Math.random() - 0.5) * 10;
const randomY = (Math.random() - 0.5) * 10;

const x = 50 + Math.cos(angle) * radiusX + randomX;
const y = 50 + Math.sin(angle) * radiusY + randomY;

return {
  src,
  top: Math.max(5, Math.min(95, y)),  // clamp to 5-95%
  left: Math.max(5, Math.min(95, x)),
  width: 260 + Math.random() * 140,   // random width 260-400px
  speed: Math.random() * 1.5 + 0.5,   // each image gets its own parallax speed
  scaleType: i % 2 === 0 ? 'grow' : 'shrink',
  initialScale: 0.8 + Math.random() * 0.4,
} as ImageConfig;

04Private collections, properly locked

Client galleries sit behind a password, and a password check is the kind of thing that is easy to get subtly wrong. A naive string comparison can leak how much of the password is right by how long it takes to fail. So the check runs in constant time with crypto.timingSafeEqual, after a length check, and behind a Cloudflare Turnstile challenge handled earlier in the same route to keep bots off the form. Only then does the server hand back an encrypted token that unlocks the collection.

src/app/api/verifyAccess/route.tsview source
const isPasswordValid =
  credentials &&
  Buffer.from(credentials.password).length === Buffer.from(password).length &&
  crypto.timingSafeEqual(
    Buffer.from(credentials.password),
    Buffer.from(password),
  );

if (!credentials || !isPasswordValid) {
  return NextResponse.json(
    { message: 'Invalid collection ID or password', status: 401 },
    { status: 401 },
  );
}

Access is meant to be temporary, and the cookie holding the token is httpOnly, which cuts both ways: page scripts cannot steal it, but they cannot remove it either. So a client hook, useAutoDeleteCookie, counts down the hour and then asks the server to log the session out through a logout route, firing an expiry toast at the same moment. The client gets told their access ended instead of just hitting a locked page. The first version made clients type a collection ID and password by hand every single time. Safe, but tedious. So I added direct access links that look like .../private?id={uniqueId} and pre-fill the ID straight from the URL, with the same password check still sitting behind them. The lock stayed exactly as strict. The typing just went away.

05Handing over an entire shoot

The hardest part of the whole project was the download. A client wants their entire gallery as one file, and that can be hundreds of large images.

Phase one did it all in the browser: fetch every image, zip it in memory with JSZip, hand over the file. That worked fine for an ordinary portfolio, and "ordinary" turned out to be the dangerous assumption. When a wedding collection of more than 850 high-resolution photos came through, the browser's main thread choked and the page froze while it tried to hold the whole archive in memory.

Phase two pushed the heavy work off the main thread into a Web Worker. Comlink sat in between as the RPC bridge, so the main thread could call into the worker like a normal async function. I also split the download into 100-image chunks, so a shaky connection could not kill the whole job halfway through. That held. But it was still asking the client's device to do work the server was better placed to handle.

The version running now makes the server do the heavy lifting, in two steps. buildArchiveFile pulls images straight from the CDN and pipes them into a zip on disk as they arrive, never holding the whole thing in memory. Then createZipResponse streams that finished file down to the client. Compression is off on purpose. Photos are already compressed, so spending CPU to shrink them buys almost nothing and just slows things down. Because the zip lands on disk first, it gets cached behind a content hash. A repeat download skips the rebuild entirely and re-streams the cached file. While a fresh archive is being built, the browser listens to a live progress feed showing how far along the packing is, and when the packing finishes, that same feed hands over a short-lived encrypted token that the actual download then redeems. The worker-and-chunks path did not die either. It still ships in the download drawer as a "download in parts" fallback, for the day a single giant stream is the wrong tool.

src/app/api/download/stream/route.tsview source
const archive = archiver('zip', {
  zlib: { level: 0 }, // photos are already compressed; don't re-crunch them
  forceZip64: false,
  highWaterMark: 1024 * 1024,
});

const fileOutput = fs.createWriteStream(outputPath, {
  highWaterMark: 1024 * 1024,
});

archive.pipe(fileOutput); // write the zip to disk as it's built

// ...

const res = await fetch(img.url, { signal });
if (!res.ok || !res.body) return null;

// pull straight from the CDN and feed the stream into the zip
// @ts-ignore Readable.fromWeb is available in the runtime used by Next.
const stream = Readable.fromWeb(res.body);
archive.append(stream, { name: filename });

The route is also paranoid in ways nobody is ever supposed to notice. A cached zip does not get re-served just because a file with the right name exists: the server first checks the zip's actual byte signatures, the local-file-header magic at the front and the end-of-central-directory record near the tail, so a truncated or corrupted build can never be handed to a client. Only images hosted on the site's own CDN are ever fetched into an archive, so the endpoint cannot be pointed at arbitrary URLs. And preparing an archive is rate limited per IP, because a route that does this much work on demand is otherwise an open invitation.

06Loading galleries that do not end

Big collections caused a quieter problem too. Loading 800 images at once slowed the first paint and left thumbnails broken far down the page, where most visitors never scroll anyway. So the gallery loads in batches of 20 as you go, hooking into the same Locomotive Scroll instance to know when you have reached the trigger point. The download form also does double duty as a soft mailing list, dropping the client's email into Resend so the studio can reach past clients later.

07Where it stands

The site runs on Next.js with Sanity behind it for the galleries and the collection credentials. The part I keep coming back to is the download arc. I shipped the happy-path version, watched it buckle on a real 850-image gallery, and rebuilt it properly. Assuming good networks and reasonable file sizes feels comfortable, right up until production hands you the case you did not plan for. What I liked about the project is that no single trick carried it. The scroll had to bend to the framework. The password check had to actually be correct. And the download was a stubborn enough data problem that I chased it from the browser all the way back to the server.