Cart Drawer
blockcommerceShopping cart drawer & full cart page view featuring free shipping progress meter, interactive line items with quantity steppers and variant tags, promo code application, order summary with tax & shipping calculation, trust badges, and empty state.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/cart-drawer.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/cart-drawer.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/cart-drawer.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/cart-drawer.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/cart-drawerInstalls to:app/components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | 'drawer''page' | 'drawer' | optional |
items | CartItem[] | () => DEFAULT_ITEMS, currency: 'USD', freeShippingThresho… | optional |
currency | string | — | optional |
freeShippingThreshold | number | — | optional |
taxRate | number | — | optional |
initialPromoCode | string | — | optional |
class | HTMLAttributes['class'] | — | optional |
Files installed (1)
app/components/blocks/CartDrawer.vue27.6 kB
<script lang="ts"> export interface CartItem { id: string name: string variant: string price: number originalPrice?: number qty: number inStock?: boolean image: string } export const DEFAULT_ITEMS: CartItem[] = [ { id: 'item-1', name: 'Aero Minimalist Runner', variant: 'Size: 10.5 · Color: Matte Black', price: 120, originalPrice: 140, qty: 1, inStock: true, image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=300&auto=format&fit=crop&q=80', }, { id: 'item-2', name: 'Technical Shell Parka', variant: 'Size: L · Color: Mineral Gray', price: 95, originalPrice: 110, qty: 1, inStock: true, image: 'https://images.unsplash.com/photo-1591047139829-d91aecb6caea?w=300&auto=format&fit=crop&q=80', }, { id: 'item-3', name: 'Minimalist Leather Cardholder', variant: 'Size: Slim · Color: Saddle Brown', price: 70, originalPrice: 85, qty: 1, inStock: true, image: 'https://images.unsplash.com/photo-1627123424574-724758594e93?w=300&auto=format&fit=crop&q=80', }, ] </script> <script setup lang="ts"> import { computed, ref, watch } from 'vue' import type { HTMLAttributes } from 'vue' import { Leaf, Lock, Minus, Plus, RotateCcw, ShieldCheck, ShoppingBag, Tag, Trash2, Truck, X } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Progress } from '@/components/ui/progress' import { Separator } from '@/components/ui/separator' interface Props { variant?: 'drawer' | 'page' items?: CartItem[] currency?: string freeShippingThreshold?: number taxRate?: number initialPromoCode?: string class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { variant: 'drawer', items: () => DEFAULT_ITEMS, currency: 'USD', freeShippingThreshold: 300, taxRate: 0.08, initialPromoCode: '', }) const emit = defineEmits<{ checkout: [items: CartItem[], total: number] continueShopping: [] close: [] updateQuantity: [id: string, qty: number] removeItem: [id: string] applyPromo: [code: string] removePromo: [] }>() const cartItems = ref<CartItem[]>(props.items.map((item) => ({ ...item }))) const promoCodeInput = ref('') const appliedPromo = ref<string>(props.initialPromoCode) const promoDiscount = ref<number>(props.initialPromoCode ? 20 : 0) const promoError = ref('') const promoSuccess = ref('') watch( () => props.items, (newItems) => { cartItems.value = newItems.map((item) => ({ ...item })) }, { deep: true }, ) const totalItemsCount = computed(() => cartItems.value.reduce((acc, item) => acc + item.qty, 0)) const subtotal = computed(() => cartItems.value.reduce((acc, item) => acc + item.price * item.qty, 0)) const isFreeShippingUnlocked = computed( () => subtotal.value >= props.freeShippingThreshold && cartItems.value.length > 0, ) const amountAwayFromFreeShipping = computed(() => Math.max(0, props.freeShippingThreshold - subtotal.value)) const shippingProgress = computed(() => { if (cartItems.value.length === 0) return 0 return Math.min(100, Math.round((subtotal.value / props.freeShippingThreshold) * 100)) }) const shippingFee = computed(() => { if (cartItems.value.length === 0) return 0 return isFreeShippingUnlocked.value ? 0 : 15 }) const discountedSubtotal = computed(() => Math.max(0, subtotal.value - promoDiscount.value)) const estimatedTax = computed(() => { if (cartItems.value.length === 0) return 0 return Number((discountedSubtotal.value * props.taxRate).toFixed(2)) }) const total = computed(() => { if (cartItems.value.length === 0) return 0 return Number((discountedSubtotal.value + shippingFee.value + estimatedTax.value).toFixed(2)) }) function formatCurrency(amount: number) { try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: props.currency, minimumFractionDigits: 2, }).format(amount) } catch { return `$${amount.toFixed(2)}` } } function updateQuantity(id: string, delta: number) { const item = cartItems.value.find((i) => i.id === id) if (!item) return const newQty = item.qty + delta if (newQty <= 0) { removeItem(id) } else { item.qty = newQty emit('updateQuantity', id, newQty) } } function removeItem(id: string) { cartItems.value = cartItems.value.filter((i) => i.id !== id) emit('removeItem', id) } function handleApplyPromo() { promoError.value = '' promoSuccess.value = '' const trimmed = promoCodeInput.value.trim().toUpperCase() if (!trimmed) return if (trimmed === 'SUMMER20' || trimmed === 'SAVE20') { appliedPromo.value = trimmed promoDiscount.value = 20 promoSuccess.value = '$20.00 coupon discount applied!' promoCodeInput.value = '' emit('applyPromo', trimmed) } else if (trimmed === 'SAVE10') { appliedPromo.value = trimmed promoDiscount.value = 10 promoSuccess.value = '$10.00 coupon discount applied!' promoCodeInput.value = '' emit('applyPromo', trimmed) } else { appliedPromo.value = trimmed promoDiscount.value = 15 promoSuccess.value = 'Promo code applied!' promoCodeInput.value = '' emit('applyPromo', trimmed) } } function handleRemovePromo() { appliedPromo.value = '' promoDiscount.value = 0 promoSuccess.value = '' promoError.value = '' emit('removePromo') } function handleCheckout() { emit('checkout', cartItems.value, total.value) } function handleContinueShopping() { emit('continueShopping') } function handleClose() { emit('close') } function resetCart() { cartItems.value = DEFAULT_ITEMS.map((item) => ({ ...item })) } </script> <template> <div data-slot="cart-drawer" :class="cn('w-full', props.class)"> <!-- DRAWER VARIANT --> <div v-if="variant === 'drawer'" class="bg-card text-card-foreground border-border/80 mx-auto flex h-full max-h-[820px] min-h-[640px] w-full max-w-md flex-col overflow-hidden rounded-2xl border shadow-xs" > <!-- Drawer Header --> <div class="border-border/60 flex shrink-0 items-center justify-between border-b px-5 py-4"> <div class="flex items-center gap-2"> <h2 class="text-base font-semibold tracking-tight">Shopping Cart</h2> <Badge v-if="totalItemsCount > 0" variant="secondary" class="tabular-nums"> {{ totalItemsCount }} </Badge> </div> <Button variant="ghost" size="icon-sm" aria-label="Close cart drawer" @click="handleClose"> <X class="size-4" /> </Button> </div> <!-- Free Shipping Meter --> <div v-if="cartItems.length > 0" class="bg-muted/30 border-border/40 shrink-0 border-b px-5 py-3.5"> <div class="flex items-center justify-between gap-2 text-xs"> <span v-if="isFreeShippingUnlocked" class="text-primary flex items-center gap-1.5 font-medium"> <Truck class="size-3.5" /> You've unlocked Free Express Shipping! </span> <span v-else class="text-muted-foreground"> 🎉 You're <strong class="text-foreground font-semibold tabular-nums">{{ formatCurrency(amountAwayFromFreeShipping) }}</strong> away from Free Shipping! </span> <span class="text-muted-foreground font-medium tabular-nums">{{ shippingProgress }}%</span> </div> <Progress :model-value="shippingProgress" class="mt-2 h-1.5" /> </div> <!-- Drawer Body: Line Items or Empty State --> <div class="flex-1 overflow-y-auto p-5"> <div v-if="cartItems.length === 0" class="flex h-full flex-col items-center justify-center py-12 text-center"> <div class="bg-muted text-muted-foreground mb-4 flex size-14 items-center justify-center rounded-full"> <ShoppingBag class="size-7 stroke-[1.5]" /> </div> <h3 class="text-base font-semibold">Your cart is empty</h3> <p class="text-muted-foreground mt-1 max-w-xs text-xs leading-relaxed"> Looks like you haven't added any products to your cart yet. Explore our latest items! </p> <Button class="mt-5 gap-2" size="sm" @click="resetCart"> <ShoppingBag class="size-3.5" /> Explore Products </Button> </div> <div v-else class="space-y-4"> <ul class="divide-border/60 divide-y" role="list"> <li v-for="item in cartItems" :key="item.id" class="flex gap-3.5 py-4 first:pt-0 last:pb-0"> <div class="border-border/60 bg-muted/40 relative size-20 shrink-0 overflow-hidden rounded-lg border shadow-xs" > <img :src="item.image" :alt="item.name" class="size-full object-cover" /> </div> <div class="flex min-w-0 flex-1 flex-col justify-between"> <div> <div class="flex items-start justify-between gap-2"> <h3 class="text-foreground truncate text-sm leading-snug font-medium"> {{ item.name }} </h3> <Button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-destructive -mt-1 -mr-1.5 size-7 shrink-0" :aria-label="`Remove ${item.name} from cart`" @click="removeItem(item.id)" > <Trash2 class="size-3.5" /> </Button> </div> <p class="text-muted-foreground mt-0.5 text-xs"> {{ item.variant }} </p> <div class="mt-1 flex items-center gap-1.5"> <span class="bg-success size-1.5 rounded-full"></span> <span class="text-success text-xs font-medium">In stock</span> </div> </div> <div class="mt-2.5 flex items-center justify-between"> <div class="border-border/80 bg-background flex items-center rounded-md border shadow-xs"> <Button variant="ghost" size="icon-sm" class="size-7 rounded-none rounded-l-md" :aria-label="`Decrease quantity for ${item.name}`" @click="updateQuantity(item.id, -1)" > <Minus class="size-3" /> </Button> <span class="w-7 text-center text-xs font-medium tabular-nums">{{ item.qty }}</span> <Button variant="ghost" size="icon-sm" class="size-7 rounded-none rounded-r-md" :aria-label="`Increase quantity for ${item.name}`" @click="updateQuantity(item.id, 1)" > <Plus class="size-3" /> </Button> </div> <div class="text-right"> <span v-if="item.originalPrice" class="text-muted-foreground mr-1.5 text-xs tabular-nums line-through" > {{ formatCurrency(item.originalPrice * item.qty) }} </span> <span class="text-foreground text-sm font-semibold tabular-nums"> {{ formatCurrency(item.price * item.qty) }} </span> </div> </div> </div> </li> </ul> <Separator class="my-3" /> <!-- Promo Code Section --> <div class="space-y-2"> <div v-if="appliedPromo" class="bg-primary/5 border-primary/20 flex items-center justify-between rounded-lg border px-3 py-2 text-xs" > <div class="flex items-center gap-2"> <Tag class="text-primary size-3.5" /> <span class="text-foreground font-medium">{{ appliedPromo }}</span> <span class="text-success text-success font-semibold tabular-nums"> -{{ formatCurrency(promoDiscount) }} </span> </div> <Button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-foreground size-6" aria-label="Remove promo code" @click="handleRemovePromo" > <X class="size-3.5" /> </Button> </div> <div v-else class="flex gap-2"> <Input v-model="promoCodeInput" placeholder="Promo code (e.g. SUMMER20)" size="small" class="text-xs" @keydown.enter.prevent="handleApplyPromo" /> <Button variant="outline" size="sm" class="shrink-0 text-xs" :disabled="!promoCodeInput.trim()" @click="handleApplyPromo" > Apply </Button> </div> <p v-if="promoSuccess" class="text-success text-xs font-medium"> {{ promoSuccess }} </p> <p v-if="promoError" class="text-destructive text-xs"> {{ promoError }} </p> </div> </div> </div> <!-- Drawer Footer: Summary & Actions --> <div v-if="cartItems.length > 0" class="border-border/60 bg-card shrink-0 space-y-3 border-t p-5"> <div class="space-y-1.5 text-xs"> <div class="flex justify-between"> <span class="text-muted-foreground">Subtotal</span> <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(subtotal) }}</span> </div> <div v-if="promoDiscount > 0" class="text-success flex justify-between"> <span>Coupon ({{ appliedPromo }})</span> <span class="font-medium tabular-nums">-{{ formatCurrency(promoDiscount) }}</span> </div> <div class="flex justify-between"> <span class="text-muted-foreground">Estimated Shipping</span> <span v-if="shippingFee === 0" class="text-success font-medium">Free</span> <span v-else class="text-foreground font-medium tabular-nums">{{ formatCurrency(shippingFee) }}</span> </div> <div class="flex justify-between"> <span class="text-muted-foreground">Estimated Tax</span> <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(estimatedTax) }}</span> </div> <Separator class="my-2" /> <div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 text-sm"> <span class="font-semibold">Total</span> <span class="font-semibold tabular-nums">{{ formatCurrency(total) }}</span> </div> </div> <div class="space-y-2 pt-1"> <Button class="w-full justify-center gap-2 font-medium shadow-xs" size="default" @click="handleCheckout"> <Lock class="size-3.5" /> Proceed to Checkout </Button> <Button variant="ghost" class="text-muted-foreground hover:text-foreground w-full text-xs" size="sm" @click="handleContinueShopping" > Continue Shopping </Button> </div> <!-- Guarantee badges --> <div class="border-border/40 text-muted-foreground grid grid-cols-1 gap-2 border-t pt-3 text-center text-xs sm:grid-cols-3" > <div class="flex flex-col items-center gap-1"> <RotateCcw class="size-3.5" /> <span>30-day returns</span> </div> <div class="flex flex-col items-center gap-1"> <ShieldCheck class="size-3.5" /> <span>256-bit secure</span> </div> <div class="flex flex-col items-center gap-1"> <Leaf class="size-3.5" /> <span>Carbon neutral</span> </div> </div> </div> </div> <!-- FULL PAGE VARIANT --> <div v-else class="mx-auto w-full max-w-6xl space-y-6"> <!-- Page Header --> <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <div> <h1 class="text-2xl font-bold tracking-tight">Shopping Cart</h1> <p class="text-muted-foreground mt-0.5 text-xs"> Review your selected items and calculate shipping before checkout. </p> </div> <Badge v-if="totalItemsCount > 0" variant="outline" class="w-fit text-xs tabular-nums"> {{ totalItemsCount }} items in cart </Badge> </div> <!-- Free Shipping Banner --> <div v-if="cartItems.length > 0" class="bg-card border-border/80 rounded-xl border p-4 shadow-xs"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="flex items-center gap-2 text-xs"> <Truck v-if="isFreeShippingUnlocked" class="text-primary size-4" /> <span v-if="isFreeShippingUnlocked" class="text-primary font-semibold"> You've unlocked Free Express Shipping on this order! </span> <span v-else class="text-muted-foreground"> 🎉 You're <strong class="text-foreground font-semibold tabular-nums">{{ formatCurrency(amountAwayFromFreeShipping) }}</strong> away from Free Shipping! </span> </div> <span class="text-muted-foreground text-xs font-medium tabular-nums">{{ shippingProgress }}% completed</span> </div> <Progress :model-value="shippingProgress" class="mt-2.5 h-2" /> </div> <!-- Empty State for Page --> <div v-if="cartItems.length === 0" class="bg-card border-border/80 flex flex-col items-center justify-center rounded-2xl border py-16 text-center shadow-xs" > <div class="bg-muted text-muted-foreground mb-4 flex size-16 items-center justify-center rounded-full"> <ShoppingBag class="size-8 stroke-[1.5]" /> </div> <h2 class="text-lg font-semibold">Your cart is currently empty</h2> <p class="text-muted-foreground mt-1 max-w-sm text-sm"> Before you proceed to checkout you must add some products to your shopping cart. </p> <Button class="mt-6 gap-2" size="default" @click="resetCart"> <ShoppingBag class="size-4" /> Explore Products </Button> </div> <!-- Page Grid: Items (Left) & Order Summary (Right) --> <div v-else class="grid grid-cols-1 items-start gap-8 lg:grid-cols-12"> <!-- Left: Line Items list --> <div class="space-y-4 lg:col-span-8"> <div class="bg-card border-border/80 divide-border/60 divide-y rounded-2xl border shadow-xs"> <div v-for="item in cartItems" :key="item.id" class="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between" > <div class="flex items-center gap-4"> <div class="border-border/60 bg-muted/40 relative size-20 shrink-0 overflow-hidden rounded-lg border shadow-xs" > <img :src="item.image" :alt="item.name" class="size-full object-cover" /> </div> <div> <h3 class="text-foreground text-sm font-semibold"> {{ item.name }} </h3> <p class="text-muted-foreground mt-0.5 text-xs"> {{ item.variant }} </p> <div class="mt-1.5 flex items-center gap-2"> <span class="bg-success size-1.5 rounded-full"></span> <span class="text-success text-xs font-medium">In stock</span> <span class="text-muted-foreground text-xs font-normal tabular-nums"> · {{ formatCurrency(item.price) }} each </span> </div> </div> </div> <div class="flex items-center justify-between gap-6 sm:justify-end"> <div class="border-border/80 bg-background flex items-center rounded-md border shadow-xs"> <Button variant="ghost" size="icon-sm" class="size-8 rounded-none rounded-l-md" :aria-label="`Decrease quantity for ${item.name}`" @click="updateQuantity(item.id, -1)" > <Minus class="size-3.5" /> </Button> <span class="w-8 text-center text-xs font-medium tabular-nums">{{ item.qty }}</span> <Button variant="ghost" size="icon-sm" class="size-8 rounded-none rounded-r-md" :aria-label="`Increase quantity for ${item.name}`" @click="updateQuantity(item.id, 1)" > <Plus class="size-3.5" /> </Button> </div> <div class="w-24 text-right"> <span v-if="item.originalPrice" class="text-muted-foreground block text-xs tabular-nums line-through"> {{ formatCurrency(item.originalPrice * item.qty) }} </span> <span class="text-foreground text-base font-semibold tabular-nums"> {{ formatCurrency(item.price * item.qty) }} </span> </div> <Button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-destructive size-8" :aria-label="`Remove ${item.name} from cart`" @click="removeItem(item.id)" > <Trash2 class="size-4" /> </Button> </div> </div> </div> <!-- Bottom bar: Guarantees --> <div class="bg-card border-border/80 grid grid-cols-1 gap-4 rounded-xl border p-4 shadow-xs sm:grid-cols-3"> <div class="flex items-center gap-3 text-xs"> <div class="bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-md"> <RotateCcw class="size-4" /> </div> <div> <p class="font-medium">Free 30-Day Returns</p> <p class="text-muted-foreground">Hassle-free return policy</p> </div> </div> <div class="flex items-center gap-3 text-xs"> <div class="bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-md"> <ShieldCheck class="size-4" /> </div> <div> <p class="font-medium">256-Bit SSL Security</p> <p class="text-muted-foreground">Bank-grade data encryption</p> </div> </div> <div class="flex items-center gap-3 text-xs"> <div class="bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-md"> <Leaf class="size-4" /> </div> <div> <p class="font-medium">Carbon Neutral</p> <p class="text-muted-foreground">Offset delivery emissions</p> </div> </div> </div> </div> <!-- Right: Sticky Order Summary Card --> <div class="space-y-4 lg:sticky lg:top-6 lg:col-span-4"> <div class="bg-card border-border/80 space-y-4 rounded-2xl border p-5 shadow-xs"> <h2 class="text-base font-semibold">Order Summary</h2> <!-- Promo Input --> <div class="space-y-2"> <label class="text-muted-foreground text-xs font-medium">Have a coupon code?</label> <div v-if="appliedPromo" class="bg-primary/5 border-primary/20 flex items-center justify-between rounded-lg border px-3 py-2 text-xs" > <div class="flex items-center gap-2"> <Tag class="text-primary size-3.5" /> <span class="text-foreground font-medium">{{ appliedPromo }}</span> <span class="text-success text-success font-semibold tabular-nums"> -{{ formatCurrency(promoDiscount) }} </span> </div> <Button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-foreground size-6" aria-label="Remove coupon" @click="handleRemovePromo" > <X class="size-3.5" /> </Button> </div> <div v-else class="flex gap-2"> <Input v-model="promoCodeInput" placeholder="Enter code (SUMMER20)" size="small" class="text-xs" @keydown.enter.prevent="handleApplyPromo" /> <Button variant="outline" size="sm" class="shrink-0 text-xs" :disabled="!promoCodeInput.trim()" @click="handleApplyPromo" > Apply </Button> </div> </div> <Separator /> <!-- Cost Breakdown --> <div class="space-y-2 text-xs"> <div class="flex justify-between"> <span class="text-muted-foreground">Subtotal</span> <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(subtotal) }}</span> </div> <div v-if="promoDiscount > 0" class="text-success flex justify-between"> <span>Coupon ({{ appliedPromo }})</span> <span class="font-medium tabular-nums">-{{ formatCurrency(promoDiscount) }}</span> </div> <div class="flex justify-between"> <span class="text-muted-foreground">Estimated Shipping</span> <span v-if="shippingFee === 0" class="text-success font-medium">Free</span> <span v-else class="text-foreground font-medium tabular-nums">{{ formatCurrency(shippingFee) }}</span> </div> <div class="flex justify-between"> <span class="text-muted-foreground">Estimated Tax (8%)</span> <span class="text-foreground font-medium tabular-nums">{{ formatCurrency(estimatedTax) }}</span> </div> <Separator class="my-2" /> <div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 text-base font-semibold"> <span>Total</span> <span class="tabular-nums">{{ formatCurrency(total) }}</span> </div> </div> <Button class="w-full justify-center gap-2 font-medium shadow-xs" size="lg" @click="handleCheckout"> <Lock class="size-4" /> Proceed to Checkout </Button> <Button variant="ghost" class="text-muted-foreground hover:text-foreground w-full text-xs" size="sm" @click="handleContinueShopping" > Continue Shopping </Button> </div> </div> </div> </div> </div> </template>
Raw manifest:https://uipkge.dev/r/vue/cart-drawer.json