About this course
<p>Card components are one of the most common UI patterns in web development. You see them in property listing apps, SaaS analytics dashboards, e-commerce product pages, and admin panels.</p>
<p>But building a card that handles hover states cleanly, supports dark mode, stays accessible, and works across screen sizes takes more than wrapping content in a <code><div></code>. You need a consistent component structure, a reliable design system, and well-thought-out Tailwind patterns.</p>
<p>In this tutorial, you'll build four types of production-ready card components using shadcn/ui and Base UI primitives via Shadcn Space. Each card targets a specific, real-world UI pattern that developers run into regularly.</p>
<p>By the end, you'll have:</p>
<ol>
<li><p>A Preview Card with a group hover image effect, an overlay arrow icon, and a property details layout</p>
</li>
<li><p>An Analytics Card with typed metric props, conditional badge colors, and a decorative background image</p>
</li>
<li><p>A Statistics Card with a responsive four-column e-commerce stats grid and icon badges</p>
</li>
<li><p>An Ecommerce Product Variant Card with size selection, a wishlist toggle, a bag button, and a ripple animation on the buy button</p>
</li>
</ol>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-shadcnui">Why shadcn/ui?</a></p>
</li>
<li><p><a href="#heading-what-is-shadcn-space">What is Shadcn Space?</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-cli-registry">How to Set Up the CLI Registry</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-preview-card-card-02">How to Build the Preview Card (card-02)</a></p>
</li>
<li><p><a href="#heading-live-preview">Live Preview</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-analytics-card-card-05">How to Build the Analytics Card (card-05)</a></p>
</li>
<li><p><a href="#heading-live-preview">Live Preview</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-statistics-card-card-06">How to Build the Statistics Card (card-06)</a></p>
</li>
<li><p><a href="#heading-live-preview">Live Preview</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-ecommerce-product-variant-card-card-17">How to Build the Ecommerce Product Variant Card (card-17)</a></p>
</li>
<li><p><a href="#heading-live-preview">Live Preview</a></p>
</li>
<li><p><a href="#heading-quick-reference-table">Quick Reference Table</a></p>
</li>
<li><p><a href="#heading-key-concepts-recap">Key Concepts Recap</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you start, make sure you have the following in place:</p>
<ul>
<li><p>Node.js 18 or higher installed</p>
</li>
<li><p>A Next.js or React project set up</p>
</li>
<li><p>shadcn/ui initialized in your project (<code>npx shadcn@latest init</code>)</p>
</li>
<li><p>Tailwind CSS configured</p>
</li>
<li><p>Basic knowledge of React and TypeScript</p>
</li>
</ul>
<p>If you haven't initialized shadcn/ui yet, run <code>npx shadcn@latest init</code> in your project root and follow the prompts before continuing.</p>
<h2 id="heading-why-shadcnui"><strong>Why shadcn/ui?</strong></h2>
<p><a href="https://ui.shadcn.com/"><strong>shadcn/ui</strong></a> is a collection of accessible, open-source React components built on top of Radix UI, Base UI, and styled with Tailwind CSS.</p>
<p>The way it works is different from a traditional component library. Instead of installing a package, you use a CLI to copy the component source files directly into your project. This means you own every line of the code. You can read it, edit it, and the component will never break because of a library update you didn't control.</p>
<p>Some key benefits:</p>
<ul>
<li><p><strong>Accessible by default</strong>: built on Radix UI and Base UI primitives</p>
</li>
<li><p><strong>Fully Tailwind-based</strong>: no external CSS files, no specificity conflicts</p>
</li>
<li><p><strong>Zero lock-in</strong>: components live in your <code>components/</code> folder, not inside <code>node_modules</code></p>
</li>
<li><p><strong>Works everywhere</strong>: Next.js, Vite, Astro, Remix, and other React frameworks</p>
</li>
</ul>
<p>The <code>Card</code>, <code>Badge</code>, <code>Button</code>, and <code>Separator</code> components you'll use in this tutorial all come from the shadcn/ui base install.</p>
<h2 id="heading-what-is-shadcn-space"><strong>What is Shadcn Space?</strong></h2>
<p><a href="https://shadcnspace.com/"><strong>Shadcn Space</strong></a> is an open-source registry of production-ready components and UI blocks built on top of shadcn/ui. It extends the default shadcn/ui component set with additional variants from common patterns to highly appealing layouts.</p>
<p>The key difference from the default shadcn/ui <code>Card</code> component is that Shadcn Space cards are designed for specific layout patterns. You get more structure out of the box.</p>
<p>Each component in Shadcn Space supports both Radix UI and Base UI primitives. You also get the functionality of <strong>Copy Prompt</strong>. This tutorial uses the Base UI versions. You install them the same way as any shadcn/ui component, through a single CLI command, and the source files land in your project.</p>
<p>You can browse the full card collection in the <a href="https://shadcnspace.com/components/card"><strong>Shadcn card component library</strong></a>.</p>
<h2 id="heading-what-youll-build"><strong>What You'll Build</strong></h2>
<p>Here's an overview of the four cards you'll build, along with their specific features:</p>
<p><strong>Preview Card (card-02)</strong></p>
<ul>
<li><p>Large image with hover brightness and scale animation</p>
</li>
<li><p>An arrow icon that appears only on hover</p>
</li>
<li><p>Property title and location</p>
</li>
<li><p>Price badge with a teal color scheme</p>
</li>
<li><p>Amenity row with bed, bath, and area icons</p>
</li>
</ul>
<p><strong>Analytics Card (card-05)</strong></p>
<ul>
<li><p>Typed TypeScript props with a built-in default dataset</p>
</li>
<li><p>Two metric columns separated by a vertical divider</p>
</li>
<li><p>Conditional badge colors based on positive or negative trend</p>
</li>
<li><p>Decorative background image pinned to the bottom-right corner</p>
</li>
</ul>
<p><strong>Statistics Card (card-06)</strong></p>
<ul>
<li><p>Four-column responsive grid that stacks on mobile</p>
</li>
<li><p>Iconify Solar icon set for each metric</p>
</li>
<li><p>Badge with trend direction icon</p>
</li>
<li><p>Border dividers are removed from the last column automatically</p>
</li>
</ul>
<p><strong>Ecommerce Product Variant Card (card-17)</strong></p>
<ul>
<li><p>Product image with 3D drop shadow and hover zoom</p>
</li>
<li><p>Wishlist heart toggle with dark mode support</p>
</li>
<li><p>Size selector with active state highlighting</p>
</li>
<li><p>Bag icon toggle that fills on click</p>
</li>
<li><p>"Buy Now" button with a CSS ripple animation</p>
</li>
<li><p>Dynamic delivery date with ordinal suffix formatting</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-the-cli-registry"><strong>How to Set Up the CLI Registry</strong></h2>
<p>Before you run any install commands, you need to register the Shadcn Space registry in your <code>components.json</code> file.</p>
<p>Open <code>components.json</code> in your project root and add the <code>registries</code> field:</p>
<pre><code class="language-javascript">{
"registries": {
"@shadcn-space": {
"url": "https://shadcnspace.com/r/{name}.json"
}
}
}
</code></pre>
<p>This tells the shadcn CLI where to find components prefixed with <code>@shadcn-space/</code>. Without this step, all the install commands in this tutorial will fail.</p>
<p>Your full <code>components.json</code> should look something like this after adding the registry:</p>
<pre><code class="language-javascript">{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
},
"registries": {
"@shadcn-space": {
"url": "https://shadcnspace.com/r/{name}.json"
}
}
}
</code></pre>
<p>For a full walkthrough of how the CLI works with third-party registries, visit the <a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli"><strong>getting started guide</strong></a>. You can also watch the video walkthrough if you prefer to follow along visually.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/n6dvjVxy02U" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<h2 id="heading-how-to-build-the-preview-card-card-02"><strong>How to Build the Preview Card (card-02)</strong></h2>
<h3 id="heading-what-the-preview-card-does">What the Preview Card Does</h3>
<p>The Preview Card is designed for property listings, hotel pages, or any content that benefits from a large image with supporting details below it.</p>
<p>When a user hovers the card, the image darkens and scales up. An arrow icon appears in the corner. Below the image, a title, location, price badge, and amenity row are displayed.</p>
<h3 id="heading-how-to-install-the-preview-card">How to Install the Preview Card</h3>
<p>Run one of the following commands based on your package manager:</p>
<p><strong>npm:</strong></p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/card-02
</code></pre>
<p><strong>pnpm:</strong></p>
<pre><code class="language-javascript">pnpm dlx shadcn@latest add @shadcn-space/card-02
</code></pre>
<p><strong>Yarn:</strong></p>
<pre><code class="language-javascript">yarn dlx shadcn@latest add @shadcn-space/card-02
</code></pre>
<p><strong>Bun:</strong></p>
<pre><code class="language-javascript">bunx --bun shadcn@latest add @shadcn-space/card-02
</code></pre>
<p>The CLI copies the component into your project at:</p>
<pre><code class="language-javascript">components/
shadcn-space/
card/
Card-02.tsx
</code></pre>
<h3 id="heading-the-component-code">The Component Code</h3>
<pre><code class="language-javascript">import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { ArrowRight, Bath, BedDouble, Expand } from "lucide-react";
const PreviewCard = () => (
<Card className="relative gap-0 py-0 rounded-2xl group hover:shadow-3xl duration-300">
<div className="overflow-hidden rounded-t-2xl">
<a href="#">
<div className="w-full h-72">
<img
src="https://images.shadcnspace.com/assets/card/property-cover-1.jpg"
alt="Serenity Residential Home"
width={440}
height={300}
className="w-full h-full object-cover rounded-t-2xl group-hover:brightness-50 group-hover:scale-125 transition duration-300 delay-75"
/>
</div>
</a>
<div className="absolute top-6 right-6 hidden p-4 bg-white rounded-full group-hover:block">
<ArrowRight className="text-card-foreground" />
</div>
</div>
<div className="p-6">
<div className="flex justify-between gap-5 mb-6">
<div>
<a href="#">
<h3 className="text-xl font-medium duration-300 group-hover:text-primary">
Serenity Residential Home
</h3>
</a>
<p className="text-base font-normal text-muted-foreground">
15 S Aurora Ave, Miami
</p>
</div>
<Badge className="px-5 py-4 text-base font-normal rounded-full bg-teal-500/10 text-teal-500">
$570,000
</Badge>
</div>
<div className="flex">
<div className="flex flex-col gap-2 xs:pr-4 pr-8 border-e border-border">
<BedDouble size={20} />
<p className="text-sm sm:text-base">5 Bedrooms</p>
</div>
<div className="flex flex-col gap-2 xs:px-4 px-8 border-e border-border">
<Bath size={20} />
<p className="text-sm sm:text-base">3 Bathrooms</p>
</div>
<div className="flex flex-col gap-2 xs:pl-4 pl-8">
<Expand size={20} />
<p className="text-sm sm:text-base">
120m<sup>2</sup>
</p>
</div>
</div>
</div>
</Card>
);
export default PreviewCard;
</code></pre>
<p>Let's now go through how this code works.</p>
<h4 id="heading-1-group-hover-behavior">1. Group hover behavior</h4>
<p>The <code>group</code> class on the outer <code>Card</code> element is the core of this component. Any child element with a <code>group-hover:</code> class will respond when the card is hovered, not just that individual element.</p>
<p>This is how the image darkens (<code>group-hover:brightness-50</code>), scales up (<code>group-hover:scale-125</code>), and the arrow icon appears (<code>group-hover:block</code>).</p>
<h4 id="heading-2-overflow-clipping-on-image-zoom">2. Overflow clipping on image zoom</h4>
<p>Without <code>overflow-hidden</code> on the image wrapper, the <code>scale-125</code> transform would bleed past the card's rounded corners on hover. The wrapper clips the image so it stays inside the card boundary.</p>
<p>Notice that <code>rounded-t-2xl</code> appears on both the wrapper and the image itself to maintain a consistent corner radius during the transition.</p>
<h4 id="heading-3-logical-border-properties-in-the-amenity-row">3. Logical border properties in the amenity row</h4>
<p>The amenity row uses <code>border-e</code> instead of <code>border-r</code>. This is a CSS logical property meaning "border at the inline end." In left-to-right layouts, that's the right side. In right-to-left layouts, it flips automatically. Using logical properties is a good production habit for any component that may need to support multiple locales.</p>
<h3 id="heading-live-preview">Live Preview:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/21233f5e-2d39-4430-8013-a780bd24419c.gif" alt="21233f5e-2d39-4430-8013-a780bd24419c" style="display:block;margin:0 auto" width="960" height="720" loading="lazy">
<hr>
<h2 id="heading-how-to-build-the-analytics-card-card-05"><strong>How to Build the Analytics Card (card-05)</strong></h2>
<h3 id="heading-what-the-analytics-card-does">What the Analytics Card Does</h3>
<p>The Analytics Card is a compact dashboard widget. It shows two metrics side by side with values and percentage-change badges. A decorative chart image sits in the bottom-right corner.</p>
<p>The component is typed with TypeScript interfaces, making it easy to swap in real data from an API.</p>
<h3 id="heading-how-to-install-the-analytics-card">How to Install the Analytics Card</h3>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/card-05
</code></pre>
<p>The CLI copies the component into:</p>
<pre><code class="language-javascript">components/
shadcn-space/
card/
card-05.tsx
</code></pre>
<h3 id="heading-the-component-code">The Component Code</h3>
<pre><code class="language-javascript">import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
type DashboardMetric = {
label: string;
value: string;
percentage: string;
isPositive?: boolean;
};
type MainDashboardData = {
title: string;
description: string;
metrics: DashboardMetric[];
};
type WidgetProps = {
mainDashboard?: MainDashboardData;
};
const mainDashboardData: MainDashboardData = {
title: "Analytics Dashboard",
description: "Check all the statistics",
metrics: [
{
label: "Earnings",
value: "$27,850",
percentage: "+18%",
isPositive: true,
},
{
label: "Expense",
value: "$18,453",
percentage: "-5%",
isPositive: false,
},
],
};
const AnalyticsCard = ({ mainDashboard = mainDashboardData }: WidgetProps) => {
return (
<div className="flex items-center justify-center w-full">
<div className="max-w-7xl mx-auto px-4 lg:px-8 xl:px-16 py-10 w-full">
<Card className="p-0 ring-0 border rounded-2xl relative h-full max-w-xl w-full mx-auto">
<CardContent className="p-0">
<div className="ps-6 py-4 flex flex-col gap-9 justify-between">
<div>
<p className="text-lg font-medium text-card-foreground">
{mainDashboard.title}
</p>
<p className="text-xs font-normal text-muted-foreground">
{mainDashboard.description}
</p>
</div>
<div className="flex items-center gap-6">
{mainDashboard.metrics.map((metric, index) => (
<div key={index} className="flex items-center gap-6">
<div>
<p className="text-xs font-normal text-muted-foreground">
{metric.label}
</p>
<div className="flex items-center gap-1">
<p className="text-2xl font-medium text-card-foreground">
{metric.value}
</p>
<Badge
className={cn(
"font-normal text-muted-foreground",
metric.isPositive
? "bg-teal-400/10"
: "bg-red-500/10"
)}
>
{metric.percentage}
</Badge>
</div>
</div>
{index < mainDashboard.metrics.length - 1 && (
<Separator orientation="vertical" className="h-12" />
)}
</div>
))}
</div>
</div>
<img
src="https://images.shadcnspace.com/assets/backgrounds/stats-01.webp"
alt="stats chart"
width={211}
height={168}
className="absolute bottom-0 right-0 hidden sm:block"
/>
</CardContent>
</Card>
</div>
</div>
);
};
export default AnalyticsCard;
</code></pre>
<p>How the Analytics Card works:</p>
<h4 id="heading-1-optional-props-with-a-default-dataset">1. Optional props with a default dataset</h4>
<p>The component accepts an optional <code>mainDashboard</code> prop. If you don't pass anything, it falls back to <code>mainDashboardData</code>, the constant is defined in the same file:</p>
<pre><code class="language-javascript">const AnalyticsCard = ({ mainDashboard = mainDashboardData }: WidgetProps) => {
</code></pre>
<p>This pattern lets the component work out of the box in demos or Storybook, while still being fully driven by real API data in production. To connect it to live data, you just pass a prop that matches the <code>MainDashboardData</code> shape.</p>
<h4 id="heading-2-conditional-badge-colors-with-cn">2. Conditional badge colors with <code>cn()</code></h4>
<p>The <code>cn()</code> utility (from <code>@/lib/utils</code>) merges Tailwind class names and handles conditional logic cleanly. It also de-duplicates conflicting Tailwind classes automatically, which plain template literals don't do:</p>
<pre><code class="language-javascript">className={cn(
"font-normal text-muted-foreground",
metric.isPositive ? "bg-teal-400/10" : "bg-red-500/10"
)}
</code></pre>
<h4 id="heading-3-separators-only-between-metrics-not-after-the-last-one">3. Separators only between metrics, not after the last one</h4>
<p>The <code>Separator</code> component renders only between metrics, never after the last one. The index check handles this:</p>
<pre><code class="language-javascript">{index < mainDashboard.metrics.length - 1 && (
<Separator orientation="vertical" className="h-12" />
)}
</code></pre>
<h4 id="heading-4-absolute-positioned-decorative-image">4. Absolute-positioned decorative image</h4>
<p>The chart image is used <code>absolute bottom-0 right-0</code> to pin it to the card's bottom-right corner. It hides on small screens with <code>hidden sm:block</code> to avoid layout issues on mobile. The parent <code>Card</code> has <code>relative</code> positioning to contain it.</p>
<h3 id="heading-live-preview">Live Preview:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/de09194f-ff75-49c1-95e5-21d758e499e8.png" alt="de09194f-ff75-49c1-95e5-21d758e499e8" style="display:block;margin:0 auto" width="1266" height="356" loading="lazy">
<hr>
<h2 id="heading-how-to-build-the-statistics-card-card-06"><strong>How to Build the Statistics Card (card-06)</strong></h2>
<h3 id="heading-what-the-statistics-card-does">What the Statistics Card Does</h3>
<p>The Statistics Card displays four e-commerce metrics in a horizontal grid: Orders, Sales, Profit, and Expense. Each column has an icon, a large value, a time period label, and a badge showing the percentage trend.</p>
<p>The layout is fully responsive, collapsing from four columns to two on medium screens and stacking on mobile.</p>
<p>This card uses <code>@iconify/react</code> for icons instead of <code>lucide-react</code>, giving you access to thousands of icon sets using string-based icon names.</p>
<h3 id="heading-how-to-install-the-statistics-card">How to Install the Statistics Card</h3>
<p>First, install <code>@iconify/react</code> if you don't have it:</p>
<pre><code class="language-javascript">npm install @iconify/react
</code></pre>
<p>Then add the card component:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/card-06
</code></pre>
<p>The CLI copies the component into:</p>
<pre><code class="language-javascript">components/
shadcn-space/
card/
card-06.tsx
</code></pre>
<h3 id="heading-the-component-code">The Component Code</h3>
<pre><code class="language-javascript">"use client";
import { Icon } from "@iconify/react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const StatisticsCard = () => {
const EcommerceActions = [
{
title: "Orders",
subtitle: "5868",
cardIcon: "solar:bag-4-line-duotone",
badgeColor: "bg-teal-400/10",
statusValue: "+18%",
statusIcon: "solar:course-up-line-duotone",
},
{
title: "Sales",
subtitle: "$96,850",
cardIcon: "solar:box-line-duotone",
badgeColor: "bg-orange-400/10",
statusValue: "-5%",
statusIcon: "solar:course-down-line-duotone",
},
{
title: "Profit",
subtitle: "$82,906",
cardIcon: "solar:chart-square-line-duotone",
badgeColor: "bg-teal-400/10",
statusValue: "+18%",
statusIcon: "solar:course-up-line-duotone",
},
{
title: "Expense",
subtitle: "$14,653",
cardIcon: "solar:star-line-duotone",
badgeColor: "bg-teal-400/10",
statusValue: "+18%",
statusIcon: "solar:course-up-line-duotone",
},
];
return (
<div className="max-w-7xl mx-auto px-4 w-full">
<Card className="p-0">
<CardContent className="flex items-center w-full lg:flex-nowrap flex-wrap px-0">
{EcommerceActions.map((item, index) => (
<div
className="lg:w-3/12 md:w-6/12 w-full border-e border-border last:border-e-0"
key={index}
>
<div className="p-6">
<div className="flex flex-col gap-1">
<div className="flex justify-between items-start">
<h5 className="text-base font-medium">{item.title}</h5>
<div className="p-3 rounded-full outline outline-border text-primary">
<Icon icon={item.cardIcon} width={16} height={16} />
</div>
</div>
<div className="flex flex-col gap-1">
<h5 className="text-2xl font-semibold">{item.subtitle}</h5>
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground">Last 7 days</p>
<Badge className={`${item.badgeColor} text-muted-foreground`}>
<div className="flex items-center gap-1">
{item.statusValue}
<Icon icon={item.statusIcon} width={14} height={14} />
</div>
</Badge>
</div>
</div>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
};
export default StatisticsCard;
</code></pre>
<p>How the Statistics Card works:</p>
<h4 id="heading-1-data-driven-layout-with-an-array">1. Data-driven layout with an array</h4>
<p>All four metrics live in the <code>EcommerceActions</code> array. Adding or removing a metric only requires updating the array. The JSX stays the same. This is the right approach for any component with a repeating structure: keep data and markup separate.</p>
<h4 id="heading-2-responsive-column-widths">2. Responsive column widths</h4>
<p>Each column uses three width classes to handle every breakpoint:</p>
<ul>
<li><p><code>w-full</code> on mobile (single column, stacked vertically)</p>
</li>
<li><p><code>md:w-6/12</code> on medium screens (two columns)</p>
</li>
<li><p><code>lg:w-3/12</code> on large screens (four equal columns)</p>
</li>
</ul>
<p>The <code>flex-wrap</code> on the <code>CardContent</code> lets columns wrap naturally on smaller screens. <code>lg:flex-nowrap</code> forces them into a single row on large screens.</p>
<h4 id="heading-3-removing-the-last-border-with-last">3. Removing the last border with <code>last:</code></h4>
<p>The <code>last:border-e-0</code> class removes the right border from the final column. Without it, there'd be a stray border on the right edge of the card.</p>
<p>The <code>last:</code> variant is a Tailwind pseudo-class that targets the last child element in a group, which is cleaner than tracking the index manually.</p>
<h4 id="heading-4-why-use-client-is-needed-here">4. Why <code>"use client"</code> is needed here</h4>
<p>The <code>@iconify/react</code> package requires a browser environment. In Next.js with the App Router, any component that imports a client-only package needs the <code>"use client"</code> directive at the top of the file. Without it, the server will throw an error during rendering.</p>
<h3 id="heading-live-preview">Live Preview:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/dd7600b4-753c-4e67-abff-2d8955a22653.png" alt="dd7600b4-753c-4e67-abff-2d8955a22653" style="display:block;margin:0 auto" width="1272" height="257" loading="lazy">
<hr>
<h2 id="heading-how-to-build-the-ecommerce-product-variant-card-card-17"><strong>How to Build the Ecommerce Product Variant Card (card-17)</strong></h2>
<h3 id="heading-what-the-ecommerce-product-variant-card-does">What the Ecommerce Product Variant Card Does</h3>
<p>This is the most interactive card in this tutorial. It's a product card for a shoe listing with a hover zoom, a wishlist toggle, size buttons, a bag toggle, and a ripple-animation buy button. All interactions are handled with React's <code>useState</code>, so no external state management library is required.</p>
<h3 id="heading-how-to-install-the-product-variant-card">How to Install the Product Variant Card</h3>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/card-17
</code></pre>
<p>The CLI copies the component into:</p>
<pre><code class="language-javascript">components/
shadcn-space/
card/
card-17.tsx
</code></pre>
<h3 id="heading-the-component-code">The Component Code</h3>
<pre><code class="language-javascript">"use client";
import { useState } from "react";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, ShoppingBag } from "lucide-react";
import { cn } from "@/lib/utils";
const sizes = ["7", "8", "9", "10"];
const getDeliveryDate = () => {
const date = new Date();
date.setDate(date.getDate() + 3);
const day = date.getDate();
const month = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
][date.getMonth()];
const suffix = ["th", "st", "nd", "rd"][
day % 10 > 3 ? 0 : (day % 100 - day % 10 !== 10 ? day % 10 : 0)
];
return `${day}${suffix} ${month}`;
};
export default function EcommerceProductCard() {
const [activeSize, setActiveSize] = useState(1);
const [isWishlisted, setIsWishlisted] = useState(false);
const [inBag, setInBag] = useState(false);
return (
<div className="flex items-center justify-center p-8 w-full bg-background">
<Card className="w-80 rounded-2xl overflow-hidden p-0 gap-0 group/card">
{/* Image zone */}
<div className="relative overflow-hidden h-80">
<img
src="https://images.shadcnspace.com/assets/card/running-shoe-3d.png"
className="object-contain drop-shadow-2xl px-8 py-6 transition-transform duration-500 ease-out group-hover/card:scale-105"
alt="Nike Air Max Pulse"
/>
{/* Discount badge */}
<span className="absolute top-3 left-3 text-xs tracking-widest font-bold uppercase bg-foreground text-background px-2.5 py-1 rounded-sm select-none">
-21%
</span>
{/* Wishlist button */}
<button
onClick={() => setIsWishlisted(!isWishlisted)}
title="Wishlist"
className={cn(
"absolute top-3 right-3 h-8 w-8 rounded-full border shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-110 active:scale-95",
isWishlisted
? "bg-rose-50 border-rose-200 dark:bg-rose-950 dark:border-rose-800"
: "bg-background"
)}
>
<Heart
className={cn(
"w-3.5 h-3.5 transition-colors",
isWishlisted ? "fill-rose-500 text-rose-500" : "text-muted-foreground"
)}
/>
</button>
</div>
{/* Info zone */}
<CardContent className="px-4 pt-4 pb-4 space-y-1.5">
<div className="min-w-0">
<h3 className="text-base font-bold text-foreground truncate">Nike</h3>
<p className="text-sm text-muted-foreground truncate">
Air Max Pulse Running Shoes
</p>
</div>
<div className="flex items-center gap-2 pt-1">
<span className="text-green-600 dark:text-green-500 font-semibold text-sm">
Down 21%
</span>
<span className="text-muted-foreground line-through text-sm">$150</span>
<span className="text-foreground font-bold text-base">$119</span>
</div>
<div className="text-xs text-muted-foreground font-medium">
Delivery by{" "}
<span suppressHydrationWarning className="text-foreground font-bold">
{getDeliveryDate()}
</span>
</div>
{/* Size selector */}
<div className="flex gap-1.5 pt-2">
{sizes.map((s, i) => (
<button
key={s}
onClick={() => setActiveSize(i)}
className={cn(
"flex-1 h-7 rounded-lg text-sm font-medium border transition-all duration-150",
activeSize === i
? "bg-foreground text-background border-foreground"
: "text-muted-foreground hover:border-foreground/50 hover:text-foreground"
)}
>
US {s}
</button>
))}
</div>
</CardContent>
{/* Action zone */}
<CardFooter className="px-4 pb-6 gap-2 bg-transparent border-t-0">
<button
onClick={() => setInBag(!inBag)}
title={inBag ? "Remove from bag" : "Add to bag"}
className={cn(
"h-12 w-12 shrink-0 rounded-xl border flex items-center justify-center transition-all duration-200 hover:scale-105 active:scale-95",
inBag
? "bg-foreground text-background border-foreground"
: "bg-background text-muted-foreground hover:border-foreground/50 hover:text-foreground"
)}
>
<ShoppingBag className="w-5 h-5" />
</button>
<Button className="relative overflow-hidden group/btn flex-1 h-12 rounded-xl font-semibold text-base cursor-pointer border border-primary transition-all flex items-center justify-center gap-2">
<span className="absolute left-1/2 -translate-x-1/2 top-full -translate-y-1/2 w-8 h-8 bg-white dark:bg-gray-950 rounded-full scale-0 transition-transform duration-700 ease-in-out group-hover/btn:scale-[20]" />
<span className="relative z-10 transition-colors duration-500 group-hover/btn:text-gray-950 dark:group-hover/btn:text-white">
Buy Now
</span>
</Button>
</CardFooter>
</Card>
</div>
);
}
</code></pre>
<p>How the Ecommerce Product Variant Card works:</p>
<h4 id="heading-1-named-group-hover-scopes">1. Named group hover scopes</h4>
<p>This card uses two independent hover group scopes: <code>group/card</code> on the outer card and <code>group/btn</code> on the Buy Now button. Tailwind's named group feature uses the <code>/name</code> suffix to keep them separate:</p>
<pre><code class="language-javascript">// Card-level hover: zooms the product image
<Card className="... group/card">
<img className="... group-hover/card:scale-105" />
// Button-level hover: triggers the ripple animation
<Button className="... group/btn">
<span className="... group-hover/btn:scale-[20]" />
</code></pre>
<p>Without named groups, hovering the button would also trigger the card's hover styles. The <code>/card</code> and <code>/btn</code> suffixes prevent this.</p>
<h4 id="heading-2-css-ripple-animation-on-the-buy-now-button">2. CSS ripple animation on the Buy Now button</h4>
<p>The ripple effect uses a pure CSS scale animation. A white circle (<code>w-8 h-8 rounded-full</code>) starts at <code>scale-0</code> and transitions to <code>scale-[20]</code> when the button is hovered. The <code>overflow-hidden</code> on the <code>Button</code> clips it to the button's boundary. The <code>z-10</code> on the label keeps the text visible above the expanding circle.</p>
<h4 id="heading-3-ordinal-suffix-logic-for-the-delivery-date">3. Ordinal suffix logic for the delivery date</h4>
<p>The <code>getDeliveryDate()</code> function calculates a date three days from now and attaches the correct ordinal suffix (st, nd, rd, th):</p>
<pre><code class="language-javascript">const suffix = ["th", "st", "nd", "rd"][
day % 10 > 3 ? 0 : (day % 100 - day % 10 !== 10 ? day % 10 : 0)
];
</code></pre>
<p>The logic handles the edge cases for 11th, 12th, and 13th, which always use "th" regardless of their last digit. This is a common gotcha in ordinal formatting.</p>
<h4 id="heading-4-suppresshydrationwarning-on-the-delivery-date-span">4. <code>suppressHydrationWarning</code> on the delivery date span</h4>
<p>The delivery date is calculated at render time using <code>new Date()</code>. The server calculates it at request time, and the client recalculates it at hydration time.</p>
<p>If there's a timezone difference, React throws a hydration mismatch warning. <code>suppressHydrationWarning</code> silences this warning for that specific node without affecting the rest of the tree.</p>
<h3 id="heading-live-preview">Live Preview:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/f81584b2-a2ba-4233-a628-5daca018a1d9.gif" alt="f81584b2-a2ba-4233-a628-5daca018a1d9" style="display:block;margin:0 auto" width="960" height="720" loading="lazy">
<h2 id="heading-quick-reference-table"><strong>Quick Reference Table</strong></h2>
<table>
<thead>
<tr>
<th>Card</th>
<th>Identifier</th>
<th>Use Case</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Preview Card</strong></td>
<td><code>card-02</code></td>
<td>Property listings, hotel cards, product previews</td>
</tr>
<tr>
<td><strong>Analytics Card</strong></td>
<td><code>card-05</code></td>
<td>Dashboard widgets with metric data</td>
</tr>
<tr>
<td><strong>Statistics Card</strong></td>
<td><code>card-06</code></td>
<td>E-commerce stats grids</td>
</tr>
<tr>
<td><strong>Product Variant Card</strong></td>
<td><code>card-17</code></td>
<td>Product pages with size selection and cart</td>
</tr>
</tbody></table>
<p>To install any of these, replace the identifier in the CLI command:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/<identifier>
</code></pre>
<h2 id="heading-key-concepts-recap"><strong>Key Concepts Recap</strong></h2>
<p>Here's a summary of the key Tailwind, React, and TypeScript patterns used across the four cards in this tutorial.</p>
<h3 id="heading-tailwind-group-hover">Tailwind Group Hover</h3>
<p>The <code>group</code> class on a parent element lets any child respond to the parent's hover state using <code>group-hover:</code> classes.</p>
<p>For nested hover scopes, use named groups like <code>group/card</code> and <code>group/btn</code> with <code>group-hover/card:</code> and <code>group-hover/btn:</code>. This prevents hover styles from bleeding across component boundaries.</p>
<h3 id="heading-the-cn-utility">The <code>cn()</code> Utility</h3>
<p><code>cn()</code> from <code>@/lib/utils</code> merges Tailwind class strings, handles conditional class logic, and de-duplicates conflicting Tailwind utilities. Use it instead of template literals whenever you have conditional classes.</p>
<h3 id="heading-last-tailwind-variant"><code>last:</code> Tailwind Variant</h3>
<p>The <code>last:</code> pseudo-class variant targets the last child element in a group. In the Statistics Card, <code>last:border-e-0</code> remove the trailing border from the final column without any index tracking in JavaScript.</p>
<h3 id="heading-css-logical-properties">CSS Logical Properties</h3>
<p><code>border-e</code> means "border at the inline end," which is the right side in LTR layouts and the left side in RTL layouts. Using logical properties like <code>border-e</code>, <code>ps-</code>, and <code>pe-</code> instead of <code>border-r</code>, <code>pl-</code>, and <code>pr-</code> makes your components locale-aware by default.</p>
<h3 id="heading-typescript-optional-props-with-defaults">TypeScript Optional Props with Defaults</h3>
<p>Assigning a default value directly in the function signature, like <code>({ mainDashboard = mainDashboardData }: WidgetProps)</code>, is a clean pattern for components that need sensible fallback data while still being configurable. It works for demos, Storybook, and production use with real API data.</p>
<h3 id="heading-use-client-in-nextjs-app-router"><code>"use client"</code> in Next.js App Router</h3>
<p>Any component that uses <code>useState</code>, browser APIs, or client-only packages like <code>@iconify/react</code> needs the <code>"use client"</code> directive at the top of the file. Without it, the Next.js App Router will try to render the component on the server and throw an error.</p>
<h3 id="heading-suppresshydrationwarning"><code>suppressHydrationWarning</code></h3>
<p>When a value rendered on the server (for example, the current date or time) differs from the value rendered on the client due to timezone differences, React throws a hydration mismatch warning. Adding <code>suppressHydrationWarning</code> to the specific element silences the warning without affecting the rest of the component tree.</p>
<h3 id="heading-css-ripple-animation-pattern">CSS Ripple Animation Pattern</h3>
<p>A CSS ripple effect can be built without JavaScript by using a <code>scale-0</code> to <code>scale-[N]</code> transition on a <code>rounded-full</code> element placed inside an <code>overflow-hidden</code> container. On hover, the circle expands and gets clipped by the container boundary. The label text sits above it with <code>relative z-10</code>.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>In this tutorial, you built four production-ready card components using shadcn/ui and Base UI primitives:</p>
<ol>
<li><p><strong>Preview Card</strong>: group hover image animation, overflow clipping, and a logical border amenity row</p>
</li>
<li><p><strong>Analytics Card</strong>: typed props with default data, conditional badge colors with <code>cn()</code>, and an absolutely-positioned decorative image</p>
</li>
<li><p><strong>Statistics Card</strong>: data-driven repeating layout, responsive flex columns, and automatic last-border removal with <code>last:</code></p>
</li>
<li><p><strong>Ecommerce Product Variant Card</strong>: named hover groups, a CSS ripple button, ordinal date formatting, and hydration warning suppression</p>
</li>
</ol>
<p>Each card is installed with one CLI command and lives in your project's source tree. You own the code and can modify anything to fit your design system.</p>
<p>The patterns covered here, from named group hover scopes to typed props with defaults to logical CSS properties, apply well beyond card components. You'll find them useful across most UI components you build with shadcn/ui and Tailwind CSS.</p>
<h2 id="heading-resources"><strong>Resources</strong></h2>
<ul>
<li><p><a href="https://shadcnspace.com/"><strong>Shadcn UI</strong></a>: Component library and documentation home</p>
</li>
<li><p><a href="https://shadcnspace.com/components/card"><strong>Shadcn Card Component Collection</strong></a>: All card variants available in Radix and Base UI</p>
</li>
<li><p><a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli"><strong>How to Use the Shadcn CLI</strong></a>: Getting started guide for the CLI and registry setup</p>
</li>
<li><p><a href="https://shadcnspace.com/cli"><strong>Shadcn CLI Reference</strong></a>: Full CLI command reference</p>
</li>
<li><p><a href="https://shadcnspace.com/docs/getting-started/component"><strong>Component Getting Started Guide</strong></a>: How to install and use individual components</p>
</li>
<li><p><a href="https://shadcnspace.com/components"><strong>Shadcn Components</strong></a>: Full component library with all available categories</p>
</li>
<li><p><a href="https://shadcnspace.com/blocks/dashboard-ui"><strong>Shadcn Dashboard UI Blocks</strong></a>: Ready-to-use dashboard layout blocks built from the same system</p>
</li>
<li><p><a href="https://ui.shadcn.com/docs"><strong>Official Shadcn/ui Documentation</strong></a></p>
</li>
<li><p><a href="https://shadcnspace.com/figma"><strong>Shadcn Figma Kit</strong></a>: Figma UI kit that mirrors the Shadcn Space component system</p>
</li>
<li><p><a href="https://youtu.be/n6dvjVxy02U?si=EXfClzSyI8D97VaI"><strong>Video Walkthrough: How to Use Shadcn Space with CLI</strong></a>: YouTube tutorial for CLI setup and component installation</p>
</li>
<li><p><a href="https://www.npmjs.com/package/@iconify/react"><strong>@iconify/react on npm</strong></a>: Icon library used in the Statistics Card</p>
</li>
</ul>