This is a Next.js project that displays a product listing with product cards using shadcn/ui components and Tailwind CSS.
First, install dependencies:
pnpm installThen, run the development server:
pnpm devOpen http://localhost:3000 to see the product listing.
- Responsive product grid layout (1 column on mobile, 2 on tablet, 3 on desktop)
- Product cards with images, prices, and stock status
- Sale badges for discounted items
- Stock status indicators (In Stock/Out of Stock)
- Hover effects and smooth transitions
- Optimized images with Next.js Image component
Add the specific components used in this project:
pnpm dlx shadcn@latest add card badgeThis installs:
- Card: For the product card layout (CardContent, CardHeader, etc.)
- Badge: For status indicators (In Stock, Out of Stock, Sale)
Update next.config.ts to allow images from external sources:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
};
export default nextConfig;
├── app/
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── stock-badge.tsx
│ └── ui/
│ ├── badge.tsx
│ └── card.tsx
├── lib/
│ └── utils.ts
├── public/
│ ├── file.svg
│ ├── globe.svg
│ ├── next.svg
│ ├── vercel.svg
│ └── window.svg
├── components.json
├── eslint.config.mjs
├── next.config.ts
├── package.json
├── postcss.config.mjs
└── tsconfig.json
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StockBadge } from "@/components/stock-badge";
import Image from "next/image";- Card, CardContent: shadcn/ui components for structured card layout
- Badge: shadcn/ui component for status labels
- StockBadge: Custom reusable component for product availability
- Image: Next.js optimized image component with lazy loading and responsive sizes
const products = [
{
id: 1,
name: "Wireless Headphones",
price: 129.99,
originalPrice: 179.99,
inStock: true,
image:
"https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=500&h=500&fit=crop",
},
// ... more products
];Each product object contains:
id: Unique identifier for React keyname: Product name displayed as headingprice: Current/sale priceoriginalPrice: Original price (shows strikethrough if on sale)inStock: Boolean for stock statusimage: URL to product image
<div className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 py-12 px-4 sm:px-6 lg:px-8">- Full viewport height background
- Gradient background from light gray to slightly darker gray
- Responsive padding that increases on larger screens
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">Responsive grid that adapts to screen size:
- Mobile: 1 column (
grid-cols-1) - Tablet: 2 columns (
md:grid-cols-2) - Desktop: 3 columns (
lg:grid-cols-3) - Gap: 6 units of spacing between items
<Card key={product.id} className="overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-0">key={product.id}: React key for efficient renderingoverflow-hidden: Clips content to card boundarieshover:shadow-lg: Adds shadow on hover for depthtransition-shadow: Smooth shadow animationp-0: Removes default padding for full-width image
<div className="relative bg-gray-200 h-64 overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover hover:scale-105 transition-transform"
/>relative: Allows absolute positioning of badgesh-64: Fixed height (256px) for consistent card sizingfill: Image fills the parent containerobject-cover: Crops image to fit without distortionhover:scale-105: Image zooms slightly on hover
{
product.originalPrice > product.price && (
<div className="absolute top-3 right-3">
<Badge className="bg-red-500 hover:bg-red-600 text-white">Sale</Badge>
</div>
);
}- Conditionally renders only if there's a price discount
absolute top-3 right-3: Positioned in top-right corner- Red badge with white text to draw attention
<div className="flex items-center gap-2 mb-4">
<p className="text-3xl font-bold text-gray-900">
${product.price.toFixed(2)}
</p>
{product.originalPrice > product.price && (
<p className="text-lg text-gray-500 line-through">
${product.originalPrice.toFixed(2)}
</p>
)}
</div>- Current price in large bold text
- Original price shown as strikethrough only if discounted
toFixed(2): Ensures 2 decimal places for currency
The stock status is handled by a separate component StockBadge for better reusability:
<StockBadge inStock={product.inStock} />Inside components/stock-badge.tsx:
export function StockBadge({ inStock }: StockBadgeProps) {
return (
<Badge
variant={inStock ? "default" : "destructive"}
className={`text-sm font-semibold ${
inStock
? "bg-green-100 text-green-800 hover:bg-green-100"
: "bg-red-100 text-red-800 hover:bg-red-100"
}`}
>
{inStock ? "In Stock" : "Out of Stock"}
</Badge>
);
}- Dynamic styling based on
inStockstatus - Green for in-stock items
- Red for out-of-stock items
- Shared logic encapsulated in a standalone component
In this project, we use the next/image component to handle product photos.
Next.js requires images to have a defined width and height (or use the fill property) to prevent Cumulative Layout Shift (CLS).
- Space Reservation: When you provide dimensions, Next.js can calculate the aspect ratio of the image. The browser uses this to reserve a placeholder box on the page before the image actually downloads.
- Preventing Content Jumps: Without these dimensions, the browser wouldn't know how big the image is until it loads. Once it loads, it would "push" other content (like the product name and price) down suddenly, causing a poor user experience and lowering your SEO/Web Vitals score.
- Aspect Ratio: Even with responsive layouts (like using
fillon a container withrelativeand a fixed height), the underlying principle is the same—defining the space the image will occupy to ensure a stable layout.
This project uses Tailwind CSS for styling. The configuration is in tailwind.config.js and CSS variables are defined in app/globals.css.