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/CartCompact.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/ui/sheet'
import { Price } from '@/components/ui/price'
import { ShoppingBagIcon } from 'lucide-vue-next'
import CartItemCompact from '@/components/CartItemCompact.vue'

interface CartItemData {
  id: string
  name: string
  variant: string
  price: { amount: number; currency: string }
  quantity: number
  image: string
}

const props = withDefaults(
  defineProps<{
    initialItems?: CartItemData[]
    freeShippingThreshold?: number
    currency?: string
  }>(),
  {
    initialItems: undefined,
    freeShippingThreshold: 15000,
    currency: 'EUR',
  },
)

const defaultItems: CartItemData[] = [
  {
    id: '1',
    name: 'Classic Leather Sneakers',
    variant: 'White / Size 42',
    price: { amount: 12999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
  {
    id: '2',
    name: 'Organic Cotton T-Shirt',
    variant: 'Navy / Size M',
    price: { amount: 3999, currency: props.currency },
    quantity: 2,
    image: '/placeholder.svg',
  },
  {
    id: '3',
    name: 'Slim Fit Chinos',
    variant: 'Khaki / Size 32',
    price: { amount: 7999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
]

const items = ref<CartItemData[]>(props.initialItems ?? defaultItems)

const itemCount = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0))
const subtotalCents = computed(() =>
  items.value.reduce((sum, item) => sum + item.price.amount * item.quantity, 0),
)

const shippingProgress = computed(() =>
  Math.min((subtotalCents.value / props.freeShippingThreshold) * 100, 100),
)
const amountUntilFreeShipping = computed(() =>
  Math.max(props.freeShippingThreshold - subtotalCents.value, 0),
)
</script>

<template>
  <Sheet>
    <SheetTrigger as-child>
      <Button variant="ghost" size="icon" class="relative">
        <ShoppingBagIcon class="size-5" />
        <Badge
          v-if="itemCount > 0"
          class="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full p-0 text-[10px]"
        >
          {{ itemCount }}
        </Badge>
        <span class="sr-only">Open cart</span>
      </Button>
    </SheetTrigger>

    <SheetContent side="right" class="flex w-full flex-col sm:max-w-md" :show-close-button="false">
      <SheetHeader class="border-b pb-4">
        <div class="flex items-center justify-between">
          <SheetTitle class="text-lg">Cart ({{ itemCount }})</SheetTitle>
          <SheetClose as-child>
            <Button variant="ghost" size="icon-sm">
              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
              <span class="sr-only">Close</span>
            </Button>
          </SheetClose>
        </div>
      </SheetHeader>

      <!-- Free shipping progress -->
      <div v-if="items.length" class="px-6">
        <div v-if="amountUntilFreeShipping > 0" class="text-center text-sm text-muted-foreground">
          Add
          <Price class="font-medium text-foreground" :price="{ amount: amountUntilFreeShipping, currency }" />
          more for free shipping
        </div>
        <div v-else class="text-center text-sm font-medium text-positive">
          You qualify for free shipping!
        </div>
        <div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted">
          <div
            class="h-full rounded-full bg-primary transition-all duration-300"
            :style="{ width: `${shippingProgress}%` }"
          />
        </div>
      </div>

      <!-- Cart items -->
      <div class="flex-1 overflow-y-auto px-6">
        <CartItemCompact v-if="items.length" v-model:items="items" :currency="currency" />

        <!-- Empty state -->
        <div v-else class="flex flex-col items-center justify-center py-16 text-center">
          <ShoppingBagIcon class="size-12 text-muted-foreground/50" />
          <p class="mt-4 text-lg font-medium">Your cart is empty</p>
          <p class="mt-1 text-sm text-muted-foreground">
            Add some items to get started.
          </p>
          <SheetClose as-child>
            <Button variant="outline" class="mt-4">
              Continue Shopping
            </Button>
          </SheetClose>
        </div>
      </div>

      <!-- Footer with subtotal and CTAs -->
      <SheetFooter v-if="items.length" class="border-t">
        <div class="flex w-full flex-col gap-3">
          <Separator class="hidden" />
          <div class="flex items-center justify-between text-sm">
            <span class="text-muted-foreground">Subtotal</span>
            <Price class="text-base font-semibold" :price="{ amount: subtotalCents, currency }" />
          </div>
          <p class="text-xs text-muted-foreground">Shipping and taxes calculated at checkout.</p>
          <Button class="w-full" color="checkout">
            Checkout
          </Button>
          <SheetClose as-child>
            <Button variant="outline" class="w-full">
              Continue Shopping
            </Button>
          </SheetClose>
        </div>
      </SheetFooter>
    </SheetContent>
  </Sheet>
</template>
A slide-over drawer mini-cart with quantity controls, subtotal, and checkout CTA.
cart-compact
Files
components/CartFull.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Price } from '@/components/ui/price'
import CartItemDetailed from '@/components/CartItemDetailed.vue'

interface CartItemData {
  id: string
  name: string
  variant: string
  price: { amount: number; currency: string }
  quantity: number
  image: string
}

const props = withDefaults(
  defineProps<{
    initialItems?: CartItemData[]
    currency?: string
  }>(),
  {
    initialItems: undefined,
    currency: 'EUR',
  },
)

const defaultItems: CartItemData[] = [
  {
    id: '1',
    name: 'Classic Leather Sneakers',
    variant: 'White / Size 42',
    price: { amount: 12999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
  {
    id: '2',
    name: 'Organic Cotton T-Shirt',
    variant: 'Navy / Size M',
    price: { amount: 3999, currency: props.currency },
    quantity: 2,
    image: '/placeholder.svg',
  },
  {
    id: '3',
    name: 'Slim Fit Chinos',
    variant: 'Khaki / Size 32',
    price: { amount: 7999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
]

const items = ref<CartItemData[]>(props.initialItems ?? defaultItems)

const subtotalCents = computed(() =>
  items.value.reduce((sum, item) => sum + item.price.amount * item.quantity, 0),
)

const shippingCents = computed(() => (subtotalCents.value > 10000 ? 0 : 599))
const totalCents = computed(() => subtotalCents.value + shippingCents.value)
</script>

<template>
  <div>
    <h1 class="mb-6 text-2xl font-semibold">
      Shopping Cart ({{ items.length }})
    </h1>

    <div v-if="items.length" class="grid gap-6 md:grid-cols-[1fr_320px]">
      <!-- Items -->
      <CartItemDetailed v-model:items="items" :currency="currency" />

      <!-- Summary -->
      <Card class="h-fit">
        <CardHeader>
          <CardTitle>Order Summary</CardTitle>
        </CardHeader>
        <CardContent class="space-y-3 text-sm">
          <div class="flex justify-between">
            <span class="text-muted-foreground">Subtotal</span>
            <Price :price="{ amount: subtotalCents, currency }" />
          </div>
          <div class="flex justify-between">
            <span class="text-muted-foreground">Shipping</span>
            <span v-if="shippingCents === 0">Free</span>
            <Price v-else :price="{ amount: shippingCents, currency }" />
          </div>
          <div class="border-t pt-3">
            <div class="flex justify-between text-base font-semibold">
              <span>Total</span>
              <Price :price="{ amount: totalCents, currency }" />
            </div>
          </div>
        </CardContent>
        <CardFooter class="flex flex-col gap-2">
          <Button class="w-full" color="checkout">
            Checkout
          </Button>
          <Button variant="link" class="w-full">
            Continue Shopping
          </Button>
        </CardFooter>
      </Card>
    </div>

    <!-- Empty State -->
    <div v-else class="py-20 text-center">
      <p class="text-4xl font-semibold">
        Your cart is empty
      </p>
      <p class="mt-2 text-muted-foreground">
        Add some items to get started.
      </p>
      <Button class="mt-6">
        Continue Shopping
      </Button>
    </div>
  </div>
</template>
A shopping cart page with item list, quantity controls, and order summary.
cart-full
Files
components/CartItemCompact.vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Button } from '@/components/ui/button'
import {
  NumberField,
  NumberFieldContent,
  NumberFieldDecrement,
  NumberFieldIncrement,
  NumberFieldInput,
} from '@/components/ui/number-field'
import { Price } from '@/components/ui/price'
import { Trash2Icon } from 'lucide-vue-next'

interface CartItem {
  id: string
  name: string
  variant: string
  price: { amount: number; currency: string }
  quantity: number
  image: string
}

const props = withDefaults(
  defineProps<{
    items?: CartItem[]
    currency?: string
  }>(),
  {
    items: undefined,
    currency: 'EUR',
  },
)

const emit = defineEmits<{
  'update:items': [items: CartItem[]]
}>()

const defaultItems: CartItem[] = [
  {
    id: '1',
    name: 'Classic Leather Sneakers',
    variant: 'White / Size 42',
    price: { amount: 12999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
  {
    id: '2',
    name: 'Organic Cotton T-Shirt',
    variant: 'Navy / Size M',
    price: { amount: 3999, currency: props.currency },
    quantity: 2,
    image: '/placeholder.svg',
  },
  {
    id: '3',
    name: 'Slim Fit Chinos',
    variant: 'Khaki / Size 32',
    price: { amount: 7999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
]

const internalItems = ref<CartItem[]>(defaultItems)

const currentItems = computed(() => props.items ?? internalItems.value)

function updateItems(newItems: CartItem[]) {
  if (props.items) {
    emit('update:items', newItems)
  } else {
    internalItems.value = newItems
  }
}

function updateQuantity(id: string, qty: number) {
  updateItems(currentItems.value.map(i => i.id === id ? { ...i, quantity: qty } : i))
}

function removeItem(id: string) {
  updateItems(currentItems.value.filter(i => i.id !== id))
}
</script>

<template>
  <div class="space-y-4">
    <div
      v-for="item in currentItems"
      :key="item.id"
      class="flex gap-3"
    >
      <img
        :src="item.image"
        :alt="item.name"
        class="size-20 rounded-md border object-cover"
      />
      <div class="flex flex-1 flex-col justify-between py-0.5">
        <div>
          <h3 class="text-sm font-medium leading-tight">{{ item.name }}</h3>
          <p class="mt-0.5 text-xs text-muted-foreground">{{ item.variant }}</p>
        </div>
        <div class="flex items-center justify-between">
          <NumberField
            :default-value="item.quantity"
            :min="1"
            :max="10"
            class="w-24"
            @update:model-value="(val: number) => updateQuantity(item.id, val)"
          >
            <NumberFieldContent>
              <NumberFieldDecrement />
              <NumberFieldInput />
              <NumberFieldIncrement />
            </NumberFieldContent>
          </NumberField>
          <div class="flex items-center gap-2">
            <Price class="text-sm font-medium" :price="{ amount: item.price.amount * item.quantity, currency }" />
            <Button
              variant="ghost"
              size="icon-sm"
              class="text-muted-foreground hover:text-destructive"
              @click="removeItem(item.id)"
            >
              <Trash2Icon class="size-4" />
              <span class="sr-only">Remove {{ item.name }}</span>
            </Button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
Cart line items with NumberField quantity controls and trash icon.
cart-item-compact
Files
components/CartItemDetailed.vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Price } from '@/components/ui/price'
import { Trash2Icon } from 'lucide-vue-next'

interface CartItem {
  id: string
  name: string
  variant: string
  price: { amount: number; currency: string }
  quantity: number
  image: string
}

const props = withDefaults(
  defineProps<{
    items?: CartItem[]
    currency?: string
  }>(),
  {
    items: undefined,
    currency: 'EUR',
  },
)

const emit = defineEmits<{
  'update:items': [items: CartItem[]]
}>()

const defaultItems: CartItem[] = [
  {
    id: '1',
    name: 'Classic Leather Sneakers',
    variant: 'White / Size 42',
    price: { amount: 12999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
  {
    id: '2',
    name: 'Organic Cotton T-Shirt',
    variant: 'Navy / Size M',
    price: { amount: 3999, currency: props.currency },
    quantity: 2,
    image: '/placeholder.svg',
  },
  {
    id: '3',
    name: 'Slim Fit Chinos',
    variant: 'Khaki / Size 32',
    price: { amount: 7999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
]

const internalItems = ref<CartItem[]>(defaultItems)

const currentItems = computed(() => props.items ?? internalItems.value)

function updateItems(newItems: CartItem[]) {
  if (props.items) {
    emit('update:items', newItems)
  } else {
    internalItems.value = newItems
  }
}

function updateQuantity(id: string, qty: number) {
  if (qty < 1 || qty > 10) return
  updateItems(currentItems.value.map(i => i.id === id ? { ...i, quantity: qty } : i))
}

function removeItem(id: string) {
  updateItems(currentItems.value.filter(i => i.id !== id))
}
</script>

<template>
  <div class="space-y-4">
    <div
      v-for="item in currentItems"
      :key="item.id"
      class="flex gap-4 rounded-lg border p-4"
    >
      <img
        :src="item.image"
        :alt="item.name"
        class="size-24 rounded-md object-cover md:size-28"
      />
      <div class="flex flex-1 flex-col justify-between">
        <div class="flex items-start justify-between">
          <div>
            <h3 class="text-sm font-medium">{{ item.name }}</h3>
            <p class="text-xs text-muted-foreground">{{ item.variant }}</p>
          </div>
          <Button
            variant="ghost"
            size="icon-sm"
            class="text-muted-foreground hover:text-destructive"
            @click="removeItem(item.id)"
          >
            <Trash2Icon class="size-4" />
            <span class="sr-only">Remove {{ item.name }}</span>
          </Button>
        </div>
        <div class="flex items-center justify-between">
          <div class="flex items-center gap-2">
            <Button
              variant="outline"
              icon
              class="size-8"
              :disabled="item.quantity <= 1"
              @click="updateQuantity(item.id, item.quantity - 1)"
            >

            </Button>
            <span class="w-8 text-center text-sm">{{ item.quantity }}</span>
            <Button
              variant="outline"
              icon
              class="size-8"
              :disabled="item.quantity >= 10"
              @click="updateQuantity(item.id, item.quantity + 1)"
            >
              +
            </Button>
          </div>
          <Price class="text-sm font-medium" :price="{ amount: item.price.amount * item.quantity, currency }" />
        </div>
      </div>
    </div>
  </div>
</template>
Cart line items with large thumbnails, quantity buttons, and remove action.
cart-item-detailed
Files
components/CartItemMinimal.vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Price } from '@/components/ui/price'

interface CartItem {
  id: string
  name: string
  variant: string
  price: { amount: number; currency: string }
  quantity: number
  image: string
}

const props = withDefaults(
  defineProps<{
    items?: CartItem[]
    currency?: string
  }>(),
  {
    items: undefined,
    currency: 'EUR',
  },
)

const defaultItems: CartItem[] = [
  {
    id: '1',
    name: 'Classic Leather Sneakers',
    variant: 'White / Size 42',
    price: { amount: 12999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
  {
    id: '2',
    name: 'Organic Cotton T-Shirt',
    variant: 'Navy / Size M',
    price: { amount: 3999, currency: props.currency },
    quantity: 2,
    image: '/placeholder.svg',
  },
  {
    id: '3',
    name: 'Slim Fit Chinos',
    variant: 'Khaki / Size 32',
    price: { amount: 7999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
]

const internalItems = ref<CartItem[]>(defaultItems)

const currentItems = computed(() => props.items ?? internalItems.value)
</script>

<template>
  <div class="flex flex-col gap-3">
    <div
      v-for="item in currentItems"
      :key="item.id"
      class="flex gap-3"
    >
      <img
        :src="item.image"
        :alt="item.name"
        class="size-12 shrink-0 rounded-md border object-cover"
      />
      <div class="flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
        <h3 class="truncate text-sm font-medium leading-tight">{{ item.name }}</h3>
        <div class="flex items-center justify-between">
          <span class="text-xs text-muted-foreground">Qty: {{ item.quantity }}</span>
          <Price class="text-sm" :price="{ amount: item.price.amount * item.quantity, currency }" />
        </div>
      </div>
    </div>
  </div>
</template>
Compact cart line items with small thumbnails and quantity text.
cart-item-minimal
Files
components/CartMinimal.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover'
import { Price } from '@/components/ui/price'
import { ShoppingBagIcon } from 'lucide-vue-next'
import CartItemMinimal from '@/components/CartItemMinimal.vue'

interface CartItemData {
  id: string
  name: string
  variant: string
  price: { amount: number; currency: string }
  quantity: number
  image: string
}

const props = withDefaults(
  defineProps<{
    initialItems?: CartItemData[]
    currency?: string
    maxVisibleItems?: number
  }>(),
  {
    initialItems: undefined,
    currency: 'EUR',
    maxVisibleItems: 3,
  },
)

const defaultItems: CartItemData[] = [
  {
    id: '1',
    name: 'Classic Leather Sneakers',
    variant: 'White / Size 42',
    price: { amount: 12999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
  {
    id: '2',
    name: 'Organic Cotton T-Shirt',
    variant: 'Navy / Size M',
    price: { amount: 3999, currency: props.currency },
    quantity: 2,
    image: '/placeholder.svg',
  },
  {
    id: '3',
    name: 'Slim Fit Chinos',
    variant: 'Khaki / Size 32',
    price: { amount: 7999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
  {
    id: '4',
    name: 'Wool Blend Scarf',
    variant: 'Grey',
    price: { amount: 2999, currency: props.currency },
    quantity: 1,
    image: '/placeholder.svg',
  },
]

const items = ref<CartItemData[]>(props.initialItems ?? defaultItems)

const itemCount = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0))
const subtotalCents = computed(() =>
  items.value.reduce((sum, item) => sum + item.price.amount * item.quantity, 0),
)
const visibleItems = computed(() => items.value.slice(0, props.maxVisibleItems))
const hiddenItemCount = computed(() => Math.max(items.value.length - props.maxVisibleItems, 0))
</script>

<template>
  <Popover>
    <PopoverTrigger as-child>
      <Button variant="ghost" size="icon" class="relative">
        <ShoppingBagIcon class="size-5" />
        <Badge
          v-if="itemCount > 0"
          class="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full p-0 text-[10px]"
        >
          {{ itemCount }}
        </Badge>
        <span class="sr-only">Open cart</span>
      </Button>
    </PopoverTrigger>

    <PopoverContent align="end" class="w-80 p-0">
      <!-- Header -->
      <div class="flex items-center justify-between px-4 pt-4 pb-2">
        <span class="text-sm font-semibold">Cart ({{ itemCount }})</span>
      </div>

      <!-- Items -->
      <div v-if="items.length" class="px-4 pb-3">
        <CartItemMinimal :items="visibleItems" :currency="currency" />

        <p v-if="hiddenItemCount > 0" class="mt-3 text-xs text-muted-foreground">
          + {{ hiddenItemCount }} more {{ hiddenItemCount === 1 ? 'item' : 'items' }}
        </p>
      </div>

      <!-- Empty state -->
      <div v-else class="flex flex-col items-center justify-center px-4 py-8 text-center">
        <ShoppingBagIcon class="size-8 text-muted-foreground/50" />
        <p class="mt-2 text-sm font-medium">Your cart is empty</p>
        <p class="mt-0.5 text-xs text-muted-foreground">
          Add some items to get started.
        </p>
      </div>

      <!-- Footer -->
      <template v-if="items.length">
        <Separator />
        <div class="flex flex-col gap-2 p-4">
          <div class="flex items-center justify-between">
            <span class="text-sm text-muted-foreground">Subtotal</span>
            <Price class="font-semibold" :price="{ amount: subtotalCents, currency }" />
          </div>
          <p class="text-xs text-muted-foreground">Shipping and taxes calculated at checkout.</p>
          <div class="mt-1 flex flex-col gap-2">
            <Button class="w-full" color="checkout" size="sm">
              Checkout
            </Button>
            <Button variant="outline" class="w-full" size="sm">
              View Cart
            </Button>
          </div>
        </div>
      </template>
    </PopoverContent>
  </Popover>
</template>
A popover dropdown cart with compact item list, subtotal, and checkout CTAs.
cart-minimal