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.
In the heart of Juba's thriving media scene, Mogz Visuals has a stellar reputation for high-end photography. Led by founder Jacob Mogga Kei, their work is exceptional, but their online presence didn't yet match the quality of their portfolio. They approached me to build a website that would truly capture the essence of their brand.
The vision was clear. Jacob needed a site that could:
This project would become a deep dive into advanced scroll mechanics, secure authentication flows, and on-the-fly file compression, all in service of delivering a cutting-edge digital experience.
To create the immersive feel the client wanted, I decided against a standard portfolio layout. Instead, I opted for a more dynamic experience using parallax effects and smooth scrolling, inspired by the creative implementations on sites like Codrops. This led me to Locomotive Scroll, a powerful library for creating silky-smooth scroll effects.
However, integrating it into a modern Next.js 13 project presented a significant architectural challenge. Locomotive Scroll is a client-side library that wants to take full control of the page's scroll container. This directly conflicts with Next.js's modern App Router, which is designed around server components that render independently of the client-side environment.
The solution was to architect a system that could isolate the client-side library without breaking the server-first paradigm of Next.js. I accomplished this using a React Context Provider.
This ScrollProvider acts as a boundary. It wraps the parts of the application that need smooth scrolling and uses a hook to initialize Locomotive Scroll on the client side. This keeps the server components completely unaware of the library's existence, resolving the core conflict. The provider then uses React Context to pass the scroll instance and its data (like scroll position) down to any child component that needs it.
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.
In the heart of Juba's thriving media scene, Mogz Visuals has a stellar reputation for high-end photography. Led by founder Jacob Mogga Kei, their work is exceptional, but their online presence didn't yet match the quality of their portfolio. They approached me to build a website that would truly capture the essence of their brand.
The vision was clear. Jacob needed a site that could:
This project would become a deep dive into advanced scroll mechanics, secure authentication flows, and on-the-fly file compression, all in service of delivering a cutting-edge digital experience.
To create the immersive feel the client wanted, I decided against a standard portfolio layout. Instead, I opted for a more dynamic experience using parallax effects and smooth scrolling, inspired by the creative implementations on sites like Codrops. This led me to Locomotive Scroll, a powerful library for creating silky-smooth scroll effects.
However, integrating it into a modern Next.js 13 project presented a significant architectural challenge. Locomotive Scroll is a client-side library that wants to take full control of the page's scroll container. This directly conflicts with Next.js's modern App Router, which is designed around server components that render independently of the client-side environment.
The solution was to architect a system that could isolate the client-side library without breaking the server-first paradigm of Next.js. I accomplished this using a React Context Provider.
This ScrollProvider acts as a boundary. It wraps the parts of the application that need smooth scrolling and uses a hook to initialize Locomotive Scroll on the client side. This keeps the server components completely unaware of the library's existence, resolving the core conflict. The provider then uses React Context to pass the scroll instance and its data (like scroll position) down to any child component that needs it.
To make implementation easier, I created a simple wrapper component that applies the necessary data-scroll-section attribute, allowing me to designate which parts of the page should be controlled by the scroll library.
A critical requirement for Mogz Visuals was a secure portal for clients to view their private photo collections. The system needed to be robust and trustworthy. I engineered a solution using encrypted, auto-expiring session cookies and Next.js middleware.
The authentication flow works like this:
This creates a secure, temporary session for clients without requiring a full user account system. To ensure the session ends properly, a custom hook also removes the cookie when the user closes their browser tab or after the one-hour timer expires.
To complete the client workflow, I built a feature allowing users to download an entire collection of images as a single zip file. This entire process is handled on the client-side to avoid server load.
I created a custom hook, useDownloadCollection, that performs several actions:
The hook also integrates a simple API-based rate limiter to prevent abuse and adds the user's email to a marketing audience in Resend, helping the client build their mailing list.
This project was a fantastic learning experience that pushed me to solve several complex, real-world problems. The journey from concept to completion was a deep dive into modern web development practices, and my toolkit is considerably larger for it. Key takeaways include:
The Mogz Visuals website is more than just a portfolio; it's a digital experience designed to capture the essence of their artistry. The final platform successfully delivered on the client's vision, providing a visually stunning showcase for their work and a secure, seamless portal for their clients.
This project stands as a testament to what can be achieved when cutting-edge web technologies are combined with creative vision and persistence. For Mogz Visuals, it’s a digital home that truly reflects their artistic prowess and positions them for continued success in Juba’s vibrant media scene.
To make implementation easier, I created a simple wrapper component that applies the necessary data-scroll-section attribute, allowing me to designate which parts of the page should be controlled by the scroll library.
A critical requirement for Mogz Visuals was a secure portal for clients to view their private photo collections. The system needed to be robust and trustworthy. I engineered a solution using encrypted, auto-expiring session cookies and Next.js middleware.
The authentication flow works like this:
This creates a secure, temporary session for clients without requiring a full user account system. To ensure the session ends properly, a custom hook also removes the cookie when the user closes their browser tab or after the one-hour timer expires.
To complete the client workflow, I built a feature allowing users to download an entire collection of images as a single zip file. This entire process is handled on the client-side to avoid server load.
I created a custom hook, useDownloadCollection, that performs several actions:
The hook also integrates a simple API-based rate limiter to prevent abuse and adds the user's email to a marketing audience in Resend, helping the client build their mailing list.
This project was a fantastic learning experience that pushed me to solve several complex, real-world problems. The journey from concept to completion was a deep dive into modern web development practices, and my toolkit is considerably larger for it. Key takeaways include:
The Mogz Visuals website is more than just a portfolio; it's a digital experience designed to capture the essence of their artistry. The final platform successfully delivered on the client's vision, providing a visually stunning showcase for their work and a secure, seamless portal for their clients.
This project stands as a testament to what can be achieved when cutting-edge web technologies are combined with creative vision and persistence. For Mogz Visuals, it’s a digital home that truly reflects their artistic prowess and positions them for continued success in Juba’s vibrant media scene.
'use client';
import { usePathname } from 'next/navigation';
import {
ReactNode,
createContext,
useContext,
useEffect,
useRef,
useState,
} from 'react';
// Define the type for the context value, including the scroll instance and functions
type ScrollContextValue = {
scrollInstance: LocomotiveScroll | null;
scroll: number;
windowHeight: number;
scrollToSection: (id: string) => void;
};
// Create a context to hold the scroll-related data and functions
const ScrollContext = createContext<ScrollContextValue | null>(null);
// Custom hook to use the ScrollContext
// Throws an error if used outside the ScrollProvider
export const useScroll = (): ScrollContextValue => {
const context = useContext(ScrollContext);
if (!context) {
throw new Error('useScroll must be used within a ScrollProvider');
}
return context;
};
// ScrollProvider component to wrap the application or part of it
// Provides scroll-related data and functions to its children
export const ScrollProvider = ({ children }: { children: ReactNode }) => {
const scrollRef = useRef<LocomotiveScroll | null>(null); // Ref to hold the LocomotiveScroll instance
const [scrollPosition, setScrollPosition] = useState(0); // State to track the current scroll position
const [windowHeight, setWindowHeight] = useState(0); // State to track the window height for scroll limits
const pathname = usePathname(); // Get the current pathname to reset scroll on navigation
// useEffect to initialize LocomotiveScroll and handle scroll events
useEffect(() => {
if (!scrollRef.current) {
const initializeScroll = async () => {
const LocomotiveScroll = (await import('locomotive-scroll')).default;
scrollRef.current = new LocomotiveScroll({
el: document.querySelector('[data-scroll-container]') as HTMLElement,
lerp: 0.05,
smooth: true,
reloadOnContextChange: true,
smartphone: { smooth: true },
touchMultiplier: 3,
});
// Event listener for scroll events to update the scroll position and window height
scrollRef.current.on('scroll', (event: any) => {
setScrollPosition(event.scroll.y);
if (windowHeight !== event.limit.y) {
setWindowHeight(event.limit.y);
}
});
};
initializeScroll();
}
// Cleanup function to destroy the scroll instance when the component unmounts or pathname changes
return () => {
if (scrollRef.current) {
scrollRef.current.destroy();
scrollRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// Function to scroll to a specific section by ID
const scrollToSection = (id: string) => {
if (scrollRef.current) {
scrollRef.current.scrollTo(id);
}
};
// Provide the scroll data and functions to the context
return (
<ScrollContext.Provider
value={{
scroll: scrollPosition,
scrollToSection,
windowHeight,
scrollInstance: scrollRef.current,
}}
>
{children}
</ScrollContext.Provider>
);
};
import { ReactNode } from 'react';
import { Tag } from '@/lib/types';
// Define the allowed HTML tags for the component
type SectionTags = Extract<Tag, 'section' | 'div' | 'footer'>;
type LocomotiveScrollWrapperProps = {
children: ReactNode;
Tag?: SectionTags;
className?: string;
[x: string]: any;
};
// Component to wrap content with LocomotiveScroll and dynamic HTML tags
const LocomotiveScrollSection = ({
children,
className,
Tag = 'section', // Default tag is 'section'
...rest
}: LocomotiveScrollWrapperProps) => {
return (
<Tag
data-scroll-section
className={`overflow-hidden ${className}`}
{...rest} // Spread any additional props
>
{children}
</Tag>
);
};
export default LocomotiveScrollSection;
'use client';
import { usePathname } from 'next/navigation';
import {
ReactNode,
createContext,
useContext,
useEffect,
useRef,
useState,
} from 'react';
// Define the type for the context value, including the scroll instance and functions
type ScrollContextValue = {
scrollInstance: LocomotiveScroll | null;
scroll: number;
windowHeight: number;
scrollToSection: (id: string) => void;
};
// Create a context to hold the scroll-related data and functions
const ScrollContext = createContext<ScrollContextValue | null>(null);
// Custom hook to use the ScrollContext
// Throws an error if used outside the ScrollProvider
export const useScroll = (): ScrollContextValue => {
const context = useContext(ScrollContext);
if (!context) {
throw new Error('useScroll must be used within a ScrollProvider');
}
return context;
};
// ScrollProvider component to wrap the application or part of it
// Provides scroll-related data and functions to its children
export const ScrollProvider = ({ children }: { children: ReactNode }) => {
const scrollRef = useRef<LocomotiveScroll | null>(null); // Ref to hold the LocomotiveScroll instance
const [scrollPosition, setScrollPosition] = useState(0); // State to track the current scroll position
const [windowHeight, setWindowHeight] = useState(0); // State to track the window height for scroll limits
const pathname = usePathname(); // Get the current pathname to reset scroll on navigation
// useEffect to initialize LocomotiveScroll and handle scroll events
useEffect(() => {
if (!scrollRef.current) {
const initializeScroll = async () => {
const LocomotiveScroll = (await import('locomotive-scroll')).default;
scrollRef.current = new LocomotiveScroll({
el: document.querySelector('[data-scroll-container]') as HTMLElement,
lerp: 0.05,
smooth: true,
reloadOnContextChange: true,
smartphone: { smooth: true },
touchMultiplier: 3,
});
// Event listener for scroll events to update the scroll position and window height
scrollRef.current.on('scroll', (event: any) => {
setScrollPosition(event.scroll.y);
if (windowHeight !== event.limit.y) {
setWindowHeight(event.limit.y);
}
});
};
initializeScroll();
}
// Cleanup function to destroy the scroll instance when the component unmounts or pathname changes
return () => {
if (scrollRef.current) {
scrollRef.current.destroy();
scrollRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// Function to scroll to a specific section by ID
const scrollToSection = (id: string) => {
if (scrollRef.current) {
scrollRef.current.scrollTo(id);
}
};
// Provide the scroll data and functions to the context
return (
<ScrollContext.Provider
value={{
scroll: scrollPosition,
scrollToSection,
windowHeight,
scrollInstance: scrollRef.current,
}}
>
{children}
</ScrollContext.Provider>
);
};
import { ReactNode } from 'react';
import { Tag } from '@/lib/types';
// Define the allowed HTML tags for the component
type SectionTags = Extract<Tag, 'section' | 'div' | 'footer'>;
type LocomotiveScrollWrapperProps = {
children: ReactNode;
Tag?: SectionTags;
className?: string;
[x: string]: any;
};
// Component to wrap content with LocomotiveScroll and dynamic HTML tags
const LocomotiveScrollSection = ({
children,
className,
Tag = 'section', // Default tag is 'section'
...rest
}: LocomotiveScrollWrapperProps) => {
return (
<Tag
data-scroll-section
className={`overflow-hidden ${className}`}
{...rest} // Spread any additional props
>
{children}
</Tag>
);
};
export default LocomotiveScrollSection;
import JSZip from 'jszip';
import { useState } from 'react';
import { saveAs } from 'file-saver';
import { useToast } from '../context/ToastContext';
import { fetchSanityData } from '../sanity/client';
import { getPrivateCollectionGallery } from '../sanity/queries';
import { COLLECTION } from '../types';
const useDownloadCollection = ({ title, uniqueId, gallery }: COLLECTION) => {
const [loading, setLoading] = useState(false);
const { show } = useToast();
const folderName = `[MOGZ] ${title}`;
const zip = new JSZip();
const folder = zip.folder(folderName);
const showToast = (
message: string,
status: 'success' | 'error',
autoClose: boolean = true
) => {
show(message, { status, autoClose });
};
const checkRateLimit = async (id: string): Promise<boolean> => {
const response = await fetch(`/api/rateLimit?id=${id}`, {
method: 'GET',
});
if (!response.ok) {
const { message } = await response.json();
console.log('Rate limit status:', response.status, message);
showToast('Rate limit exceeded, please try again later.', 'error', false);
return false;
}
return true;
};
const addEmailToAudience = async (email: string) => {
try {
const response = await fetch('/api/contact/audience', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
console.log(response);
} catch (error) {
console.log(error);
}
};
const fetchImages = async () => {
const gallery: string[] = await fetchSanityData(
getPrivateCollectionGallery,
{ id: uniqueId }
);
return gallery;
};
const downloadImages = async (email: string) => {
setLoading(true);
let images = gallery;
try {
if (!(await checkRateLimit('download'))) return;
await addEmailToAudience(email);
if (!images) {
images = await fetchImages();
}
const imageFetchPromises = images.map(async (image, index) => {
try {
const response = await fetch(image);
if (!response.ok) {
throw new Error(
`Failed to fetch image at index ${index}, status: ${response.status}`
);
}
const blob = await response.blob();
if (!folder) {
throw new Error('folder is undefined');
}
folder.file(generateImageName(title, index), blob, { binary: true });
} catch (err) {
console.error(`Error fetching image at index ${index}:`, err);
throw err;
}
});
await Promise.all(imageFetchPromises);
console.log('Adding images done, proceeding to ZIP...');
const content = await zip.generateAsync({ type: 'blob' });
saveAs(content, `${folderName}.zip`);
showToast('Collection downloaded successfully!', 'success');
} catch (err: any) {
console.error(err);
showToast(
`An error occurred while downloading the collection! Try again later.`,
'error'
);
} finally {
setLoading(false);
}
};
return {
loading,
downloadImages,
};
};
export default useDownloadCollection;
const generateImageName = (title: string, index: number): string => {
const formattedTitle = title.replace(/\s/g, '-');
return `[MOGZ]-${formattedTitle}-${index + 1}.jpg`;
};
import JSZip from 'jszip';
import { useState } from 'react';
import { saveAs } from 'file-saver';
import { useToast } from '../context/ToastContext';
import { fetchSanityData } from '../sanity/client';
import { getPrivateCollectionGallery } from '../sanity/queries';
import { COLLECTION } from '../types';
const useDownloadCollection = ({ title, uniqueId, gallery }: COLLECTION) => {
const [loading, setLoading] = useState(false);
const { show } = useToast();
const folderName = `[MOGZ] ${title}`;
const zip = new JSZip();
const folder = zip.folder(folderName);
const showToast = (
message: string,
status: 'success' | 'error',
autoClose: boolean = true
) => {
show(message, { status, autoClose });
};
const checkRateLimit = async (id: string): Promise<boolean> => {
const response = await fetch(`/api/rateLimit?id=${id}`, {
method: 'GET',
});
if (!response.ok) {
const { message } = await response.json();
console.log('Rate limit status:', response.status, message);
showToast('Rate limit exceeded, please try again later.', 'error', false);
return false;
}
return true;
};
const addEmailToAudience = async (email: string) => {
try {
const response = await fetch('/api/contact/audience', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
console.log(response);
} catch (error) {
console.log(error);
}
};
const fetchImages = async () => {
const gallery: string[] = await fetchSanityData(
getPrivateCollectionGallery,
{ id: uniqueId }
);
return gallery;
};
const downloadImages = async (email: string) => {
setLoading(true);
let images = gallery;
try {
if (!(await checkRateLimit('download'))) return;
await addEmailToAudience(email);
if (!images) {
images = await fetchImages();
}
const imageFetchPromises = images.map(async (image, index) => {
try {
const response = await fetch(image);
if (!response.ok) {
throw new Error(
`Failed to fetch image at index ${index}, status: ${response.status}`
);
}
const blob = await response.blob();
if (!folder) {
throw new Error('folder is undefined');
}
folder.file(generateImageName(title, index), blob, { binary: true });
} catch (err) {
console.error(`Error fetching image at index ${index}:`, err);
throw err;
}
});
await Promise.all(imageFetchPromises);
console.log('Adding images done, proceeding to ZIP...');
const content = await zip.generateAsync({ type: 'blob' });
saveAs(content, `${folderName}.zip`);
showToast('Collection downloaded successfully!', 'success');
} catch (err: any) {
console.error(err);
showToast(
`An error occurred while downloading the collection! Try again later.`,
'error'
);
} finally {
setLoading(false);
}
};
return {
loading,
downloadImages,
};
};
export default useDownloadCollection;
const generateImageName = (title: string, index: number): string => {
const formattedTitle = title.replace(/\s/g, '-');
return `[MOGZ]-${formattedTitle}-${index + 1}.jpg`;
};
import Cookies from 'js-cookie';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useToast } from '../context/ToastContext';
export const useAutoDeleteCookie = (slug: string, isPrivate: boolean) => {
const [decryptedSlug, setDecryptedSlug] = useState<string | null>(null);
const { show } = useToast();
const router = useRouter();
useEffect(() => {
if (isPrivate) {
const func = async () => {
const encryptedCookie = Cookies.get('collectionAccess');
if (encryptedCookie) {
const parsedCookie = JSON.parse(encryptedCookie);
console.log('encrypted slug', parsedCookie.slug);
const decryptedSlug = await getDecryptedSlug(parsedCookie.slug);
setDecryptedSlug(decryptedSlug);
}
};
// Decrypt the cookie and set the state
func();
}
}, [isPrivate]);
useEffect(() => {
if (isPrivate && decryptedSlug) {
const timer = setTimeout(() => {
if (slug === decryptedSlug) {
Cookies.remove('collectionAccess');
router.push('/gallery');
show('Your access to private collection expired!', {
status: 'info',
autoClose: false,
});
}
}, 1 * 60 * 60 * 1000); // 1 hour in milliseconds
// Listen to the 'beforeunload' event to delete the cookie when the tab is closed
const handleBeforeUnload = async () => {
if (slug === decryptedSlug) {
Cookies.remove('collectionAccess');
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
clearTimeout(timer);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}
}, [decryptedSlug, isPrivate, router, show, slug]);
};
const getDecryptedSlug = async (encryptedCookie: string) => {
const response = await fetch('/api/decryptCookie', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ encryptedCookie }),
});
const { decryptedSlug } = await response.json();
return decryptedSlug;
};
import Cookies from 'js-cookie';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useToast } from '../context/ToastContext';
export const useAutoDeleteCookie = (slug: string, isPrivate: boolean) => {
const [decryptedSlug, setDecryptedSlug] = useState<string | null>(null);
const { show } = useToast();
const router = useRouter();
useEffect(() => {
if (isPrivate) {
const func = async () => {
const encryptedCookie = Cookies.get('collectionAccess');
if (encryptedCookie) {
const parsedCookie = JSON.parse(encryptedCookie);
console.log('encrypted slug', parsedCookie.slug);
const decryptedSlug = await getDecryptedSlug(parsedCookie.slug);
setDecryptedSlug(decryptedSlug);
}
};
// Decrypt the cookie and set the state
func();
}
}, [isPrivate]);
useEffect(() => {
if (isPrivate && decryptedSlug) {
const timer = setTimeout(() => {
if (slug === decryptedSlug) {
Cookies.remove('collectionAccess');
router.push('/gallery');
show('Your access to private collection expired!', {
status: 'info',
autoClose: false,
});
}
}, 1 * 60 * 60 * 1000); // 1 hour in milliseconds
// Listen to the 'beforeunload' event to delete the cookie when the tab is closed
const handleBeforeUnload = async () => {
if (slug === decryptedSlug) {
Cookies.remove('collectionAccess');
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
clearTimeout(timer);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}
}, [decryptedSlug, isPrivate, router, show, slug]);
};
const getDecryptedSlug = async (encryptedCookie: string) => {
const response = await fetch('/api/decryptCookie', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ encryptedCookie }),
});
const { decryptedSlug } = await response.json();
return decryptedSlug;
};