Vibe coding with AI course Β· Module 14: Pinterest Clone

Masonry Grid and Pin Discovery

4 min read
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. 1. Main difference between Pinterest and Instagram?

  2. 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):

Useful articles