案例:電商 — 商品列表與購物車實作 | TailwindCSS 完整教學

2026/09/15
案例:電商 — 商品列表與購物車實作 | TailwindCSS 完整教學

走完前面幾堂實戰,這一篇我們挑戰商業價值最高、也最貼近真實產品的一種介面——電商。我們會用 TailwindCSS v4 逐段打造一整套電商前台:帶篩選側欄的商品列表 grid、圖片價格評分俱全的等高商品卡、資訊完整的商品詳情頁,以及從右側滑入的購物車側欄結帳摘要。全程響應式並支援暗色模式。做完你會得到一套能直接接上真實商品資料的電商 UI 骨架。

前言

電商介面是前端裡「規則最多」的一種頁面。它不像 Landing Page 只求氣勢、也不像部落格只求好讀——電商每一個像素都跟轉換率掛鉤:商品圖要吸睛且比例一致、價格與折扣要一眼看懂、評分要建立信任、「加入購物車」的 CTA 要顯眼好按,而購物車與結帳的每一步都不能讓使用者猶豫。這是把視覺、資訊層級、互動三者綁最緊的一種版面。

這是本系列實戰案例的收尾之作。上一篇《案例:儀表板》我們練的是「資訊密度」與「固定外殼 + 可捲動內容」的應用程式骨架,主角是 flexgrid 的綜合排版;這一篇的挑戰不同——電商的核心是**「大量重複單元的整齊呈現」加上「抽屜式的購物流程」**。商品卡會出現幾十上百次,任何一點不對齊都會被放大成廉價感;而購物車側欄則把我們在儀表板學到的「固定外殼」思路,搬到一個從右側滑入的抽屜上。

為什麼電商值得壓軸?因為它把整個系列學過的東西一次全用上,還逼你面對兩個電商特有的難題。第一是商品卡等高:一整片 grid 裡,如何讓每張卡不管標題幾行、有沒有折扣標籤,底部的「加入購物車」按鈕都對齊在同一條線上。第二是購物車抽屜:如何用 fixed + translate-x 做一個從右側滑入、內部清單可捲動、結帳摘要固定在底的側欄。這兩題是電商前端天天在解的日常。

成品是一套電商前台。商品列表頁左側是可收合的篩選側欄(分類、價格、顏色),右側是響應式的商品卡 grid,每張卡有圖片、標籤、標題、評分、價格與加入購物車。商品詳情頁左圖右資訊,含圖片、麵包屑、評分、價格、尺寸選擇與雙 CTA。購物車則是從右側滑入的側欄,含商品清單與含小計、運費、總計的結帳摘要。整套完全響應式並支援暗色模式。

本系列以 TailwindCSS v4 為預設版本,範例皆以 v4 語法示範。凡涉及 v3 差異之處,我會特別標註。

本篇你將學到:

  • 等高商品卡:用 aspect-* 固定圖片比例、flex flex-col h-fullmt-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]imgobject-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-squareaspect-[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:容器固定比例、imgw-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 完整教學 系列的第四十六篇,也是實戰案例單元的壓軸。上一篇《案例:儀表板》我們練的是「資訊密度」與「固定外殼 + 可捲動內容」的後台骨架,主角是 flexgrid 的綜合排版;這一篇則轉向商業價值最高的電商前台,核心換成「大量重複單元的整齊呈現」加上「抽屜式的購物流程」。回顧幾個重點:

  • 版面拆解:電商前台四塊——商品列表(篩選側欄 + 卡片 grid)、等高商品卡、左圖右資訊的商品詳情、右滑入的購物車側欄。
  • 逐段實作:商品列表外殼(lg:grid-cols-[240px_1fr] + sticky 篩選)、商品卡(等高三件套)、商品詳情(lg:grid-cols-2 + peer-checked 尺寸選擇 + 雙 CTA)、購物車側欄(fixed 抽屜 + 三段式 flex + 結帳摘要)。
  • 電商心法:圖片固定比例、卡片 flex-colmt-auto 等高、主 CTA 用品牌色實心、購物車結帳摘要 shrink-0 釘底。
  • 電商的坑:商品卡高低不齊、圖片變形、CTA 不明顯、結帳摘要被擠出畫面,以及對應的正確做法。

到這裡,本系列的實戰案例單元——Landing Page、部落格、儀表板、電商——全部走完了。這幾種頁面的商業目標與版面哲學截然不同,卻共用同一套 Tailwind 工具:flexgrid、響應式斷點、dark:peer 與狀態變體。當你能用同一組 utility 打造出需求完全相反的多種真實產品,就代表你已經把 TailwindCSS 內化成了自己的工具。實戰之路走完,接下來系列要進入另一個階段——面試。下一篇《面試:核心概念》,我們會把前面所有基礎與實戰濃縮成一份面試題庫,幫你把「會用」淬鍊成「講得清楚」。我們下篇見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →