Docs
Project Gallery
Project Gallery
A Project Gallery with an interactive modal
Here's a full page preview of this component.
Installation
Install the following dependencies:
npm install motion
Copy and paste the following code into your project.
components/atlas_ui/project-gallery.tsx
"use client";
import React, {
useState,
useRef,
useEffect,
Dispatch,
SetStateAction,
ReactNode,
} from "react";
import { motion, useMotionValue, useSpring } from "motion/react";
import Image from "next/image";
import Link from "next/link";
interface ProjectGalleryProps {
children: ReactNode;
}
interface BaseProjectProps {
title: string;
subtitle: string;
href: string;
imgSrc: string;
color: string;
}
interface InternalProjectProps extends BaseProjectProps {
index: number;
setModal: Dispatch<SetStateAction<{ active: boolean; index: number }>>;
}
interface ModalProps {
modal: { active: boolean; index: number };
projects: BaseProjectProps[];
containerRef: React.RefObject<HTMLDivElement>;
}
const ProjectGallery = ({ children }: ProjectGalleryProps) => {
const [modal, setModal] = useState({ active: false, index: 0 });
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className="grid grid-cols-1 sm:grid-cols-2 md:block relative"
>
{children &&
React.Children.map(children, (child, index) => {
if (React.isValidElement<BaseProjectProps>(child)) {
return React.cloneElement(
child as React.ReactElement<InternalProjectProps>,
{
index,
setModal,
}
);
}
return child;
})}
<Modal
modal={modal}
projects={React.Children.toArray(children)
.map((child) => {
if (React.isValidElement<BaseProjectProps>(child)) {
const { title, subtitle, imgSrc, color, href } = child.props;
return { title, subtitle, imgSrc, color, href };
}
return null;
})
.filter((project): project is BaseProjectProps => project !== null)}
containerRef={containerRef}
/>
</div>
);
};
const Project = (props: BaseProjectProps | InternalProjectProps) => {
const { title, subtitle, href, imgSrc, color, index, setModal } =
props as InternalProjectProps;
return (
<Link href={href}>
<div
className="flex w-full flex-col justify-between p-4 md:flex-row md:items-center md:border-t md:p-24 border-primary cursor-pointer group transition-all duration-200 ease-linear md:hover:opacity-40"
onMouseEnter={() => setModal?.({ active: true, index })}
onMouseLeave={() => setModal?.({ active: false, index })}
>
<div
style={{ backgroundColor: color }}
className="relative mb-4 w-full overflow-hidden aspect-[4/3] md:hidden"
>
<Image
src={imgSrc}
alt={title}
fill
className="object-contain p-4"
sizes="(max-width: 768px) 100vw"
/>
</div>
<h2 className="font-bold md:font-normal text-2xl sm:text-3xl md:text-6xl md:group-hover:-translate-x-3 transition-all duration-200 ease-linear">
{title}
</h2>
<p className="font-light text-sm sm:text-base md:group-hover:translate-x-3 transition-all duration-200 ease-linear">
{subtitle}
</p>
</div>
</Link>
);
};
const Modal = ({ modal, projects, containerRef }: ModalProps) => {
const { index, active } = modal;
const modalRef = useRef(null);
const cursorRef = useRef(null);
const cursorLabelRef = useRef(null);
const mouse = {
x: useMotionValue(0),
y: useMotionValue(0),
};
const cursor = {
_x: useMotionValue(0),
_y: useMotionValue(0),
};
const mouseMove = (e: any) => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const styles = getComputedStyle(containerRef.current);
const paddingLeft = parseFloat(styles.paddingLeft) || 0;
const paddingTop = parseFloat(styles.paddingTop) || 0;
const borderLeftWidth = parseFloat(styles.borderLeftWidth) || 0;
const borderTopWidth = parseFloat(styles.borderTopWidth) || 0;
const x = e.clientX - rect.left - paddingLeft - borderLeftWidth;
const y = e.clientY - rect.top - paddingTop - borderTopWidth;
mouse.x.set(x);
mouse.y.set(y);
}
};
const cursorMove = (e: any) => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const styles = getComputedStyle(containerRef.current);
const paddingLeft = parseFloat(styles.paddingLeft) || 0;
const paddingTop = parseFloat(styles.paddingTop) || 0;
const borderLeftWidth = parseFloat(styles.borderLeftWidth) || 0;
const borderTopWidth = parseFloat(styles.borderTopWidth) || 0;
const x = e.clientX - rect.left - paddingLeft - borderLeftWidth;
const y = e.clientY - rect.top - paddingTop - borderTopWidth;
cursor._x.set(x);
cursor._y.set(y);
}
};
const smoothOptions = {
damping: 40,
stiffness: 300,
mass: 0.5,
};
const cursorMoveSmoothOptions = {
damping: 20,
stiffness: 300,
mass: 0.5,
};
const smoothMouse = {
x: useSpring(mouse.x, smoothOptions),
y: useSpring(mouse.y, smoothOptions),
};
const smoothCursor = {
_x: useSpring(cursor._x, cursorMoveSmoothOptions),
_y: useSpring(cursor._y, cursorMoveSmoothOptions),
};
useEffect(() => {
window.addEventListener("mousemove", mouseMove);
window.addEventListener("mousemove", cursorMove);
return () => {
window.removeEventListener("mousemove", mouseMove);
window.removeEventListener("mousemove", cursorMove);
};
}, []);
const scaleAnimation = {
initial: {
scale: 0,
x: "-50%",
y: "-50%",
},
open: {
scale: 1,
x: "-50%",
y: "-50%",
transition: {
duration: 0.4,
ease: [0.76, 0, 0.24, 1],
},
},
closed: {
scale: 0,
x: "-50%",
y: "-50%",
transition: {
duration: 0.4,
ease: [0.32, 0, 0.67, 0],
},
},
};
return (
<>
<motion.div
variants={scaleAnimation}
initial={"initial"}
animate={active ? "open" : "closed"}
ref={modalRef}
className="h-[350px] w-[400px] hidden md:flex items-center justify-center absolute overflow-hidden pointer-events-none"
style={{
left: smoothMouse.x,
top: smoothMouse.y,
}}
>
<div
className="h-full w-full absolute transition-[top] duration-500 ease-[cubic-bezier(0.76, 0, 0.24, 1)]"
style={{
top: index * -100 + "%",
}}
>
{projects.map((project, i) => {
const { title, color, imgSrc } = project;
return (
<div
key={i}
style={{
backgroundColor: color,
}}
className="relative h-full flex items-center justify-center"
>
<Image
src={imgSrc}
width={350}
height={0}
alt={title}
className="h-auto"
/>
</div>
);
})}
</div>
</motion.div>
<motion.div
variants={scaleAnimation}
initial={"initial"}
animate={active ? "open" : "closed"}
ref={cursorRef}
className="h-20 w-20 hidden md:flex items-center justify-center rounded-full absolute pointer-events-none bg-blue-600 "
style={{
left: smoothCursor._x,
top: smoothCursor._y,
}}
/>
<motion.div
variants={scaleAnimation}
initial={"initial"}
animate={active ? "open" : "closed"}
ref={cursorLabelRef}
className="h-20 w-20 hidden md:flex items-center justify-center rounded-full absolute pointer-events-none bg-transparent text-primary"
style={{
left: smoothCursor._x,
top: smoothCursor._y,
}}
>
View
</motion.div>
</>
);
};
export { ProjectGallery, Project };
Update the import paths to match your project setup.
Usage
import {
Project,
ProjectGallery,
} from "@/components/atlas_ui/(react)/project-gallery";
<ProjectGallery>
<Project
title="Atlas UI"
subtitle="Frontend"
href="#"
imgSrc="/assets/atlas-ui.png"
color="#EFE8D3"
/>
<Project
title="Portfolio"
subtitle="Design & Development"
href="#"
imgSrc="/assets/portfolio.png"
color="#706D63"
/>
<Project
title="Tokyo Art"
subtitle="Photography"
href="#"
imgSrc="https://images.unsplash.com/photo-1540652980807-db4655e8fa60?q=80&w=2130&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
color="#FAFBFC"
/>
<Project
title="Milky Way"
subtitle="Photography"
href="#"
imgSrc="https://images.unsplash.com/photo-1515705576963-95cad62945b6?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
color="#404040"
/>
</ProjectGallery>
Props
Prop | Type | Description |
---|---|---|
title | string | Project Name / Title |
subtitle | string | Project SubTitle / Category |
href | string | Link to the project |
imgSrc | string | Image Source for ComponentPreview |
color | string | Background Color for the Modal |