Guides

Creating Custom CardMini Components

Step-by-step guide to building custom display components for reference fields

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 users
  • locations - Your internal locations collection
  • products - 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:

  1. Loading (pending: true) → Show skeleton
  2. Success (item: {...}) → Show data
  3. 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-2 for card containers
  • Use bg-white dark:bg-gray-800 for backgrounds
  • Use text-sm for primary text, text-xs for secondary
  • Use text-gray-500 dark:text-gray-400 for 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

  1. Start dev server:
    pnpm dev
    
  2. Navigate to a table/form with your reference field
  3. 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:

  1. Check file name matches format: {Collection}CardMini.vue
  2. Check file location is correct
  3. 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:

  1. Add truncate to text elements
  2. Use flex-shrink-0 on images/icons
  3. Use min-w-0 on flex children
  4. Keep padding small (p-2 or p-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