Skip to main content

Product Catalog Overview

The product catalog is the heart of the e-commerce store. It displays products in a responsive grid that automatically adjusts columns based on screen size, includes loading states, error handling, and dynamic rendering.

Features You’ll Build

  • Auto-responsive CSS Grid layout
  • Loading spinner animation
  • Error state with retry button
  • Empty state placeholder
  • Dynamic product card generation

Building the HTML Structure

1

Create the Products Section

Start with a semantic <section> element:
<section class="products" id="products-section">
  <div class="products__container">
    <!-- Content goes here -->
  </div>
</section>
2

Add the Header

Create a header with title and load button:
<div class="products__header">
  <h2 class="products__title">Nuestros Productos</h2>
  
  <button 
    type="button" 
    class="products__load-btn" 
    id="load-products-btn"
  >
    Cargar Productos
  </button>
</div>
The id="load-products-btn" is crucial - TypeScript will find this element to attach the click event.
3

Create the Products Grid

Add an empty grid container:
<div class="products__grid" id="products-grid">
  <p class="products__empty-state">
    Haz clic en "Cargar Productos" para ver el catálogo
  </p>
</div>
This starts with a placeholder message. TypeScript will replace it with products.
4

Add Loading State

Create a loading spinner (hidden by default):
<div class="products__loading" id="products-loading" hidden aria-live="polite">
  <div class="products__spinner"></div>
  <p>Cargando productos...</p>
</div>
Accessibility: aria-live="polite" announces loading status to screen readers.
5

Add Error State

Create error message container (also hidden):
<div class="products__error" id="products-error" hidden>
  <p class="products__error-message">
    Error al cargar productos. Por favor, intenta de nuevo.
  </p>
  <button type="button" class="products__retry-btn" id="retry-btn">
    Reintentar
  </button>
</div>

Complete Products HTML

<section class="products" id="products-section">
  <div class="products__container">
    <div class="products__header">
      <h2 class="products__title">Nuestros Productos</h2>
      <button type="button" class="products__load-btn" id="load-products-btn">
        Cargar Productos
      </button>
    </div>

    <div class="products__grid" id="products-grid">
      <p class="products__empty-state">
        Haz clic en "Cargar Productos" para ver el catálogo
      </p>
    </div>

    <div class="products__loading" id="products-loading" hidden>
      <div class="products__spinner"></div>
      <p>Cargando productos...</p>
    </div>

    <div class="products__error" id="products-error" hidden>
      <p class="products__error-message">
        Error al cargar productos. Por favor, intenta de nuevo.
      </p>
      <button type="button" class="products__retry-btn" id="retry-btn">
        Reintentar
      </button>
    </div>
  </div>
</section>

CSS Grid Layout

Auto-Responsive Grid

The magic of CSS Grid - no media queries needed:
.products__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: var(--spacing-lg);
}
How it works:
  1. display: grid - Activates CSS Grid
  2. repeat(auto-fill, ...) - Creates as many columns as will fit
  3. minmax(280px, 1fr) - Each column is minimum 280px, maximum 1 fraction of remaining space
  4. gap - Space between grid items
auto-fill vs auto-fit: Both create flexible columns, but:
  • auto-fill keeps empty columns if there’s extra space
  • auto-fit collapses empty columns and expands existing ones

What This Achieves

Screen WidthColumnsColumn Width
320px (mobile)1280-320px
768px (tablet)2~360px each
1024px (desktop)3~320px each
1400px (large)4-5280-300px each
All automatic, no media queries!

Grid Container Styling

.products {
  padding: var(--spacing-2xl) var(--spacing-lg);
  background-color: var(--color-gray-100);
}

.products__container {
  max-width: var(--container-max-width);
  margin: 0 auto;
}

Header with Flexbox

.products__header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap; /* Stack on narrow screens */
  gap: var(--spacing-md);
  margin-bottom: var(--spacing-xl);
}

.products__title {
  font-size: var(--font-size-2xl);
  color: var(--color-gray-600);
}
flex-wrap: wrap allows items to stack vertically on narrow screens instead of being squished.

State Styling

Load Button

.products__load-btn {
  padding: var(--spacing-sm) var(--spacing-lg);
  background-color: var(--color-secondary);
  color: var(--color-white);
  font-weight: 600;
  border-radius: var(--border-radius-sm);
  transition: background-color var(--transition-fast);
}

.products__load-btn:hover {
  background-color: var(--color-secondary-dark);
}

.products__load-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

Empty State

.products__empty-state {
  /* Span all columns */
  grid-column: 1 / -1;
  
  text-align: center;
  padding: var(--spacing-2xl);
  color: var(--color-gray-400);
  background-color: var(--color-white);
  border-radius: var(--border-radius-md);
  border: 2px dashed var(--color-gray-300);
}
grid-column: 1 / -1 makes the element span from the first column (1) to the last column (-1), taking up the full width.

Loading State

.products__loading {
  grid-column: 1 / -1;
  
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--spacing-md);
  
  padding: var(--spacing-2xl);
  color: var(--color-gray-500);
}

Animated Spinner

.products__spinner {
  width: 40px;
  height: 40px;
  
  /* Circular border with one colored segment */
  border: 4px solid var(--color-gray-200);
  border-top-color: var(--color-secondary);
  border-radius: 50%; /* Perfect circle */
  
  /* Infinite spin animation */
  animation: spin 1s linear infinite;
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
Animation Breakdown:
  • spin - Name of the animation (defined below)
  • 1s - Duration (one full rotation per second)
  • linear - Constant speed (no easing)
  • infinite - Never stops
Use linear timing for spinners. Easing functions like ease look awkward for continuous rotation.

Error State

.products__error {
  grid-column: 1 / -1;
  text-align: center;
  padding: var(--spacing-xl);
  background-color: #FFF5F5; /* Light red background */
  border: 1px solid var(--color-error);
  border-radius: var(--border-radius-md);
}

.products__error-message {
  color: var(--color-error);
  margin-bottom: var(--spacing-md);
}

.products__retry-btn {
  padding: var(--spacing-sm) var(--spacing-lg);
  background-color: var(--color-error);
  color: var(--color-white);
  font-weight: 600;
  border-radius: var(--border-radius-sm);
  transition: background-color var(--transition-fast);
}

.products__retry-btn:hover {
  background-color: #D13545; /* Darker red */
}

Product Card Component

Individual product cards are generated dynamically by TypeScript, but here’s the CSS:
.product-card {
  background-color: var(--color-white);
  border-radius: var(--border-radius-md);
  overflow: hidden; /* Image respects border-radius */
  box-shadow: var(--shadow-sm);
  
  transition: transform var(--transition-base),
              box-shadow var(--transition-base);
}

.product-card:hover {
  transform: translateY(-8px); /* Lift up on hover */
  box-shadow: var(--shadow-lg); /* Deeper shadow */
}

Product Image

.product-card__figure {
  /* Maintain 4:3 aspect ratio */
  aspect-ratio: 4 / 3;
  overflow: hidden;
  background-color: var(--color-gray-100);
}

.product-card__image {
  width: 100%;
  height: 100%;
  object-fit: cover; /* Fill area, may crop */
  transition: transform var(--transition-base);
}

/* Zoom image on card hover */
.product-card:hover .product-card__image {
  transform: scale(1.05);
}
aspect-ratio: 4 / 3 - Modern CSS property that maintains proportions, preventing layout shift while images load. object-fit: cover - Options:
  • cover - Fills area, crops if necessary (most common)
  • contain - Fits entire image, may leave empty space
  • fill - Stretches to fill (can distort)

Product Content

.product-card__content {
  padding: var(--spacing-md);
}

.product-card__category {
  display: inline-block;
  padding: var(--spacing-xs) var(--spacing-sm);
  background-color: var(--color-gray-100);
  color: var(--color-gray-500);
  font-size: var(--font-size-xs);
  border-radius: var(--border-radius-sm);
  margin-bottom: var(--spacing-sm);
  text-transform: uppercase;
  letter-spacing: 0.5px; /* Spacing for uppercase text */
}

.product-card__title {
  font-size: var(--font-size-base);
  font-weight: 500;
  color: var(--color-gray-600);
  margin-bottom: var(--spacing-sm);
  
  /* Truncate with ellipsis if too long */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.product-card__price {
  font-size: var(--font-size-xl);
  font-weight: 700;
  color: var(--color-gray-600);
  margin-bottom: var(--spacing-md);
}

Add to Cart Button

.product-card__btn {
  width: 100%; /* Full width */
  padding: var(--spacing-sm) var(--spacing-md);
  background-color: var(--color-secondary);
  color: var(--color-white);
  font-weight: 600;
  border-radius: var(--border-radius-sm);
  transition: background-color var(--transition-fast);
}

.product-card__btn:hover {
  background-color: var(--color-secondary-dark);
}

Dynamic Rendering with TypeScript

The HTML for product cards is generated in TypeScript:
function createProductCardHTML(product: Product): string {
  const formattedPrice = product.price.toLocaleString('es-MX', {
    style: 'currency',
    currency: 'MXN'
  });
  
  const imageUrl = product.images[0] || 'https://placehold.co/400x300?text=Sin+imagen';
  const cleanImageUrl = imageUrl.replace(/["\[\]]/g, '');
  
  return `
    <article class="product-card" data-product-id="${product.id}">
      <figure class="product-card__figure">
        <img 
          src="${cleanImageUrl}" 
          alt="${product.title}"
          class="product-card__image"
          loading="lazy"
          onerror="this.src='https://placehold.co/400x300?text=Error'"
        />
      </figure>
      <div class="product-card__content">
        <span class="product-card__category">${product.category.name}</span>
        <h3 class="product-card__title" title="${product.title}">${product.title}</h3>
        <p class="product-card__price">
          ${formattedPrice}
          <span class="product-card__shipping">Envío gratis</span>
        </p>
        <button type="button" class="product-card__btn" data-action="add-to-cart">
          Agregar al carrito
        </button>
      </div>
    </article>
  `;
}

Rendering All Products

function renderProducts(): void {
  const grid = getElement<HTMLDivElement>("#products-grid");
  
  if (appState.products.length === 0) {
    grid.innerHTML = `<p class="products__empty-state">No se encontraron productos</p>`;
    return;
  }
  
  // Transform array of products into HTML string
  grid.innerHTML = appState.products
    .map(product => createProductCardHTML(product))
    .join('');
  
  // Setup event listeners for "Add to Cart" buttons
  setupAddToCartButtons();
}
How it works:
  1. .map() transforms each product into HTML string
  2. .join('') combines all strings into one
  3. innerHTML replaces grid content with product cards

Complete Code Reference

HTML: /workspace/source/mi-tutorial/index.html:552-644 CSS: /workspace/source/mi-tutorial/src/style.css:1017-1350 TypeScript: /workspace/source/mi-tutorial/src/main.ts:460-663

Next Steps

Shopping Cart

Implement the cart functionality with Map

API Integration

Learn how products are fetched from the API

Build docs developers (and LLMs) love