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
<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
<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
<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
<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
<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
<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