案例:電商 — 商品列表與購物車實作 | TailwindCSS 完整教學
走完前面幾堂實戰,這一篇我們挑戰商業價值最高、也最貼近真實產品的一種介面——電商。我們會用 TailwindCSS v4 逐段打造一整套電商前台:帶篩選側欄的商品列表 grid、圖片價格評分俱全的等高商品卡、資訊完整的商品詳情頁,以及從右側滑入的購物車側欄與結帳摘要。全程響應式並支援暗色模式。做完你會得到一套能直接接上真實商品資料的電商 UI 骨架。
前言
電商介面是前端裡「規則最多」的一種頁面。它不像 Landing Page 只求氣勢、也不像部落格只求好讀——電商每一個像素都跟轉換率掛鉤:商品圖要吸睛且比例一致、價格與折扣要一眼看懂、評分要建立信任、「加入購物車」的 CTA 要顯眼好按,而購物車與結帳的每一步都不能讓使用者猶豫。這是把視覺、資訊層級、互動三者綁最緊的一種版面。
這是本系列實戰案例的收尾之作。上一篇《案例:儀表板》我們練的是「資訊密度」與「固定外殼 + 可捲動內容」的應用程式骨架,主角是 flex 與 grid 的綜合排版;這一篇的挑戰不同——電商的核心是**「大量重複單元的整齊呈現」加上「抽屜式的購物流程」**。商品卡會出現幾十上百次,任何一點不對齊都會被放大成廉價感;而購物車側欄則把我們在儀表板學到的「固定外殼」思路,搬到一個從右側滑入的抽屜上。
為什麼電商值得壓軸?因為它把整個系列學過的東西一次全用上,還逼你面對兩個電商特有的難題。第一是商品卡等高:一整片 grid 裡,如何讓每張卡不管標題幾行、有沒有折扣標籤,底部的「加入購物車」按鈕都對齊在同一條線上。第二是購物車抽屜:如何用 fixed + translate-x 做一個從右側滑入、內部清單可捲動、結帳摘要固定在底的側欄。這兩題是電商前端天天在解的日常。
成品是一套電商前台。商品列表頁左側是可收合的篩選側欄(分類、價格、顏色),右側是響應式的商品卡 grid,每張卡有圖片、標籤、標題、評分、價格與加入購物車。商品詳情頁左圖右資訊,含圖片、麵包屑、評分、價格、尺寸選擇與雙 CTA。購物車則是從右側滑入的側欄,含商品清單與含小計、運費、總計的結帳摘要。整套完全響應式並支援暗色模式。
本系列以 TailwindCSS v4 為預設版本,範例皆以 v4 語法示範。凡涉及 v3 差異之處,我會特別標註。
本篇你將學到:
- 等高商品卡:用
aspect-*固定圖片比例、flex flex-col h-full搭mt-auto讓 CTA 對齊,做出整齊的商品牆 - 帶篩選的商品 grid:桌機常駐篩選側欄、手機彈出面板,搭
grid-cols-*響應式換欄 - 逐段實作:商品列表 + 篩選 → 商品卡 → 商品詳情 → 購物車側欄 / 結帳摘要,每段一個可執行區塊
- 電商的坑:商品卡高低不齊、圖片變形、CTA 不明顯等常見錯誤與正確做法
版面拆解
動手前,先把電商前台拆成四塊。它們共用一套 Tailwind 工具,但各有各的排版重點。
商品列表頁
┌────────────┬──────────────────────────────────────┐
│ 篩選 │ 排序 ▾ 共 128 件商品 [篩選] │
│ ───── ├──────────────────────────────────────┤
│ 分類 │ ┌──────┐┌──────┐┌──────┐┌──────┐ │
│ □ 上衣 │ │ 圖片 ││ 圖片 ││ 圖片 ││ 圖片 │ │
│ □ 褲子 │ │-20% ││ ││ NEW ││ │ │ ← 商品卡 grid
│ 價格 │ │ 標題 ││ 標題 ││ 標題 ││ 標題 │ │ (等高)
│ ○ $0-500 │ │★4.5 ││★4.2 ││★4.8 ││★4.0 │ │
│ ○ $500+ │ │ $890 ││ $1290││ $690 ││ $450 │ │
│ 顏色 │ │[加入]││[加入]││[加入]││[加入]│ │
│ ● ● ● ● │ └──────┘└──────┘└──────┘└──────┘ │
└────────────┴──────────────────────────────────────┘
商品詳情頁 購物車側欄 (右滑入)
┌────────────┬──────────────┐ ┌──────────────────┐
│ │ 首頁>上衣>T恤│ │ 購物車 (3) ✕ │ ← shrink-0
│ 主圖 │ 商品名稱 │ ├──────────────────┤
│ aspect- │ ★★★★☆ 128則 │ │ [圖]商品A $890 │
│ square │ $890 ~~1290~~│ │ [圖]商品B $1290 │ ← flex-1
│ │ 尺寸 SML XL │ │ [圖]商品C $690 │ overflow
│ ▢ ▢ ▢ ▢ │ [加入購物車] │ ├──────────────────┤
│ 縮圖列 │ [♡ 收藏] │ │ 小計 $2,870 │
│ │ ✓免運 ✓退換 │ │ 運費 免費 │ ← shrink-0
└────────────┴──────────────┘ │ 總計 $2,870 │ 結帳摘要
│ [前往結帳] │
└──────────────────┘
四塊的佈局邏輯先講清楚:
- 商品列表外層:用
lg:grid lg:grid-cols-[240px_1fr]讓篩選側欄與商品區並排,桌機側欄固定 240px、商品區吃剩餘寬度;手機側欄hidden,改用按鈕彈出面板。 - 商品卡 grid:
grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4,手機兩欄、平板三欄、桌機四欄,gap-4撐開間距。 - 商品卡本身:圖片區用
aspect-[3/4]固定比例,卡片用flex flex-col h-full,價格與按鈕之間靠mt-auto把 CTA 推到底——這是等高的關鍵。 - 商品詳情:
grid grid-cols-1 lg:grid-cols-2 gap-8,手機上下疊、桌機左圖右資訊。 - 購物車側欄:
fixed inset-y-0 right-0抽屜,內部flex flex-col h-full切成標題 / 清單(flex-1 overflow-y-auto)/ 摘要三段。
心裡有了這張地圖——列表用 grid 排卡、卡片靠 flex-col 等高、詳情左圖右資訊、購物車是右滑抽屜——接下來逐段填肉。
逐段實作
以下每段都是可獨立執行的 HTML 片段,搭配一個掛好 Tailwind v4 的頁面即可。我們由外而內、由列表到結帳:先搭商品列表與篩選,再放大單張商品卡,接著做商品詳情頁,最後是購物車側欄與結帳摘要。
商品列表:篩選側欄 + 商品 grid 外殼
先搭列表頁的骨架。外層用 grid 讓篩選側欄與商品區並排:桌機側欄常駐佔一欄,手機用 hidden lg:block 收起、改在商品區頂部放一顆「篩選」按鈕。商品區上方是排序與數量列,下方就是商品卡 grid。
<!-- 商品列表頁外殼:桌機側欄 240px + 商品區,手機側欄收起 -->
<div class="mx-auto max-w-7xl px-4 py-8 lg:px-6">
<div class="lg:grid lg:grid-cols-[240px_1fr] lg:gap-8">
<!-- 篩選側欄:桌機常駐,手機隱藏(改用彈出面板) -->
<aside class="hidden lg:block">
<div class="sticky top-6 space-y-6">
<!-- 分類篩選 -->
<div>
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">分類</h3>
<ul class="space-y-2">
<li>
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-cyan-600 focus:ring-cyan-500" />
上衣 <span class="ml-auto text-xs text-gray-400">(42)</span>
</label>
</li>
<li>
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" checked class="h-4 w-4 rounded border-gray-300 text-cyan-600 focus:ring-cyan-500" />
褲子 <span class="ml-auto text-xs text-gray-400">(28)</span>
</label>
</li>
<li>
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-cyan-600 focus:ring-cyan-500" />
外套 <span class="ml-auto text-xs text-gray-400">(19)</span>
</label>
</li>
</ul>
</div>
<!-- 價格篩選 -->
<div class="border-t border-gray-200 pt-6 dark:border-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">價格</h3>
<ul class="space-y-2">
<li>
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="radio" name="price" class="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500" />
$0 – $500
</label>
</li>
<li>
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="radio" name="price" class="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500" />
$500 – $1,000
</label>
</li>
</ul>
</div>
<!-- 顏色篩選 -->
<div class="border-t border-gray-200 pt-6 dark:border-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">顏色</h3>
<div class="flex flex-wrap gap-2">
<button class="h-7 w-7 rounded-full bg-gray-900 ring-2 ring-cyan-500 ring-offset-2 dark:ring-offset-gray-900" aria-label="黑色"></button>
<button class="h-7 w-7 rounded-full border border-gray-300 bg-white" aria-label="白色"></button>
<button class="h-7 w-7 rounded-full bg-cyan-600" aria-label="藍色"></button>
<button class="h-7 w-7 rounded-full bg-red-500" aria-label="紅色"></button>
</div>
</div>
</div>
</aside>
<!-- 商品區 -->
<section>
<!-- 排序 + 數量列 -->
<div class="mb-6 flex items-center justify-between gap-3">
<p class="text-sm text-gray-500 dark:text-gray-400">共 <span class="font-semibold text-gray-900 dark:text-white">128</span> 件商品</p>
<div class="flex items-center gap-3">
<!-- 手機才顯示的篩選按鈕 -->
<button id="filter-toggle"
class="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium
text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 lg:hidden">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h18M6 12h12M10 20h4"/>
</svg>
篩選
</button>
<select class="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700
focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500
dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
<option>最新上架</option>
<option>價格低到高</option>
<option>價格高到低</option>
<option>評分最高</option>
</select>
</div>
</div>
<!-- 商品卡 grid:手機 2 欄 / 平板 3 欄 / 桌機 4 欄 -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<!-- 商品卡放這裡(下一段實作) -->
</div>
</section>
</div>
</div>
這段有兩個地基級的重點。第一是外層那行 lg:grid lg:grid-cols-[240px_1fr]:它用 grid 的任意值語法,讓側欄固定 240px、商品區用 1fr 吃掉剩餘寬度,桌機一次到位;而 lg: 前綴確保手機時 grid 不生效,側欄靠自身的 hidden 收起、商品區佔滿整寬。第二是側欄的 sticky top-6:讓篩選面板在使用者往下捲商品時黏在畫面上、隨時可調整條件,這是商品列表的標配體驗。手機的 filter-toggle 按鈕加了 lg:hidden,只在小螢幕出現,點它彈出篩選面板(這一小段開關留到最後補上 JavaScript)。
商品卡:圖片、標籤、評分、價格與加入購物車
列表骨架好了,來做會出現幾十次的主角——商品卡。它是整個電商 UI 最需要「整齊」的單元:圖片區用固定比例、卡片用垂直 flex 把「加入購物車」按鈕推到底,確保一整排卡片不管內容長短都等高、按鈕齊平。
<!-- 商品卡:group 聯動 hover,flex flex-col h-full 讓按鈕對齊底部 -->
<article class="group flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white
shadow-sm transition-shadow hover:shadow-md
dark:border-gray-800 dark:bg-gray-900">
<!-- 圖片區:aspect-[3/4] 固定比例,object-cover 統一裁切 -->
<div class="relative aspect-[3/4] overflow-hidden bg-gray-100 dark:bg-gray-800">
<img src="product.jpg" alt="經典棉質 T 恤"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
<!-- 折扣標籤(左上) -->
<span class="absolute left-2 top-2 rounded-full bg-red-500 px-2 py-0.5 text-xs font-semibold text-white">-20%</span>
<!-- 收藏按鈕(右上,hover 顯示) -->
<button class="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full
bg-white/80 text-gray-600 opacity-0 backdrop-blur-sm transition-opacity
group-hover:opacity-100 hover:text-red-500 dark:bg-gray-900/80"
aria-label="加入收藏">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</button>
</div>
<!-- 資訊區:flex-1 吃剩餘高度,把下方 CTA 頂到底 -->
<div class="flex flex-1 flex-col p-4">
<p class="mb-1 text-xs uppercase tracking-wide text-gray-400">BENZ WEAR</p>
<h3 class="line-clamp-2 text-sm font-medium text-gray-900 group-hover:text-cyan-600 dark:text-white">
經典棉質短袖 T 恤 圓領素面
</h3>
<!-- 評分 -->
<div class="mt-1.5 flex items-center gap-1">
<div class="flex text-amber-400">
<svg class="h-4 w-4 fill-current" viewBox="0 0 20 20"><path d="M9.05 2.9c.3-.9 1.6-.9 1.9 0l1.07 3.3a1 1 0 00.95.68h3.46c.97 0 1.37 1.24.59 1.81l-2.8 2.03a1 1 0 00-.36 1.12l1.07 3.29c.3.92-.76 1.69-1.54 1.12l-2.8-2.03a1 1 0 00-1.18 0l-2.8 2.03c-.78.57-1.83-.2-1.54-1.12l1.07-3.29a1 1 0 00-.36-1.12L2.02 8.7c-.78-.57-.38-1.81.59-1.81h3.46a1 1 0 00.95-.68L9.05 2.9z"/></svg>
</div>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">4.5</span>
<span class="text-xs text-gray-400">(128)</span>
</div>
<!-- 價格 + 加入購物車:mt-auto 推到卡片底部,達成等高對齊 -->
<div class="mt-auto flex items-end justify-between pt-3">
<div class="flex items-baseline gap-1.5">
<span class="text-lg font-bold text-gray-900 dark:text-white">$712</span>
<span class="text-xs text-gray-400 line-through">$890</span>
</div>
<button class="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-500 text-white
transition-colors hover:bg-cyan-600
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500 focus-visible:ring-offset-2
dark:focus-visible:ring-offset-gray-900"
aria-label="加入購物車">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.3 2.3c-.6.6-.2 1.7.7 1.7H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</button>
</div>
</div>
</article>
這張卡是全篇最該記牢的一段,它的等高由三個 class 協力達成。第一是圖片容器的 aspect-[3/4] 搭 img 的 object-cover:不論原圖尺寸,圖片區永遠是 3:4 的直式比例、以中心裁切填滿,所有卡片的圖片高度先統一。第二是卡片本身的 flex h-full flex-col:grid 的每個 cell 預設會 stretch 撐滿列高,h-full 讓卡片吃滿整個 cell,flex-col 讓內容垂直排。第三是價格列的 mt-auto:它把「價格 + 加入購物車」那一列往下推到卡片最底部,於是即使某張卡標題只有一行、另一張標題折了兩行(靠 line-clamp-2 最多兩行),兩張卡的 CTA 按鈕依然對齊在同一水平線上。圖片比例固定 + flex-col + mt-auto,就是商品卡等高的黃金三件套。
商品詳情頁:左圖右資訊 + 尺寸選擇 + 雙 CTA
點進單一商品,來到詳情頁。它的排版是電商最經典的左圖右資訊:桌機用 lg:grid-cols-2 分成兩欄,左邊主圖加縮圖列、右邊是麵包屑、名稱、評分、價格、尺寸選擇與雙 CTA;手機則自動疊成上下。
<!-- 商品詳情:手機上下疊,桌機左圖右資訊 -->
<div class="mx-auto max-w-6xl px-4 py-8 lg:px-6">
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-12">
<!-- 左:圖片畫廊 -->
<div class="space-y-4">
<!-- 主圖 -->
<div class="relative aspect-square overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-800">
<img src="product-main.jpg" alt="經典棉質 T 恤 正面"
class="h-full w-full object-cover" />
<span class="absolute left-4 top-4 rounded-full bg-red-500 px-2.5 py-1 text-xs font-semibold text-white">-20%</span>
</div>
<!-- 縮圖列:4 欄,選中的加 ring -->
<div class="grid grid-cols-4 gap-3">
<button class="aspect-square overflow-hidden rounded-xl ring-2 ring-cyan-500 ring-offset-2 dark:ring-offset-gray-900">
<img src="thumb-1.jpg" alt="正面" class="h-full w-full object-cover" />
</button>
<button class="aspect-square overflow-hidden rounded-xl ring-1 ring-gray-200 transition hover:ring-cyan-300 dark:ring-gray-700">
<img src="thumb-2.jpg" alt="背面" class="h-full w-full object-cover" />
</button>
<button class="aspect-square overflow-hidden rounded-xl ring-1 ring-gray-200 transition hover:ring-cyan-300 dark:ring-gray-700">
<img src="thumb-3.jpg" alt="側面" class="h-full w-full object-cover" />
</button>
<button class="aspect-square overflow-hidden rounded-xl ring-1 ring-gray-200 transition hover:ring-cyan-300 dark:ring-gray-700">
<img src="thumb-4.jpg" alt="穿著" class="h-full w-full object-cover" />
</button>
</div>
</div>
<!-- 右:商品資訊 -->
<div class="space-y-6">
<!-- 麵包屑 -->
<nav class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400" aria-label="麵包屑">
<a href="#" class="hover:text-gray-900 dark:hover:text-white">首頁</a>
<span class="text-gray-300 dark:text-gray-600">/</span>
<a href="#" class="hover:text-gray-900 dark:hover:text-white">上衣</a>
<span class="text-gray-300 dark:text-gray-600">/</span>
<span class="text-gray-900 dark:text-white" aria-current="page">經典棉質 T 恤</span>
</nav>
<!-- 名稱與評分 -->
<div>
<p class="mb-1 text-sm font-medium uppercase tracking-wider text-cyan-600 dark:text-cyan-400">BENZ WEAR</p>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white lg:text-3xl">經典棉質短袖 T 恤 圓領素面</h1>
<div class="mt-2 flex items-center gap-3">
<div class="flex text-amber-400">
<svg class="h-5 w-5 fill-current" viewBox="0 0 20 20"><path d="M9.05 2.9c.3-.9 1.6-.9 1.9 0l1.07 3.3a1 1 0 00.95.68h3.46c.97 0 1.37 1.24.59 1.81l-2.8 2.03a1 1 0 00-.36 1.12l1.07 3.29c.3.92-.76 1.69-1.54 1.12l-2.8-2.03a1 1 0 00-1.18 0l-2.8 2.03c-.78.57-1.83-.2-1.54-1.12l1.07-3.29a1 1 0 00-.36-1.12L2.02 8.7c-.78-.57-.38-1.81.59-1.81h3.46a1 1 0 00.95-.68L9.05 2.9z"/></svg>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">4.5</span>
<a href="#reviews" class="text-sm text-gray-500 hover:underline dark:text-gray-400">128 則評論</a>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-sm font-medium text-green-600 dark:text-green-400">有庫存</span>
</div>
</div>
<!-- 價格 -->
<div class="flex items-baseline gap-3">
<span class="text-3xl font-bold text-gray-900 dark:text-white">$712</span>
<span class="text-xl text-gray-400 line-through">$890</span>
<span class="rounded-full bg-red-100 px-2 py-0.5 text-sm font-semibold text-red-700 dark:bg-red-900/30 dark:text-red-400">省 $178</span>
</div>
<!-- 尺寸選擇:隱藏 radio + peer-checked 樣式化 -->
<div>
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">尺寸</p>
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="選擇尺寸">
<label class="cursor-pointer">
<input type="radio" name="size" value="S" class="peer sr-only" />
<span class="flex h-10 w-14 items-center justify-center rounded-lg border border-gray-200 text-sm font-medium
text-gray-700 transition hover:border-gray-400
peer-checked:border-gray-900 peer-checked:bg-gray-900 peer-checked:text-white
dark:border-gray-700 dark:text-gray-300 dark:peer-checked:border-white dark:peer-checked:bg-white dark:peer-checked:text-gray-900">S</span>
</label>
<label class="cursor-pointer">
<input type="radio" name="size" value="M" checked class="peer sr-only" />
<span class="flex h-10 w-14 items-center justify-center rounded-lg border border-gray-200 text-sm font-medium
text-gray-700 transition hover:border-gray-400
peer-checked:border-gray-900 peer-checked:bg-gray-900 peer-checked:text-white
dark:border-gray-700 dark:text-gray-300 dark:peer-checked:border-white dark:peer-checked:bg-white dark:peer-checked:text-gray-900">M</span>
</label>
<label class="cursor-pointer">
<input type="radio" name="size" value="L" class="peer sr-only" />
<span class="flex h-10 w-14 items-center justify-center rounded-lg border border-gray-200 text-sm font-medium
text-gray-700 transition hover:border-gray-400
peer-checked:border-gray-900 peer-checked:bg-gray-900 peer-checked:text-white
dark:border-gray-700 dark:text-gray-300 dark:peer-checked:border-white dark:peer-checked:bg-white dark:peer-checked:text-gray-900">L</span>
</label>
</div>
</div>
<!-- 雙 CTA:主按鈕加入購物車 + 次按鈕收藏 -->
<div class="flex flex-col gap-3 pt-2 sm:flex-row">
<button class="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-cyan-500 px-6 text-base font-semibold text-white
transition-colors hover:bg-cyan-600
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500 focus-visible:ring-offset-2
dark:focus-visible:ring-offset-gray-900">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.3 2.3c-.6.6-.2 1.7.7 1.7H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
加入購物車
</button>
<button class="flex h-12 items-center justify-center gap-2 rounded-xl border border-gray-300 px-6 font-medium
text-gray-700 transition hover:border-gray-900 hover:bg-gray-50
dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800" aria-label="加入收藏">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
收藏
</button>
</div>
<!-- 政策標籤 -->
<div class="flex flex-wrap gap-4 border-t border-gray-200 pt-4 text-sm text-gray-600 dark:border-gray-800 dark:text-gray-400">
<span class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
免費運送
</span>
<span class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
30 天退換
</span>
</div>
</div>
</div>
</div>
詳情頁的重點在左右分欄與尺寸選擇器。外層 grid-cols-1 lg:grid-cols-2 讓版面在手機自然疊成「圖上、資訊下」,桌機才左右並排——這是不需要任何 JavaScript 的純響應式切換。主圖用 aspect-square 鎖 1:1、縮圖列用 grid-cols-4,選中的縮圖靠 ring-2 ring-cyan-500 ring-offset-2 標示。尺寸選擇器則沿用一個無障礙又好樣式化的技巧:把真正的 <input type="radio"> 用 sr-only 藏起來(仍可被鍵盤與螢幕閱讀器操作)、標成 peer,再讓相鄰的 <span> 用 peer-checked:bg-gray-900 peer-checked:text-white 呈現選中態。這樣既保留原生 radio 的可存取性,外觀又能完全自訂成方塊按鈕。雙 CTA 用 flex-col sm:flex-row,手機疊排、平板以上並排,主按鈕 flex-1 吃寬、用品牌 cyan 突顯,次按鈕描邊低調,主次分明。
購物車側欄:右滑抽屜 + 結帳摘要
最後是電商的臨門一腳——購物車側欄。它是一個從右側滑入的 fixed 抽屜,內部用垂直 flex 切成三段:頂部標題固定、中間商品清單可捲動、底部結帳摘要與 CTA 釘在最底。這個「固定外殼 + 可捲動內容」的結構,正是我們在《案例:儀表板》學過的思路,搬到抽屜上。
<!-- 遮罩:點它關閉購物車 -->
<div id="cart-overlay" class="fixed inset-0 z-40 hidden bg-black/50 backdrop-blur-sm"></div>
<!-- 購物車側欄:預設 translate-x-full 藏在右側畫面外 -->
<aside id="cart-drawer"
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md translate-x-full flex-col
bg-white shadow-xl transition-transform duration-300 ease-in-out
dark:bg-gray-900">
<!-- 頂部標題列(固定) -->
<div class="flex shrink-0 items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-800">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">購物車 <span class="text-gray-400">(3)</span></h2>
<button id="cart-close" class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800" aria-label="關閉購物車">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- 商品清單(唯一捲動區) -->
<div class="flex-1 overflow-y-auto px-5 py-4">
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
<!-- 單筆商品 -->
<li class="flex gap-4 py-4">
<div class="h-20 w-16 shrink-0 overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800">
<img src="cart-1.jpg" alt="經典棉質 T 恤" class="h-full w-full object-cover" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex items-start justify-between gap-2">
<h3 class="line-clamp-2 text-sm font-medium text-gray-900 dark:text-white">經典棉質短袖 T 恤 圓領素面</h3>
<button class="shrink-0 text-gray-400 hover:text-red-500" aria-label="移除">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">尺寸:M / 黑色</p>
<div class="mt-auto flex items-center justify-between pt-2">
<!-- 數量調整 -->
<div class="flex items-center rounded-lg border border-gray-200 dark:border-gray-700">
<button class="px-2.5 py-1 text-gray-500 hover:text-gray-900 dark:hover:text-white" aria-label="減少">−</button>
<span class="w-8 text-center text-sm text-gray-900 dark:text-white">1</span>
<button class="px-2.5 py-1 text-gray-500 hover:text-gray-900 dark:hover:text-white" aria-label="增加">+</button>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">$712</span>
</div>
</div>
</li>
<li class="flex gap-4 py-4">
<div class="h-20 w-16 shrink-0 overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800">
<img src="cart-2.jpg" alt="修身牛仔褲" class="h-full w-full object-cover" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex items-start justify-between gap-2">
<h3 class="line-clamp-2 text-sm font-medium text-gray-900 dark:text-white">修身直筒牛仔褲</h3>
<button class="shrink-0 text-gray-400 hover:text-red-500" aria-label="移除">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">尺寸:32 / 藍色</p>
<div class="mt-auto flex items-center justify-between pt-2">
<div class="flex items-center rounded-lg border border-gray-200 dark:border-gray-700">
<button class="px-2.5 py-1 text-gray-500 hover:text-gray-900 dark:hover:text-white" aria-label="減少">−</button>
<span class="w-8 text-center text-sm text-gray-900 dark:text-white">1</span>
<button class="px-2.5 py-1 text-gray-500 hover:text-gray-900 dark:hover:text-white" aria-label="增加">+</button>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">$1,290</span>
</div>
</div>
</li>
</ul>
</div>
<!-- 底部結帳摘要(固定) -->
<div class="shrink-0 border-t border-gray-200 px-5 py-4 dark:border-gray-800">
<dl class="space-y-2 text-sm">
<div class="flex justify-between text-gray-600 dark:text-gray-400">
<dt>小計</dt><dd class="font-medium text-gray-900 dark:text-white">$2,002</dd>
</div>
<div class="flex justify-between text-gray-600 dark:text-gray-400">
<dt>運費</dt><dd class="font-medium text-green-600 dark:text-green-400">免費</dd>
</div>
<div class="flex justify-between border-t border-gray-200 pt-2 text-base font-semibold text-gray-900 dark:border-gray-800 dark:text-white">
<dt>總計</dt><dd>$2,002</dd>
</div>
</dl>
<button class="mt-4 w-full rounded-xl bg-cyan-500 py-3 text-center font-semibold text-white
transition-colors hover:bg-cyan-600
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500 focus-visible:ring-offset-2
dark:focus-visible:ring-offset-gray-900">
前往結帳
</button>
<p class="mt-2 text-center text-xs text-gray-400">結帳前皆可修改數量或移除商品</p>
</div>
</aside>
購物車側欄有三個必記重點。第一是外層那組 fixed inset-y-0 right-0 w-full max-w-md translate-x-full:貼齊右緣、佔滿整個視窗高度、寬度用 w-full max-w-md 在手機滿版、桌機限制在 28rem,預設用 translate-x-full 把整個抽屜平移到畫面外藏起來,加 transition-transform duration-300 就有平滑滑入動畫。第二是內部的垂直三段式 flex flex-col:標題列與結帳摘要都是 shrink-0(固定不縮),中間商品清單用 flex-1 overflow-y-auto 吃掉所有剩餘高度並在商品多時自己捲動——這樣結帳摘要與「前往結帳」按鈕永遠釘在底部可見,絕不會被長清單擠出畫面。第三是每筆商品內部又是一個小 flex flex-col,用 mt-auto 把「數量調整 + 金額」推到該列底部對齊,呼應商品卡的同款技巧。
最後補上讓購物車與手機篩選面板開合的那一小段 JavaScript——版面純 CSS 就完成了,只有這些開關需要它:
// 購物車側欄滑入 / 滑出 + 遮罩
const cart = document.getElementById('cart-drawer');
const cartOverlay = document.getElementById('cart-overlay');
function openCart() {
cart.classList.remove('translate-x-full'); // 滑入
cartOverlay.classList.remove('hidden'); // 顯示遮罩
}
function closeCart() {
cart.classList.add('translate-x-full'); // 滑出
cartOverlay.classList.add('hidden'); // 隱藏遮罩
}
document.getElementById('cart-close').addEventListener('click', closeCart);
cartOverlay.addEventListener('click', closeCart);
// 商品卡或頂部購物車圖示綁定 openCart 即可觸發
至此,商品列表 + 篩選、等高商品卡、商品詳情頁、購物車側欄與結帳摘要四大塊全部到位,一套完整的電商前台骨架就成形了。
常見錯誤與最佳實踐
電商的坑幾乎都圍繞「整齊」與「轉換」打轉——一點不對齊就顯得廉價,一個 CTA 不夠明顯就流失訂單。
坑一:商品卡高低不齊,加入購物車按鈕參差不齊
這是電商 grid 第一名的坑。你把商品卡排進 grid,一切看似正常——直到某張卡標題折了兩行、某張有折扣標籤,整排卡片就高矮不一、底部按鈕像波浪一樣參差。原因是卡片高度預設由內容決定。正解是三件套:圖片區用 aspect-[3/4] 固定比例讓圖片高度先統一,卡片用 flex flex-col h-full 撐滿格子並垂直排,再給價格 / CTA 那列 mt-auto 把它推到卡片底部。 這樣不管內容長短,所有卡片等高、按鈕齊平。
坑二:商品圖片變形或比例不一,整片 grid 顯得雜亂
商品原圖尺寸五花八門,若直接讓 img 用原始比例塞進卡片,會出現有的方、有的長、有的被拉伸變形,整片 grid 立刻廉價。正解是圖片容器固定比例(aspect-square 或 aspect-[3/4])、img 一律 w-full h-full object-cover,讓所有圖片以中心裁切填滿同一個框、比例完全一致。切記別用 object-contain(會留白)或不設比例(會變形),object-cover 才是商品圖的標配。
坑三:加入購物車 CTA 不夠明顯,淹沒在卡片裡
有些人把「加入購物車」做成跟其他文字一樣的灰色小連結,結果最重要的轉換動作被淹沒,使用者找不到。正解是讓主 CTA 用品牌色實心底(如 bg-cyan-500 text-white)、有明確的按鈕外型與 hover 回饋,和卡片其他元素形成強烈對比。詳情頁的主 CTA 更要 flex-1 吃滿寬、字級加大,次要動作(收藏)則用描邊低調處理,主次分明。CTA 是電商的收銀台,絕不能低調。
坑四:購物車側欄結帳摘要被長清單擠出畫面
購物車做成單純一個往下長的容器,商品一多,結帳摘要與「前往結帳」按鈕就被擠到畫面外,使用者得捲到最底才找得到結帳——這是轉換殺手。正解是抽屜內用 flex flex-col h-full,把標題與結帳摘要設 shrink-0 固定,只讓中間商品清單 flex-1 overflow-y-auto 捲動。 這樣結帳 CTA 永遠釘在底部可見,商品再多也不影響下一步。
最佳實踐小結
- 商品卡等高三件套:
aspect-*固定圖片比例 +flex flex-col h-full+ CTA 列mt-auto。 - 圖片一律 object-cover:容器固定比例、
img用w-full h-full object-cover,杜絕變形與留白。 - 主 CTA 用品牌色實心:「加入購物車」要顯眼、有 hover 回饋;次要動作描邊低調,主次分明。
- 等寬卡片用 grid:
grid-cols-2 sm:grid-cols-3 lg:grid-cols-4換欄,天生等寬、對齊免算。 - 篩選側欄雙模式:桌機
lg:block常駐、手機hidden收起改用彈出面板,共用同一組選項。 - 購物車固定外殼:抽屜用
flex flex-col h-full,清單flex-1 overflow-y-auto、摘要shrink-0釘底。 - 暗色成對出現:每個
bg-、text-、border-都補一組dark:,徽章底色用/30透明度。
小結
這是 TailwindCSS 完整教學 系列的第四十六篇,也是實戰案例單元的壓軸。上一篇《案例:儀表板》我們練的是「資訊密度」與「固定外殼 + 可捲動內容」的後台骨架,主角是 flex 與 grid 的綜合排版;這一篇則轉向商業價值最高的電商前台,核心換成「大量重複單元的整齊呈現」加上「抽屜式的購物流程」。回顧幾個重點:
- 版面拆解:電商前台四塊——商品列表(篩選側欄 + 卡片 grid)、等高商品卡、左圖右資訊的商品詳情、右滑入的購物車側欄。
- 逐段實作:商品列表外殼(
lg:grid-cols-[240px_1fr]+sticky篩選)、商品卡(等高三件套)、商品詳情(lg:grid-cols-2+peer-checked尺寸選擇 + 雙 CTA)、購物車側欄(fixed抽屜 + 三段式 flex + 結帳摘要)。 - 電商心法:圖片固定比例、卡片
flex-col搭mt-auto等高、主 CTA 用品牌色實心、購物車結帳摘要shrink-0釘底。 - 電商的坑:商品卡高低不齊、圖片變形、CTA 不明顯、結帳摘要被擠出畫面,以及對應的正確做法。
到這裡,本系列的實戰案例單元——Landing Page、部落格、儀表板、電商——全部走完了。這幾種頁面的商業目標與版面哲學截然不同,卻共用同一套 Tailwind 工具:flex、grid、響應式斷點、dark:、peer 與狀態變體。當你能用同一組 utility 打造出需求完全相反的多種真實產品,就代表你已經把 TailwindCSS 內化成了自己的工具。實戰之路走完,接下來系列要進入另一個階段——面試。下一篇《面試:核心概念》,我們會把前面所有基礎與實戰濃縮成一份面試題庫,幫你把「會用」淬鍊成「講得清楚」。我們下篇見。