Vibe coding with AI course Β· Module 14: Pinterest Clone
Masonry Grid and Pin Discovery
In this lesson5
Welcome! Our last stop - Treasure Map! A visual treasure discovery platform!
This will be like Pinterest - masonry grid, pins, boards, visual search.
Pinterest vs Instagram/Twitter
Instagram:
- Square/vertical photos
- Following-based feed
- Stories + Reels
Twitter:
- Text-first
- Chronological timeline
- Short updates
Pinterest:
- Visual discovery (not social networking)
- Masonry grid layout (different sizes)
- Boards (collections of pins)
- Visual search (search by image)
- Shopping integration
- No likes/comments focus - saving is key
- Inspiration and planning (weddings, recipes, home decor)
Pin Structure
interface Pin {
id: string;
author: User;
// Content
imageUrl: string;
title: string;
description: string;
link?: string; // external link (recipe, product, article)
// Metadata
width: number; // original image dimensions
height: number;
dominantColor: string; // for placeholder
boardId?: string;
board?: Board;
// Engagement
saves: number; // key metric (not likes!)
comments: Comment[];
// Discovery
tags: string[];
relatedPins: Pin[];
createdAt: Date;
}
interface Board {
id: string;
userId: string;
user: User;
// Info
name: string;
description?: string;
coverPinId?: string; // main pin image
// Content
pins: Pin[];
sections?: BoardSection[]; // organize pins within board
// Privacy
isPrivate: boolean;
collaborators?: string[]; // users who can add pins
createdAt: Date;
updatedAt: Date;
}
interface BoardSection {
id: string;
name: string;
pins: Pin[];
}Masonry Grid Layout
'use client';
import { useState, useEffect, useRef } from 'react';
interface MasonryGridProps {
pins: Pin[];
columns?: number;
gap?: number;
}
export default function MasonryGrid({
pins,
columns = 4,
gap = 16
}: MasonryGridProps) {
const [columnHeights, setColumnHeights] = useState<number[]>(
Array(columns).fill(0)
);
// Distribute pins across columns
const distributePins = () => {
const columnArrays: Pin[][] = Array.from({ length: columns }, () => []);
const heights = Array(columns).fill(0);
pins.forEach(pin => {
// Find column with minimum height
const minHeight = Math.min(...heights);
const columnIndex = heights.indexOf(minHeight);
// Add pin to that column
columnArrays[columnIndex].push(pin);
// Update column height (approximate)
const aspectRatio = pin.height / pin.width;
const pinHeight = 300 * aspectRatio; // assuming 300px width
heights[columnIndex] += pinHeight + gap;
});
setColumnHeights(heights);
return columnArrays;
};
const columnArrays = distributePins();
return (
<div
className="w-full"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: `${gap}px`
}}
>
{columnArrays.map((columnPins, columnIndex) => (
<div key={columnIndex} className="flex flex-col" style={{ gap: `${gap}px` }}>
{columnPins.map(pin => (
<PinCard key={pin.id} pin={pin} />
))}
</div>
))}
</div>
);
}
function PinCard({ pin }: { pin: Pin }) {
const [isHovered, setIsHovered] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
return (
<div
className="relative rounded-2xl overflow-hidden cursor-zoom-in group"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Placeholder */}
{!imageLoaded && (
<div
className="w-full"
style={{
paddingBottom: `${(pin.height / pin.width) * 100}%`,
backgroundColor: pin.dominantColor
}}
/>
)}
{/* Image */}
<img
src={pin.imageUrl}
alt={pin.title}
onLoad={() => setImageLoaded(true)}
className={`
w-full h-auto block
${!imageLoaded ? 'absolute inset-0' : ''}
`}
/>
{/* Hover Overlay */}
{isHovered && (
<div className="absolute inset-0 bg-black/40 p-4 flex flex-col justify-between opacity-0 group-hover:opacity-100 transition">
{/* Top Actions */}
<div className="flex justify-end">
<button className="px-4 py-2 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700">
Save
</button>
</div>
{/* Bottom Info */}
<div>
{pin.title && (
<h3 className="text-white font-semibold text-lg mb-2">
{pin.title}
</h3>
)}
<div className="flex items-center gap-2">
<img
src={pin.author.avatar}
alt={pin.author.name}
className="w-8 h-8 rounded-full"
/>
<span className="text-white text-sm">{pin.author.name}</span>
</div>
</div>
</div>
)}
</div>
);
}Infinite Scroll
'use client';
export default function PinFeed() {
const [pins, setPins] = useState<Pin[]>([]);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMorePins = async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
const newPins = await fetchPins(page);
if (newPins.length === 0) {
setHasMore(false);
} else {
setPins([...pins, ...newPins]);
setPage(page + 1);
}
setIsLoading(false);
};
// Intersection Observer for infinite scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMorePins();
}
},
{ threshold: 0.5 }
);
const sentinel = document.getElementById('scroll-sentinel');
if (sentinel) observer.observe(sentinel);
return () => observer.disconnect();
}, [page, isLoading, hasMore]);
return (
<div className="p-8">
<MasonryGrid pins={pins} />
{/* Sentinel element */}
<div id="scroll-sentinel" className="h-20 flex items-center justify-center">
{isLoading && <div className="text-gray-500">Loading more pins...</div>}
</div>
</div>
);
}Summary
Masonry grid - dynamic columns with varying heights Pin card - image, hover overlay, save button Aspect ratio - maintain original image proportions Column distribution - balance heights across columns Dominant color - placeholder while loading Infinite scroll - Intersection Observer Hover effects - show actions and info
In the next exercise we'll add boards and saving!
See you there!
Code for this lesson: App.tsx
'use client';
import { useState } from 'react';
interface Pin {
id: string;
imageUrl: string;
title: string;
author: {
name: string;
avatar: string;
};
width: number;
height: number;
dominantColor: string;
}
const mockPins: Pin[] = [
{
id: '1',
imageUrl: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=400',
title: 'Pirate Ship at Sunset',
author: { name: 'Captain Redbeard', avatar: 'https://i.pravatar.cc/150?img=12' },
width: 400,
height: 600,
dominantColor: '#4a5568'
},
{
id: '2',
imageUrl: 'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=400',
title: 'Coding Setup',
author: { name: 'Sailor Sam', avatar: 'https://i.pravatar.cc/150?img=33' },
width: 400,
height: 267,
dominantColor: '#2d3748'
},
{
id: '3',
imageUrl: 'https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=400',
title: 'Music Production',
author: { name: 'Navigator Nancy', avatar: 'https://i.pravatar.cc/150?img=45' },
width: 400,
height: 500,
dominantColor: '#1a202c'
}
];
function MasonryGrid({ pins }: { pins: Pin[] }) {
const columns = 3;
const gap = 16;
// Distribute pins across columns
const columnArrays: Pin[][] = Array.from({ length: columns }, () => []);
const heights = Array(columns).fill(0);
pins.forEach(pin => {
const minHeight = Math.min(...heights);
const columnIndex = heights.indexOf(minHeight);
columnArrays[columnIndex].push(pin);
const aspectRatio = pin.height / pin.width;
const pinHeight = 300 * aspectRatio;
heights[columnIndex] += pinHeight + gap;
});
return (
<div
className="w-full"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: `${gap}px`
}}
>
{columnArrays.map((columnPins, i) => (
<div key={i} className="flex flex-col" style={{ gap: `${gap}px` }}>
{columnPins.map(pin => (
<PinCard key={pin.id} pin={pin} />
))}
</div>
))}
</div>
);
}
function PinCard({ pin }: { pin: Pin }) {
const [isHovered, setIsHovered] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
return (
<div
className="relative rounded-2xl overflow-hidden cursor-zoom-in group"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{!imageLoaded && (
<div
className="w-full"
style={{
paddingBottom: `${(pin.height / pin.width) * 100}%`,
backgroundColor: pin.dominantColor
}}
/>
)}
<img
src={pin.imageUrl}
alt={pin.title}
onLoad={() => setImageLoaded(true)}
className="w-full h-auto block"
/>
{isHovered && (
<div className="absolute inset-0 bg-black/40 p-4 flex flex-col justify-between opacity-0 group-hover:opacity-100 transition">
<div className="flex justify-end">
<button className="px-4 py-2 bg-red-600 text-white rounded-full font-semibold hover:bg-red-700">
Save
</button>
</div>
<div>
<h3 className="text-white font-semibold mb-2">{pin.title}</h3>
<div className="flex items-center gap-2">
<img src={pin.author.avatar} alt={pin.author.name} className="w-8 h-8 rounded-full" />
<span className="text-white text-sm">{pin.author.name}</span>
</div>
</div>
</div>
)}
</div>
);
}
export default function App() {
return (
<div className="min-h-screen bg-white p-8">
<h1 className="text-3xl font-bold mb-8 text-center">Masonry Grid</h1>
<MasonryGrid pins={mockPins} />
</div>
);
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Main difference between Pinterest and Instagram?
2. What is masonry layout?
These are 2 of 8 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the steps of pin distribution in masonry:
- Vertical ordering
Order the steps of placing a single pin in the masonry grid:
- Code editor
Create a masonry layout for pins
- Horizontal ordering
Arrange the order of elements on a single line of the pin image tag:
- Click in order
Click the elements to build the React hook import used in PinCard:
- Code editor
Build a pin card with hover effects
- Vertical ordering
Order the steps of the loadMorePins function during infinite scroll:
- Click in order
Click the elements to build registering the sentinel element with the IntersectionObserver:
- Horizontal ordering
Arrange the elements of the CSS grid value for masonry columns: repeat(columns, 1fr)
- Click in order
Click the elements to build the computation of the minimum column height:
- Vertical ordering
Order the PinCard layers from bottom (background) to top (front):