10.1k
New Components: Field, Input Group, Item and more

Building Blocks for the Web

Clean, modern building blocks. Copy and paste into your apps. Works with all Vue frameworks. Open Source. Free forever.

Files
components/ProductCardBasic.vue
<script setup lang="ts">
import { ref } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardImage } from '@/components/ui/card'
import { Image } from '@/components/ui/image'
import { Price } from '@/components/ui/price'
import { Rating } from '@/components/ui/rating'
import { SwatchFallback, SwatchImage } from '@/components/ui/swatch'
import { SwatchGroup, SwatchGroupItem } from '@/components/ui/swatch-group'
import type { PriceType } from '@/lib/format'

interface Sibling {
  key: string
  name?: string
  cover?: { src?: string }
  link?: { path?: string }
  colors?: {
    mainColorHex?: string
    exactColorHex?: string
    exactColorName?: string
  }
}

interface Product {
  name?: string
  key?: string
  link?: { path?: string }
  price: PriceType
  cover?: { src?: string }
  rating?: number
  reviewCount?: number
  badge?: string
  clearout?: boolean
  inStore?: boolean
  siblings?: { items?: Sibling[] }
}

const props = withDefaults(defineProps<{
  product?: Product
}>(), {
  product: () => ({
    name: 'Classic Leather Sneakers',
    key: 'product-1',
    price: { amount: 12999, currency: 'EUR', ref: 15999 },
    cover: { src: '/placeholder.svg' },
    rating: 4.5,
    reviewCount: 128,
    badge: 'Sale',
    clearout: true,
    inStore: false,
    siblings: {
      items: [
        { key: 'sibling-1', name: 'White', cover: { src: '/placeholder.svg' }, colors: { exactColorHex: '#FFFFFF', exactColorName: 'White' } },
        { key: 'sibling-2', name: 'Black', cover: { src: '/placeholder.svg' }, colors: { exactColorHex: '#1A1A1A', exactColorName: 'Black' } },
        { key: 'sibling-3', name: 'Navy', cover: { src: '/placeholder.svg' }, colors: { exactColorHex: '#1E3A5F', exactColorName: 'Navy' } },
      ],
    },
  }),
})

const selectedSibling = ref(props.product.key ?? props.product.siblings?.items?.[0]?.key ?? '')
</script>

<template>
  <Card class="group w-full max-w-xs overflow-hidden">
    <CardImage class="relative">
      <Image
        :src="product.cover?.src ?? '/placeholder.svg'"
        :alt="product.name"
        class="aspect-[3/4] w-full object-cover transition-transform duration-300 group-hover:scale-105"
      />
      <Badge
        v-if="product.badge"
        variant="default"
        color="promo"
        class="absolute left-3 top-3"
      >
        {{ product.badge }}
      </Badge>
      <div v-if="product.clearout || product.inStore" class="absolute right-3 top-3 flex flex-col gap-1">
        <Badge v-if="product.clearout" variant="default" color="discount">
          Clearout
        </Badge>
        <Badge v-if="product.inStore" variant="default" color="positive">
          In Store
        </Badge>
      </div>
    </CardImage>

    <CardContent class="space-y-2 p-4">
      <p class="text-xs text-muted-foreground">
        Flagship
      </p>
      <h3 class="line-clamp-1 text-sm font-medium">
        {{ product.name }}
      </h3>

      <Rating v-if="product.rating" :rating="product.rating" size="sm">
        <template #meta>
          <span class="text-xs text-muted-foreground">({{ product.reviewCount }})</span>
        </template>
      </Rating>

      <Price
        :price="product.price"
      />

      <SwatchGroup
        v-if="product.siblings?.items?.length"
        v-model="selectedSibling"
        type="single"
        size="sm"
        class="pt-1"
      >
        <SwatchGroupItem
          v-for="sibling in product.siblings.items.slice(0, 5)"
          :key="sibling.key"
          :value="sibling.key"
          :hex="sibling.colors?.exactColorHex"
        >
          <SwatchImage
            v-if="sibling.cover?.src"
            :src="sibling.cover.src"
            :alt="sibling.colors?.exactColorName ?? sibling.name"
          />
          <SwatchFallback />
        </SwatchGroupItem>
      </SwatchGroup>
    </CardContent>

    <CardFooter class="p-4 pt-0">
      <Button class="w-full" color="buy">
        Add to Cart
      </Button>
    </CardFooter>
  </Card>
</template>
A product card with image, rating, price, and color swatches.
product-card-basic
Files
components/ProductCardHorizontal.vue
<script setup lang="ts">
import { ref } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardImage } from '@/components/ui/card'
import { Image } from '@/components/ui/image'
import { Price } from '@/components/ui/price'
import { Rating } from '@/components/ui/rating'
import { SwatchFallback, SwatchImage } from '@/components/ui/swatch'
import { SwatchGroup, SwatchGroupItem } from '@/components/ui/swatch-group'
import type { PriceType } from '@/lib/format'

interface Sibling {
  key: string
  name?: string
  cover?: { src?: string }
  link?: { path?: string }
  colors?: {
    mainColorHex?: string
    exactColorHex?: string
    exactColorName?: string
  }
}

interface Product {
  name?: string
  key?: string
  description?: string
  link?: { path?: string }
  price: PriceType
  cover?: { src?: string }
  rating?: number
  reviewCount?: number
  badge?: string
  clearout?: boolean
  inStore?: boolean
  siblings?: { items?: Sibling[] }
}

const props = withDefaults(
  defineProps<{
    product?: Product
  }>(),
  {
    product: () => ({
      name: 'Zip Tote Basket',
      key: 'product-1',
      description: 'White and black stripes with a zippered top and two interior pockets for organization.',
      price: { amount: 14000, currency: 'EUR' },
      cover: { src: '/placeholder.svg' },
      rating: 4.0,
      reviewCount: 42,
      siblings: {
        items: [
          { key: 'sibling-1', name: 'Striped', colors: { exactColorHex: '#374151', exactColorName: 'Charcoal' } },
          { key: 'sibling-2', name: 'Natural', colors: { exactColorHex: '#D4C5A9', exactColorName: 'Natural' } },
        ],
      },
    }),
  },
)

const selectedSibling = ref(props.product.key ?? props.product.siblings?.items?.[0]?.key ?? '')
</script>

<template>
  <Card class="group overflow-hidden">
    <a :href="product.link?.path ?? '#'">
      <CardImage class="relative">
        <Image
          :src="product.cover?.src ?? '/placeholder.svg'"
          :alt="product.name"
          class="aspect-[4/3] w-full object-cover transition-opacity duration-200 group-hover:opacity-75"
        />
        <Badge
          v-if="product.badge"
          variant="default"
          color="promo"
          class="absolute left-3 top-3"
        >
          {{ product.badge }}
        </Badge>
        <div v-if="product.clearout || product.inStore" class="absolute right-3 top-3 flex flex-col gap-1">
          <Badge v-if="product.clearout" variant="default" color="discount">
            Clearout
          </Badge>
          <Badge v-if="product.inStore" variant="default" color="positive">
            In Store
          </Badge>
        </div>
      </CardImage>
    </a>
    <CardContent class="space-y-2 p-4">
      <h3 class="text-sm font-medium">
        {{ product.name }}
      </h3>
      <p v-if="product.description" class="line-clamp-2 text-sm text-muted-foreground">
        {{ product.description }}
      </p>
      <Rating v-if="product.rating" :rating="product.rating" size="sm" />
      <SwatchGroup
        v-if="product.siblings?.items?.length"
        v-model="selectedSibling"
        type="single"
        size="sm"
      >
        <SwatchGroupItem
          v-for="sibling in product.siblings.items.slice(0, 4)"
          :key="sibling.key"
          :value="sibling.key"
          :hex="sibling.colors?.exactColorHex"
        >
          <SwatchImage
            v-if="sibling.cover?.src"
            :src="sibling.cover.src"
            :alt="sibling.colors?.exactColorName ?? sibling.name"
          />
          <SwatchFallback />
        </SwatchGroupItem>
      </SwatchGroup>
      <Price class="font-semibold" :price="product.price" :show-ref="false" :show-discount="false" />
    </CardContent>
  </Card>
</template>
A bordered product card grid with image, description, color label, and bold price.
product-card-horizontal
Files
components/ProductCardHover.vue
<script setup lang="ts">
import { ref } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardImage } from '@/components/ui/card'
import { Image } from '@/components/ui/image'
import { Price } from '@/components/ui/price'
import { Rating } from '@/components/ui/rating'
import { SwatchGroup, SwatchGroupItem } from '@/components/ui/swatch-group'
import type { PriceType } from '@/lib/format'

interface Sibling {
  key: string
  name?: string
  cover?: { src?: string }
  link?: { path?: string }
  colors?: {
    mainColorHex?: string
    exactColorHex?: string
    exactColorName?: string
  }
}

interface Product {
  name?: string
  key?: string
  link?: { path?: string }
  price: PriceType
  cover?: { src?: string }
  rating?: number
  reviewCount?: number
  badge?: string
  clearout?: boolean
  inStore?: boolean
  siblings?: { items?: Sibling[] }
}

const props = withDefaults(
  defineProps<{
    product?: Product
  }>(),
  {
    product: () => ({
      name: 'Basic Tee',
      key: 'product-1',
      price: { amount: 3500, currency: 'EUR' },
      cover: { src: '/placeholder.svg' },
      rating: 4.2,
      reviewCount: 64,
      siblings: {
        items: [
          { key: 'sibling-1', name: 'Black', colors: { exactColorHex: '#1A1A1A', exactColorName: 'Black' } },
          { key: 'sibling-2', name: 'White', colors: { exactColorHex: '#FFFFFF', exactColorName: 'White' } },
          { key: 'sibling-3', name: 'Heather Grey', colors: { exactColorHex: '#9CA3AF', exactColorName: 'Heather Grey' } },
        ],
      },
    }),
  },
)

const selectedSibling = ref(props.product.key ?? props.product.siblings?.items?.[0]?.key ?? '')
</script>

<template>
  <Card class="group overflow-hidden">
    <a :href="product.link?.path ?? '#'">
      <CardImage class="relative">
        <Image
          :src="product.cover?.src ?? '/placeholder.svg'"
          :alt="product.name"
          class="aspect-square w-full object-cover transition-opacity duration-200 group-hover:opacity-75"
        />
        <Badge
          v-if="product.badge"
          variant="default"
          color="promo"
          class="absolute left-3 top-3"
        >
          {{ product.badge }}
        </Badge>
        <div v-if="product.clearout || product.inStore" class="absolute right-3 top-3 flex flex-col gap-1">
          <Badge v-if="product.clearout" variant="default" color="discount">
            Clearout
          </Badge>
          <Badge v-if="product.inStore" variant="default" color="positive">
            In Store
          </Badge>
        </div>
      </CardImage>
    </a>
    <CardContent class="flex items-start justify-between pt-4">
      <div class="space-y-1">
        <h3 class="text-sm font-medium">
          {{ product.name }}
        </h3>
        <Rating v-if="product.rating" :rating="product.rating" size="sm" />
        <SwatchGroup
          v-if="product.siblings?.items?.length"
          v-model="selectedSibling"
          type="single"
          size="sm"
        >
          <SwatchGroupItem
            v-for="sibling in product.siblings.items.slice(0, 4)"
            :key="sibling.key"
            :value="sibling.key"
            :hex="sibling.colors?.exactColorHex"
          />
        </SwatchGroup>
      </div>
      <Price size="sm" :price="product.price" :show-ref="false" :show-discount="false" />
    </CardContent>
  </Card>
</template>
A minimal product card grid with image, name, color label, and price.
product-card-hover
Files
components/ProductCardMinimal.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardImage } from '@/components/ui/card'
import { Image } from '@/components/ui/image'
import { Price } from '@/components/ui/price'
import { Rating } from '@/components/ui/rating'
import { SwatchFallback, SwatchImage } from '@/components/ui/swatch'
import { SwatchGroup, SwatchGroupItem } from '@/components/ui/swatch-group'
import type { PriceType } from '@/lib/format'

interface Sibling {
  key: string
  name?: string
  cover?: { src?: string }
  link?: { path?: string }
  colors?: {
    mainColorHex?: string
    exactColorHex?: string
    exactColorName?: string
  }
}

interface Product {
  name?: string
  key?: string
  description?: string
  link?: { path?: string }
  price: PriceType
  cover?: { src?: string }
  rating?: number
  reviewCount?: number
  badge?: string
  clearout?: boolean
  inStore?: boolean
  siblings?: { items?: Sibling[] }
}

const props = withDefaults(
  defineProps<{
    product?: Product
  }>(),
  {
    product: () => ({
      name: 'Nike Air Max 270',
      key: 'product-1',
      description: "Women's Shoes",
      price: { amount: 15999, currency: 'EUR' },
      cover: { src: '/placeholder.svg' },
      rating: 4.3,
      reviewCount: 215,
      badge: 'Bestseller',
      siblings: {
        items: [
          { key: 'sibling-1', name: 'Black', colors: { exactColorHex: '#1A1A1A', exactColorName: 'Black' } },
          { key: 'sibling-2', name: 'Grey', colors: { exactColorHex: '#6B7280', exactColorName: 'Grey' } },
          { key: 'sibling-3', name: 'Green', colors: { exactColorHex: '#16A34A', exactColorName: 'Green' } },
          { key: 'sibling-4', name: 'Blue', colors: { exactColorHex: '#2563EB', exactColorName: 'Blue' } },
          { key: 'sibling-5', name: 'Red', colors: { exactColorHex: '#DC2626', exactColorName: 'Red' } },
        ],
      },
    }),
  },
)

const selectedSibling = ref(props.product.key ?? props.product.siblings?.items?.[0]?.key ?? '')

const siblingCountLabel = computed(() => {
  const count = props.product.siblings?.items?.length ?? 0
  return count > 0 ? `${count} Color${count > 1 ? 's' : ''}` : ''
})
</script>

<template>
  <Card class="group overflow-hidden">
    <a :href="product.link?.path ?? '#'">
      <CardImage class="relative">
        <Image
          :src="product.cover?.src ?? '/placeholder.svg'"
          :alt="product.name"
          class="aspect-[3/4] w-full object-cover transition-transform duration-300 group-hover:scale-105"
        />
        <Badge
          v-if="product.badge"
          variant="default"
          color="promo"
          class="absolute left-3 top-3"
        >
          {{ product.badge }}
        </Badge>
        <div v-if="product.clearout || product.inStore" class="absolute right-3 top-3 flex flex-col gap-1">
          <Badge v-if="product.clearout" variant="default" color="discount">
            Clearout
          </Badge>
          <Badge v-if="product.inStore" variant="default" color="positive">
            In Store
          </Badge>
        </div>
      </CardImage>
    </a>
    <CardContent class="space-y-2 pt-4">
      <!-- Color dots -->
      <SwatchGroup
        v-if="product.siblings?.items?.length"
        v-model="selectedSibling"
        type="single"
        size="sm"
        pill
      >
        <SwatchGroupItem
          v-for="sibling in product.siblings.items.slice(0, 5)"
          :key="sibling.key"
          :value="sibling.key"
          :hex="sibling.colors?.exactColorHex"
        >
          <SwatchImage
            v-if="sibling.cover?.src"
            :src="sibling.cover.src"
            :alt="sibling.colors?.exactColorName ?? sibling.name"
          />
          <SwatchFallback />
        </SwatchGroupItem>
      </SwatchGroup>

      <Badge
        v-if="product.badge"
        variant="default"
        color="positive"
        class="mb-1"
      >
        {{ product.badge }}
      </Badge>
      <h3 class="text-sm font-medium">
        {{ product.name }}
      </h3>
      <p v-if="product.description" class="text-sm text-muted-foreground">
        {{ product.description }}
      </p>
      <Rating v-if="product.rating" :rating="product.rating" size="sm" />
      <p v-if="siblingCountLabel" class="text-sm text-muted-foreground">
        {{ siblingCountLabel }}
      </p>
      <Price size="sm" :price="product.price" :show-ref="false" :show-discount="false" />
    </CardContent>
  </Card>
</template>
A product card grid with color dots, color count label, brand subtitle, and badge.
product-card-minimal
Files
components/ProductDetail.vue
<script setup lang="ts">
import { ref } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Price } from '@/components/ui/price'
import { Rating } from '@/components/ui/rating'
import type { PriceType } from '@/lib/format'

interface Color {
  name: string
  value: string
}

interface Size {
  name: string
  inStock: boolean
}

interface ProductDetailData {
  name: string
  brand: string
  price: PriceType
  compareAtPrice?: PriceType
  image: string
  images?: string[]
  rating?: number
  reviewCount?: number
  colors?: Color[]
  sizes?: Size[]
  description?: string
  details?: string[]
  badge?: string
}

const props = withDefaults(defineProps<{
  product?: ProductDetailData
}>(), {
  product: () => ({
    name: 'Basic Tee 6-Pack',
    brand: 'Urban Craft',
    price: { amount: 19200, currency: 'EUR' },
    image: '/placeholder.svg',
    images: ['/placeholder.svg', '/placeholder.svg'],
    rating: 3.9,
    reviewCount: 512,
    colors: [
      { name: 'White', value: '#FFFFFF' },
      { name: 'Gray', value: '#9CA3AF' },
      { name: 'Black', value: '#1A1A1A' },
    ],
    sizes: [
      { name: 'XXS', inStock: true },
      { name: 'XS', inStock: true },
      { name: 'S', inStock: true },
      { name: 'M', inStock: true },
      { name: 'L', inStock: true },
      { name: 'XL', inStock: false },
      { name: 'XXL', inStock: true },
      { name: '3XL', inStock: true },
    ],
    description: 'The Basic Tee 6-Pack allows you to fully express your vibrant personality with three grayscale options. Rinse, repeat, and add some variety with our multipacks designed for everyday wear.',
    details: [
      'Hand cut and sewn locally',
      'Dyed with our proprietary colors',
      'Pre-washed & pre-shrunk',
      'Ultra-soft 100% cotton',
    ],
  }),
})

const selectedColor = ref(props.product.colors?.[0])
const selectedSize = ref(props.product.sizes?.find(s => s.inStock))
</script>

<template>
  <div class="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:grid lg:max-w-7xl lg:grid-cols-2 lg:gap-x-8 lg:px-8">
    <!-- Image gallery -->
    <div class="lg:row-span-2">
      <div class="grid gap-4" :class="product.images?.length ? 'grid-cols-2' : ''">
        <div :class="product.images?.length ? 'col-span-2' : ''">
          <img
            :src="product.image"
            :alt="product.name"
            class="aspect-[4/5] w-full rounded-lg bg-muted object-cover"
          />
        </div>
        <img
          v-for="(img, i) in product.images?.slice(0, 2)"
          :key="i"
          :src="img"
          :alt="`${product.name} - Image ${i + 2}`"
          class="aspect-[4/5] w-full rounded-lg bg-muted object-cover"
        />
      </div>
    </div>

    <!-- Product info -->
    <div class="mt-8 lg:mt-0">
      <p v-if="product.brand" class="text-sm text-muted-foreground">
        {{ product.brand }}
      </p>

      <div class="flex items-start justify-between gap-4">
        <h1 class="text-2xl font-bold tracking-tight sm:text-3xl">
          {{ product.name }}
        </h1>
        <Badge
          v-if="product.badge"
          variant="default"
          color="promo"
          class="mt-1 shrink-0"
        >
          {{ product.badge }}
        </Badge>
      </div>

      <!-- compareAtPrice.currency is assumed to match price.currency -->
      <Price
        class="mt-3"
        size="lg"
        :price="{ ...product.price, ref: product.compareAtPrice?.amount }"
      />

      <!-- Rating -->
      <div v-if="product.rating" class="mt-3">
        <Rating :rating="product.rating" size="sm">
          <template #meta>
            <a href="#" class="text-sm text-muted-foreground hover:text-foreground">
              {{ product.reviewCount }} reviews
            </a>
          </template>
        </Rating>
      </div>

      <!-- Color picker -->
      <div v-if="product.colors?.length" class="mt-8">
        <h3 class="text-sm font-medium">Color</h3>
        <fieldset class="mt-3">
          <legend class="sr-only">Choose a color</legend>
          <div class="flex items-center gap-3">
            <button
              v-for="color in product.colors"
              :key="color.name"
              type="button"
              :aria-label="color.name"
              :title="color.name"
              class="relative flex size-8 cursor-pointer items-center justify-center rounded-full border-2 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
              :class="[
                selectedColor?.name === color.name
                  ? 'border-foreground'
                  : 'border-transparent hover:border-muted-foreground/50',
              ]"
              @click="selectedColor = color"
            >
              <span
                class="size-6 rounded-full border border-black/10"
                :style="{ backgroundColor: color.value }"
              />
            </button>
          </div>
        </fieldset>
      </div>

      <!-- Size picker -->
      <div v-if="product.sizes?.length" class="mt-8">
        <div class="flex items-center justify-between">
          <h3 class="text-sm font-medium">Size</h3>
          <a href="#" class="text-sm font-medium text-primary hover:text-primary/80">
            Size guide
          </a>
        </div>
        <fieldset class="mt-3">
          <legend class="sr-only">Choose a size</legend>
          <div class="grid grid-cols-4 gap-3 sm:grid-cols-8">
            <button
              v-for="size in product.sizes"
              :key="size.name"
              type="button"
              :disabled="!size.inStock"
              class="relative flex items-center justify-center rounded-md border px-3 py-2.5 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
              :class="[
                size.inStock
                  ? selectedSize?.name === size.name
                    ? 'border-foreground bg-foreground text-background'
                    : 'border-border bg-background text-foreground hover:bg-accent'
                  : 'cursor-not-allowed border-border bg-muted text-muted-foreground',
              ]"
              @click="size.inStock && (selectedSize = size)"
            >
              {{ size.name }}
              <!-- Diagonal line for out-of-stock -->
              <svg
                v-if="!size.inStock"
                class="pointer-events-none absolute inset-0 size-full stroke-muted-foreground/40"
                viewBox="0 0 100 100"
                preserveAspectRatio="none"
                stroke-width="1"
              >
                <line x1="0" y1="100" x2="100" y2="0" vector-effect="non-scaling-stroke" />
              </svg>
            </button>
          </div>
        </fieldset>
      </div>

      <!-- Add to cart -->
      <Button class="mt-8 w-full" size="lg" color="buy">
        Add to Cart
      </Button>

      <!-- Description -->
      <div v-if="product.description" class="mt-8 border-t pt-8">
        <h3 class="text-sm font-medium">Description</h3>
        <p class="mt-3 text-sm leading-relaxed text-muted-foreground">
          {{ product.description }}
        </p>
      </div>

      <!-- Details -->
      <div v-if="product.details?.length" class="mt-8 border-t pt-8">
        <h3 class="text-sm font-medium">Fabric &amp; Care</h3>
        <ul class="mt-3 list-disc space-y-1.5 pl-5 text-sm text-muted-foreground">
          <li v-for="(detail, i) in product.details" :key="i">
            {{ detail }}
          </li>
        </ul>
      </div>

      <!-- Policies -->
      <div class="mt-8 border-t pt-8">
        <h3 class="sr-only">Policies</h3>
        <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
          <div class="flex flex-col items-center rounded-lg border p-4 text-center">
            <svg xmlns="http://www.w3.org/2000/svg" class="size-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
              <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.467.729-3.56" />
            </svg>
            <p class="mt-2 text-sm font-medium">International delivery</p>
            <p class="mt-1 text-xs text-muted-foreground">Get your order in 2-8 days</p>
          </div>
          <div class="flex flex-col items-center rounded-lg border p-4 text-center">
            <svg xmlns="http://www.w3.org/2000/svg" class="size-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
              <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
            </svg>
            <p class="mt-2 text-sm font-medium">Loyalty rewards</p>
            <p class="mt-1 text-xs text-muted-foreground">Earn points with every purchase</p>
          </div>
          <div class="flex flex-col items-center rounded-lg border p-4 text-center">
            <svg xmlns="http://www.w3.org/2000/svg" class="size-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
              <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182" />
            </svg>
            <p class="mt-2 text-sm font-medium">Free returns</p>
            <p class="mt-1 text-xs text-muted-foreground">30-day return policy</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
A product detail view with image gallery, variant pickers, rating, and buy box.
product-detail
Files
components/ProductFeatureGrid.vue
<script setup lang="ts">
import Image from '@/components/ui/image/Image.vue'

interface Feature {
  name: string
  description: string
}

withDefaults(
  defineProps<{
    subtitle?: string
    title?: string
    description?: string
    image?: string
    imageAlt?: string
    features?: Feature[]
  }>(),
  {
    subtitle: 'Alpine Pro Collection',
    title: 'Built for the Mountain',
    description: 'Engineered for the harshest winter conditions, this snowboard jacket keeps you warm, dry, and moving freely all day on the slopes.',
    image: 'https://cdn.demo-shop.com/media/1366/768/e0/86/58/1740128566/mann_jacken.png?ts=1740128566',
    imageAlt: 'Man wearing a winter sports jacket in the mountains',
    features: () => [
      {
        name: 'Waterproof',
        description: '20K waterproof rating with fully taped seams keeps you dry in heavy snowfall and wet conditions.',
      },
      {
        name: 'Breathable',
        description: 'High-performance membrane wicks moisture away so you stay comfortable during intense riding sessions.',
      },
      {
        name: 'Insulated',
        description: 'Synthetic down fill provides lightweight warmth without bulk, so you can move freely on every turn.',
      },
      {
        name: 'Helmet-compatible hood',
        description: 'Adjustable hood fits over your helmet for full protection during storms without limiting visibility.',
      },
    ],
  },
)
</script>

<template>
  <section class="relative">
    <!-- Image — full-width on mobile, left half on desktop -->
    <Image
      :src="image"
      :alt="imageAlt"
      class="block aspect-3/2 w-full sm:aspect-5/2 lg:absolute lg:inset-y-0 lg:left-0 lg:aspect-auto lg:h-full lg:w-1/2"
    />

    <!-- Content — right half on desktop -->
    <div class="mx-auto max-w-2xl px-4 pt-16 pb-24 sm:px-6 sm:pb-32 lg:grid lg:max-w-7xl lg:grid-cols-2 lg:gap-x-8 lg:px-8 lg:pt-32">
      <div class="lg:col-start-2">
        <p class="text-sm font-medium text-muted-foreground">{{ subtitle }}</p>
        <h2 class="mt-4 text-4xl font-bold tracking-tight">{{ title }}</h2>
        <p class="mt-4 text-muted-foreground">{{ description }}</p>

        <!-- Feature grid -->
        <dl class="mt-10 grid grid-cols-1 gap-x-8 gap-y-10 text-sm sm:grid-cols-2">
          <div v-for="feature in features" :key="feature.name">
            <dt class="font-medium">{{ feature.name }}</dt>
            <dd class="mt-2 text-muted-foreground">{{ feature.description }}</dd>
          </div>
        </dl>
      </div>
    </div>
  </section>
</template>
Split product feature section with image left and feature specs grid right.
product-feature-grid
Files
components/ProductFeatureList.vue
<script setup lang="ts">
import Image from '@/components/ui/image/Image.vue'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'

interface TabFeature {
  name: string
  description: string
  image: string
  imageAlt: string
}

interface Tab {
  name: string
  value: string
  features: TabFeature[]
}

withDefaults(
  defineProps<{
    title?: string
    description?: string
    tabs?: Tab[]
  }>(),
  {
    title: 'Technical Specifications',
    description: 'Every piece of gear in the Alpine Pro line is designed to perform in extreme winter conditions while keeping you comfortable all day on the mountain.',
    tabs: () => [
      {
        name: 'Protection',
        value: 'protection',
        features: [
          {
            name: 'Waterproof shell with taped seams',
            description: 'A 20K/20K waterproof-breathable membrane combined with fully taped seams ensures you stay dry in heavy snowfall, sleet, and wind-driven rain — even during extended sessions in the backcountry.',
            image: 'https://cdn.demo-shop.com/media/1366/768/e0/86/58/1740128566/mann_jacken.png?ts=1740128566',
            imageAlt: 'Man wearing a winter jacket in snowy mountain conditions',
          },
        ],
      },
      {
        name: 'Insulation',
        value: 'insulation',
        features: [
          {
            name: 'Synthetic down core warmth',
            description: 'Lightweight synthetic insulation retains heat even when damp, giving you consistent warmth without the bulk. Strategically placed in the core and hood to protect against wind chill on exposed lifts and ridgelines.',
            image: 'https://cdn.demo-shop.com/media/1366/768/30/da/24/1740128586/mann_winter_hose.png?ts=1740128586',
            imageAlt: 'Man wearing winter snow pants on the slopes',
          },
        ],
      },
      {
        name: 'Mobility',
        value: 'mobility',
        features: [
          {
            name: 'Articulated fit for unrestricted movement',
            description: 'Pre-shaped sleeves, gusseted underarms, and stretch panels in key areas give you full range of motion for carving, hiking, and adjusting gear — without the jacket riding up or bunching.',
            image: 'https://cdn.demo-shop.com/media/1366/768/0c/00/3b/1740127972/frau_handschuhe.png?ts=1740127972',
            imageAlt: 'Woman wearing winter gloves ready for the slopes',
          },
        ],
      },
      {
        name: 'Safety',
        value: 'safety',
        features: [
          {
            name: 'Helmet-compatible hood and rescue whistle',
            description: 'An adjustable, helmet-compatible hood provides full storm protection without limiting your peripheral vision. A built-in rescue whistle on the zipper pull adds a layer of backcountry safety when you need it most.',
            image: 'https://cdn.demo-shop.com/media/1366/768/c5/c0/51/1741782847/women_helmets.png?ts=1741782847',
            imageAlt: 'Women wearing ski helmets on the mountain',
          },
        ],
      },
    ],
  },
)
</script>

<template>
  <section class="mx-auto max-w-7xl px-4 py-24 sm:px-6 sm:py-32 lg:px-8">
    <div class="max-w-3xl">
      <h2 class="text-3xl font-bold tracking-tight sm:text-4xl">{{ title }}</h2>
      <p class="mt-4 text-muted-foreground">{{ description }}</p>
    </div>

    <Tabs :default-value="tabs[0]?.value" class="mt-8">
      <div class="-mx-4 overflow-x-auto sm:mx-0">
        <div class="px-4 sm:px-0">
          <TabsList variant="line">
            <TabsTrigger
              v-for="tab in tabs"
              :key="tab.value"
              :value="tab.value"
            >
              {{ tab.name }}
            </TabsTrigger>
          </TabsList>
        </div>
      </div>

      <TabsContent
        v-for="tab in tabs"
        :key="tab.value"
        :value="tab.value"
        class="mt-0 space-y-16 pt-10 lg:pt-16"
      >
        <div
          v-for="feature in tab.features"
          :key="feature.name"
          class="flex flex-col-reverse lg:grid lg:grid-cols-12 lg:gap-x-8"
        >
          <div class="mt-6 lg:col-span-5 lg:mt-0">
            <h3 class="text-lg font-medium">{{ feature.name }}</h3>
            <p class="mt-2 text-sm text-muted-foreground">{{ feature.description }}</p>
          </div>
          <div class="lg:col-span-7">
            <Image
              :src="feature.image"
              :alt="feature.imageAlt"
              class="block aspect-2/1 w-full rounded-lg sm:aspect-5/2"
            />
          </div>
        </div>
      </TabsContent>
    </Tabs>
  </section>
</template>
Tabbed product feature section with image and description per tab.
product-feature-list
Files
components/ProductList.vue
<script setup lang="ts">
import ProductCardBasic from '@/components/ProductCardBasic.vue'
import type { PriceType } from '@/lib/format'

interface Product {
  name?: string
  key?: string
  link?: { path?: string }
  price: PriceType
  cover?: { src?: string }
  rating?: number
  reviewCount?: number
  badge?: string
  clearout?: boolean
  inStore?: boolean
  siblings?: {
    items?: {
      key: string
      name?: string
      cover?: { src?: string }
      link?: { path?: string }
      colors?: {
        mainColorHex?: string
        exactColorHex?: string
        exactColorName?: string
      }
    }[]
  }
}

withDefaults(
  defineProps<{
    title?: string
    products?: Product[]
  }>(),
  {
    title: 'Customers also purchased',
    products: () => [
      {
        name: 'Basic Tee',
        key: 'product-1',
        price: { amount: 3500, currency: 'EUR' },
        cover: { src: '/placeholder.svg' },
        rating: 4.5,
      },
      {
        name: 'Basic Tee',
        key: 'product-2',
        price: { amount: 3500, currency: 'EUR' },
        cover: { src: '/placeholder.svg' },
        rating: 4.0,
      },
      {
        name: 'Basic Tee',
        key: 'product-3',
        price: { amount: 3500, currency: 'EUR' },
        cover: { src: '/placeholder.svg' },
        rating: 3.5,
      },
      {
        name: 'Artwork Tee',
        key: 'product-4',
        price: { amount: 3500, currency: 'EUR' },
        cover: { src: '/placeholder.svg' },
        rating: 4.2,
      },
    ],
  },
)
</script>

<template>
  <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
    <ProductCardBasic
      v-for="(product, index) in products"
      :key="product.key ?? index"
      :product="product"
    />
  </div>
</template>
A simple product grid with images, name, price, rating, and add-to-bag button.
product-list
Files
components/ProductOverview.vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Button } from '@/components/ui/button'
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
  CarouselDots,
} from '@/components/ui/carousel'
import { Image } from '@/components/ui/image'
import { Price } from '@/components/ui/price'
import { Rating } from '@/components/ui/rating'
import { Separator } from '@/components/ui/separator'
import { SwatchGroup, SwatchGroupItem } from '@/components/ui/swatch-group'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { PriceType } from '@/lib/format'

interface Color {
  name: string
  value: string
}

interface Size {
  name: string
  inStock: boolean
}

interface BreadcrumbEntry {
  name: string
  href: string
}

interface ProductOverviewData {
  name: string
  price: PriceType
  compareAtPrice?: PriceType
  images: string[]
  rating?: number
  reviewCount?: number
  colors?: Color[]
  sizes?: Size[]
  description?: string
  details?: string[]
  breadcrumbs?: BreadcrumbEntry[]
}

const props = withDefaults(defineProps<{
  product?: ProductOverviewData
}>(), {
  product: () => ({
    name: 'Basic Tee',
    price: { amount: 3500, currency: 'EUR' },
    images: ['/placeholder.svg', '/placeholder.svg', '/placeholder.svg'],
    rating: 3.9,
    reviewCount: 512,
    colors: [
      { name: 'Black', value: '#1A1A1A' },
      { name: 'Heather Grey', value: '#9CA3AF' },
    ],
    sizes: [
      { name: 'XXS', inStock: true },
      { name: 'XS', inStock: true },
      { name: 'S', inStock: true },
      { name: 'M', inStock: true },
      { name: 'L', inStock: true },
      { name: 'XL', inStock: false },
    ],
    description: 'The Basic tee is an honest new take on a classic. The tee uses super soft, pre-shrunk cotton for true comfort and a dependable fit. They are hand cut and sewn locally, with a special dye technique that gives each tee its own look.',
    details: [
      'Only the best materials',
      'Ethically and locally made',
      'Pre-washed and pre-shrunk',
      'Machine wash cold with similar colors',
    ],
    breadcrumbs: [
      { name: 'Women', href: '#' },
      { name: 'Clothing', href: '#' },
    ],
  }),
})

const selectedColor = ref(props.product.colors?.[0]?.value)
const selectedSize = ref(props.product.sizes?.find(s => s.inStock)?.name)
</script>

<template>
  <div class="bg-background pb-16 pt-6 sm:pb-24">
    <!-- Breadcrumbs -->
    <Breadcrumb
      v-if="product.breadcrumbs?.length"
      class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"
    >
      <BreadcrumbList>
        <template v-for="(crumb, i) in product.breadcrumbs" :key="crumb.name">
          <BreadcrumbItem>
            <BreadcrumbLink :href="crumb.href">
              {{ crumb.name }}
            </BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator v-if="i < product.breadcrumbs.length - 1" />
        </template>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbPage>{{ product.name }}</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>

    <div class="mx-auto mt-8 max-w-2xl px-4 sm:px-6 lg:max-w-7xl lg:px-8">
      <div class="lg:grid lg:auto-rows-min lg:grid-cols-12 lg:gap-x-8">
        <!-- Title + Price + Rating (right column header) -->
        <div class="lg:col-span-5 lg:col-start-8">
          <div class="flex justify-between">
            <h1 class="text-xl font-medium">{{ product.name }}</h1>
            <Price size="lg" :price="{ ...product.price, ref: product.compareAtPrice?.amount }" />
          </div>

          <div v-if="product.rating" class="mt-4">
            <Rating :rating="product.rating" size="sm">
              <template #meta>
                <a href="#" class="text-sm text-muted-foreground hover:text-foreground">
                  See all {{ product.reviewCount }} reviews
                </a>
              </template>
            </Rating>
          </div>
        </div>

        <!-- Image gallery with Carousel (left column, spans 3 rows) -->
        <div class="mt-8 lg:col-span-7 lg:col-start-1 lg:row-span-3 lg:row-start-1 lg:mt-0">
          <Carousel class="relative w-full" :opts="{ loop: true }">
            <CarouselContent>
              <CarouselItem v-for="(img, i) in product.images" :key="i">
                <Image
                  :src="img"
                  :alt="`${product.name} - Image ${i + 1}`"
                  class="w-full rounded-lg"
                />
              </CarouselItem>
            </CarouselContent>
            <CarouselPrevious class="left-4" />
            <CarouselNext class="right-4" />
            <CarouselDots />
          </Carousel>
        </div>

        <!-- Product options (right column body) -->
        <div class="mt-8 lg:col-span-5">
          <!-- Color picker -->
          <div v-if="product.colors?.length">
            <h3 class="text-sm font-medium">Color</h3>
            <SwatchGroup
              v-model="selectedColor"
              type="single"
              class="mt-3"
            >
              <SwatchGroupItem
                v-for="color in product.colors"
                :key="color.name"
                :value="color.value"
                :hex="color.value"
                :label="color.name"
                :aria-label="color.name"
              />
            </SwatchGroup>
          </div>

          <!-- Size picker -->
          <div v-if="product.sizes?.length" class="mt-8">
            <div class="flex items-center justify-between">
              <h3 class="text-sm font-medium">Size</h3>
              <a href="#" class="text-sm font-medium text-primary hover:text-primary/80">
                See sizing chart
              </a>
            </div>
            <ToggleGroup
              v-model="selectedSize"
              type="single"
              variant="outline"
              :spacing="3"
              class="mt-3 grid w-full grid-cols-3 sm:grid-cols-6"
            >
              <ToggleGroupItem
                v-for="size in product.sizes"
                :key="size.name"
                :value="size.name"
                :disabled="!size.inStock"
                class="relative uppercase"
              >
                {{ size.name }}
                <svg
                  v-if="!size.inStock"
                  class="pointer-events-none absolute inset-0 size-full stroke-muted-foreground/40"
                  viewBox="0 0 100 100"
                  preserveAspectRatio="none"
                  stroke-width="1"
                >
                  <line x1="0" y1="100" x2="100" y2="0" vector-effect="non-scaling-stroke" />
                </svg>
              </ToggleGroupItem>
            </ToggleGroup>
          </div>

          <Button class="mt-8 w-full" size="lg" color="buy">
            Add to cart
          </Button>

          <!-- Description -->
          <div v-if="product.description" class="mt-10">
            <h3 class="text-sm font-medium">Description</h3>
            <p class="mt-4 text-sm leading-relaxed text-muted-foreground">
              {{ product.description }}
            </p>
          </div>

          <!-- Fabric & Care -->
          <div v-if="product.details?.length" class="mt-8">
            <Separator />
            <div class="pt-8">
              <h3 class="text-sm font-medium">Fabric &amp; Care</h3>
              <ul class="mt-4 list-disc space-y-1.5 pl-5 text-sm text-muted-foreground marker:text-muted-foreground/50">
                <li v-for="(detail, i) in product.details" :key="i" class="pl-2">
                  {{ detail }}
                </li>
              </ul>
            </div>
          </div>

          <!-- Policies -->
          <section class="mt-10">
            <h3 class="sr-only">Policies</h3>
            <dl class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
              <div class="rounded-lg border bg-muted/40 p-6 text-center">
                <dt>
                  <svg xmlns="http://www.w3.org/2000/svg" class="mx-auto size-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.467.729-3.56" />
                  </svg>
                  <span class="mt-4 block text-sm font-medium">International delivery</span>
                </dt>
                <dd class="mt-1 text-sm text-muted-foreground">Get your order in 2-8 days</dd>
              </div>
              <div class="rounded-lg border bg-muted/40 p-6 text-center">
                <dt>
                  <svg xmlns="http://www.w3.org/2000/svg" class="mx-auto size-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
                  </svg>
                  <span class="mt-4 block text-sm font-medium">Loyalty rewards</span>
                </dt>
                <dd class="mt-1 text-sm text-muted-foreground">Earn points with every purchase</dd>
              </div>
            </dl>
          </section>
        </div>
      </div>
    </div>
  </div>
</template>
A product overview with tiered image gallery, breadcrumbs, color and size pickers, rating, and policy cards.
product-overview