This guide walks you through creating custom CardMini components to display related entities in your tables and forms with rich, contextual information.
Use custom CardMini components when:
The default CardMini shows only the title field. Custom components let you show whatever makes sense for your data.
Identify which collection needs a custom card. Examples:
users - Auth system userslocations - Your internal locations collectionproducts - Your products collectionFor internal collections (created with generator):
collections/locations/app/components/CardMini.vue
For auth-related collections (users, teams):
app/components/UsersCardMini.vue
Start with this template and customize:
<script setup lang="ts">
const props = defineProps<{
item: any
pending: boolean
error: any
id: string
collection: string
refresh: () => Promise<void>
}>()
const { open } = useCrouton()
</script>
<template>
<div class="group relative">
<!-- Loading State -->
<USkeleton v-if="pending" class="h-12 w-full rounded-md" />
<!-- Loaded State -->
<div v-else-if="item" class="border rounded-md p-2 bg-white dark:bg-gray-800">
<!-- Your custom layout here -->
<div>{{ item.title }}</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-red-500 text-xs p-2">
Error loading item
</div>
<!-- Action Buttons (keep these!) -->
<CroutonItemButtonsMini
v-if="item"
update
@update="open('update', collection, [id])"
class="absolute -top-1 right-2 transition delay-150 duration-300 ease-in-out group-hover:-translate-y-6 group-hover:scale-110"
buttonClasses="pb-4"
containerClasses="flex flex-row gap-[2px]"
/>
</div>
</template>
Let's create a custom card for a locations collection:
File: collections/locations/app/components/CardMini.vue
<script setup lang="ts">
const props = defineProps<{
item: any
pending: boolean
error: any
id: string
collection: string
refresh: () => Promise<void>
}>()
const { open } = useCrouton()
</script>
<template>
<div class="group relative">
<USkeleton v-if="pending" class="h-16 w-full rounded-md" />
<div v-else-if="item" class="border rounded-md p-3 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<div class="flex items-start gap-3">
<!-- Icon -->
<UIcon name="i-lucide-map-pin" class="text-blue-500 mt-1 flex-shrink-0" />
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{{ item.name }}</div>
<div class="text-xs text-gray-500 truncate">{{ item.address }}</div>
<!-- Status Badge -->
<UBadge
v-if="item.active"
color="green"
size="xs"
class="mt-1"
>
Active
</UBadge>
</div>
</div>
</div>
<div v-else-if="error" class="text-red-500 text-xs p-2 border border-red-200 rounded-md">
Failed to load location
</div>
<CroutonItemButtonsMini
v-if="item"
update
@update="open('update', collection, [id])"
class="absolute -top-1 right-2 transition delay-150 duration-300 ease-in-out group-hover:-translate-y-6 group-hover:scale-110"
buttonClasses="pb-4"
containerClasses="flex flex-row gap-[2px]"
/>
</div>
</template>
What this shows:
For users from your auth system:
File: app/components/UsersCardMini.vue
<script setup lang="ts">
const props = defineProps<{
item: any
pending: boolean
error: any
id: string
collection: string
refresh: () => Promise<void>
}>()
const { open } = useCrouton()
// Generate initials from name
const userInitials = computed(() => {
if (!props.item?.full_name) return '?'
return props.item.full_name
.split(' ')
.map((n: string) => n[0])
.join('')
.toUpperCase()
.slice(0, 2) // Max 2 letters
})
</script>
<template>
<div class="group relative">
<USkeleton v-if="pending" class="h-12 w-full rounded-lg" />
<div v-else-if="item" class="flex items-center gap-3 p-2 border rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<!-- Avatar or Initials -->
<div class="flex-shrink-0">
<img
v-if="item.avatar_url"
:src="item.avatar_url"
:alt="item.full_name"
class="w-10 h-10 rounded-full object-cover"
/>
<div
v-else
class="w-10 h-10 rounded-full bg-primary-500 text-white flex items-center justify-center font-medium text-sm"
>
{{ userInitials }}
</div>
</div>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{{ item.full_name }}</div>
<div class="text-xs text-gray-500 truncate">{{ item.email }}</div>
</div>
<!-- Status Badge -->
<UBadge
v-if="item.is_active"
color="green"
size="xs"
class="flex-shrink-0"
>
Active
</UBadge>
</div>
<div v-else-if="error" class="text-red-500 text-xs p-2">
User not found
</div>
<CroutonItemButtonsMini
v-if="item"
update
@update="open('update', collection, [id])"
class="absolute -top-1 right-2 transition delay-150 duration-300 ease-in-out group-hover:-translate-y-6 group-hover:scale-110"
buttonClasses="pb-4"
containerClasses="flex flex-row gap-[2px]"
/>
</div>
</template>
What this shows:
For products with images and pricing:
File: collections/products/app/components/CardMini.vue
<script setup lang="ts">
const props = defineProps<{
item: any
pending: boolean
error: any
id: string
collection: string
refresh: () => Promise<void>
}>()
const { open } = useCrouton()
// Format price for display
const formattedPrice = computed(() => {
if (!props.item?.price) return '--'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(props.item.price)
})
// Stock badge color based on quantity
const stockColor = computed(() => {
const stock = props.item?.stock || 0
if (stock === 0) return 'red'
if (stock <= 10) return 'yellow'
return 'green'
})
</script>
<template>
<div class="group relative">
<USkeleton v-if="pending" class="h-20 w-full rounded-lg" />
<div v-else-if="item" class="flex items-center gap-3 p-2 border rounded-lg bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<!-- Product Thumbnail -->
<div class="flex-shrink-0">
<img
v-if="item.thumbnail"
:src="item.thumbnail"
:alt="item.name"
class="w-16 h-16 rounded object-cover"
/>
<div v-else class="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<UIcon name="i-lucide-image" class="text-gray-400 text-2xl" />
</div>
</div>
<!-- Product Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{{ item.name }}</div>
<div class="text-xs text-gray-500 truncate">SKU: {{ item.sku }}</div>
<div class="font-semibold text-sm text-primary-600 dark:text-primary-400 mt-1">
{{ formattedPrice }}
</div>
</div>
<!-- Stock Badge -->
<UBadge
:color="stockColor"
size="xs"
class="flex-shrink-0"
>
{{ item.stock }} in stock
</UBadge>
</div>
<div v-else-if="error" class="text-red-500 text-xs p-2">
Product not found
</div>
<CroutonItemButtonsMini
v-if="item"
update
@update="open('update', collection, [id])"
class="absolute -top-1 right-2 transition delay-150 duration-300 ease-in-out group-hover:-translate-y-6 group-hover:scale-110"
buttonClasses="pb-4"
containerClasses="flex flex-row gap-[2px]"
/>
</div>
</template>
What this shows:
CardMini is meant to be compact - it's a preview, not a full detail view.
✅ Good:
<div class="flex items-center gap-2 p-2">
<img class="w-10 h-10 rounded" />
<div>
<div class="text-sm">{{ item.name }}</div>
<div class="text-xs text-gray-500">{{ item.email }}</div>
</div>
</div>
❌ Too Much:
<div class="p-6">
<img class="w-32 h-32" />
<h2>{{ item.name }}</h2>
<p>{{ item.bio }}</p>
<div>Created: {{ item.createdAt }}</div>
<div>Updated: {{ item.updatedAt }}</div>
<div>Last Login: {{ item.lastLogin }}</div>
</div>
Your component receives three states - handle all of them:
pending: true) → Show skeletonitem: {...}) → Show dataerror: {...}) → Show error message<template>
<div class="group relative">
<!-- 1. Loading -->
<USkeleton v-if="pending" class="h-12 w-full" />
<!-- 2. Success -->
<div v-else-if="item">
<!-- Your content -->
</div>
<!-- 3. Error -->
<div v-else-if="error" class="text-red-500 text-xs">
Failed to load
</div>
<!-- Mini buttons (only show when loaded) -->
<CroutonItemButtonsMini v-if="item" ... />
</div>
</template>
Prevent overflow by truncating long text:
<div class="truncate">{{ item.veryLongTitle }}</div>
Or use min-w-0 on flex children:
<div class="flex items-center gap-3">
<img class="flex-shrink-0" />
<div class="flex-1 min-w-0"> <!-- min-w-0 allows truncation -->
<div class="truncate">{{ item.longText }}</div>
</div>
</div>
Match Crouton's design system:
border rounded-md p-2 for card containersbg-white dark:bg-gray-800 for backgroundstext-sm for primary text, text-xs for secondarytext-gray-500 dark:text-gray-400 for muted texthover:bg-gray-50 dark:hover:bg-gray-700Always include CroutonItemButtonsMini for consistency:
<CroutonItemButtonsMini
v-if="item"
update
@update="open('update', collection, [id])"
class="absolute -top-1 right-2 transition delay-150 duration-300 ease-in-out group-hover:-translate-y-6 group-hover:scale-110"
buttonClasses="pb-4"
containerClasses="flex flex-row gap-[2px]"
/>
This provides the edit button that appears on hover.
Use computed properties for formatted data:
<script setup lang="ts">
// Format currency
const formattedPrice = computed(() => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(props.item?.price || 0)
})
// Format date
const formattedDate = computed(() => {
if (!props.item?.createdAt) return '--'
return new Date(props.item.createdAt).toLocaleDateString()
})
// Conditional badge color
const statusColor = computed(() => {
return props.item?.active ? 'green' : 'gray'
})
</script>
Handle missing images gracefully:
<img
v-if="item.image"
:src="item.image"
class="w-10 h-10"
/>
<div v-else class="w-10 h-10 bg-gray-200 rounded flex items-center justify-center">
<UIcon name="i-lucide-image" class="text-gray-400" />
</div>
Show badges only when relevant:
<UBadge v-if="item.featured" color="yellow">Featured</UBadge>
<UBadge v-if="item.stock === 0" color="red">Out of Stock</UBadge>
<UBadge v-if="item.isNew" color="green">New</UBadge>
pnpm dev
Problem: Still seeing default card (just title).
Solutions:
{Collection}CardMini.vuepnpm dev
Problem: Props are undefined or type errors.
Solution: Ensure all required props are defined:
<script setup lang="ts">
const props = defineProps<{
item: any
pending: boolean
error: any
id: string
collection: string
refresh: () => Promise<void>
}>()
</script>
Problem: Card is too large or overflowing.
Solutions:
truncate to text elementsflex-shrink-0 on images/iconsmin-w-0 on flex childrenp-2 or p-3)Problem: item is always null.
Solution: The parent CardMini handles data fetching automatically. You don't need to fetch anything. Just use the item prop.