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
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 >
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.
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.
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.
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 ( 280 px , 1 fr ));
gap : var ( --spacing-lg );
}
How it works:
display: grid - Activates CSS Grid
repeat(auto-fill, ...) - Creates as many columns as will fit
minmax(280px, 1fr) - Each column is minimum 280px, maximum 1 fraction of remaining space
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 Width Columns Column Width 320px (mobile) 1 280-320px 768px (tablet) 2 ~360px each 1024px (desktop) 3 ~320px each 1400px (large) 4-5 280-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 ;
}
.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
.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 : 2 px 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 : 40 px ;
height : 40 px ;
/* Circular border with one colored segment */
border : 4 px solid var ( --color-gray-200 );
border-top-color : var ( --color-secondary );
border-radius : 50 % ; /* Perfect circle */
/* Infinite spin animation */
animation : spin 1 s linear infinite ;
}
@keyframes spin {
from {
transform : rotate ( 0 deg );
}
to {
transform : rotate ( 360 deg );
}
}
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 : 1 px 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 ( -8 px ); /* 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.5 px ; /* 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 );
}
.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:
.map() transforms each product into HTML string
.join('') combines all strings into one
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