Creating Custom CardMini Components
This guide walks you through creating custom CardMini components to display related entities in your tables and forms with rich, contextual information.
When to Create a Custom CardMini
Use custom CardMini components when:
- User references need avatars and profile info
- Location references should show addresses and maps
- Product references need thumbnails and pricing
- Any reference where just a title isn't enough context
The default CardMini shows only the title field. Custom components let you show whatever makes sense for your data.
Quick Start
Step 1: Choose Your Collection
Identify which collection needs a custom card. Examples:
users- External SuperSaaS userslocations- Your internal locations collectionproducts- Your products collection
Step 2: Create the Component File
For internal collections (created with generator):
collections/locations/app/components/CardMini.vue
For external collections (SuperSaaS, etc.):
app/components/UsersCardMini.vue
Step 3: Use the Template
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!) -->
<CroutonMiniButtons
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>
Complete Examples
Example 1: Location Card with Icon and Badge
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-heroicons-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>
<CroutonMiniButtons
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:
- 📍 Map pin icon for visual context
- Location name in bold
- Address in smaller, muted text
- Green "Active" badge when applicable
- Hover effect for better UX
Example 2: User Card with Avatar
For SuperSaaS users or any user collection:
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>
<CroutonMiniButtons
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:
- 👤 Avatar image or colored initials fallback
- User's full name
- Email address
- Active status badge
- Professional, clean layout
Example 3: Product Card with Thumbnail
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-heroicons-photo" 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>
<CroutonMiniButtons
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:
- 🖼️ Product thumbnail or placeholder
- Product name and SKU
- Formatted price
- Dynamic stock badge (color changes based on quantity)
- Clean, scannable layout
Best Practices
Keep It Compact
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>
Always Handle All States
Your component receives three states - handle all of them:
- Loading (
pending: true) → Show skeleton - Success (
item: {...}) → Show data - Error (
error: {...}) → 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) -->
<CroutonMiniButtons v-if="item" ... />
</div>
</template>
Use Truncation for Long Text
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>
Keep Styles Consistent
Match Crouton's design system:
- Use
border rounded-md p-2for card containers - Use
bg-white dark:bg-gray-800for backgrounds - Use
text-smfor primary text,text-xsfor secondary - Use
text-gray-500 dark:text-gray-400for muted text - Add hover effects:
hover:bg-gray-50 dark:hover:bg-gray-700
Include the Action Buttons
Always include CroutonMiniButtons for consistency:
<CroutonMiniButtons
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.
Common Patterns
Computing Display Values
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>
Fallback Images
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-heroicons-photo" class="text-gray-400" />
</div>
Conditional Badges
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>
Testing Your Custom Card
Visual Testing
- Start dev server:
pnpm dev - Navigate to a table/form with your reference field
- Check all states:
- Loading skeleton appears
- Data displays correctly
- Hover shows action buttons
- Dark mode looks good
Test Edge Cases
- Long names - Do they truncate?
- Missing data - Does fallback work?
- No image - Does placeholder show?
- Mobile view - Does it work on small screens?
Troubleshooting
Card not showing up
Problem: Still seeing default card (just title).
Solutions:
- Check file name matches format:
{Collection}CardMini.vue - Check file location is correct
- Restart dev server to refresh auto-imports:
pnpm dev
TypeScript errors
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>
Layout issues
Problem: Card is too large or overflowing.
Solutions:
- Add
truncateto text elements - Use
flex-shrink-0on images/icons - Use
min-w-0on flex children - Keep padding small (
p-2orp-3)
Data not loading
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.
Next Steps
- Read CardMini Components for full reference
- Check useCollectionItem API to understand data fetching
- Explore Custom Components for form customization
- See Table Configuration for table display options