Learn how to customize layouts in Nuxt Crouton - from responsive presets to custom Card components and CardMini reference displays.
Nuxt Crouton provides responsive layout presets that automatically adapt your data views to different screen sizes.
responsive (Default):
mobile-friendly:
compact:
tree-default:
<script setup lang="ts">
const { items } = await useCollectionQuery('shopProducts')
const { columns } = useShopProducts()
</script>
<template>
<!-- Responsive preset: list on mobile, table on desktop -->
<CroutonCollection
:rows="items"
:columns="columns"
layout="responsive"
/>
</template>
Define your own responsive behavior:
<template>
<CroutonCollection
:rows="items"
:columns="columns"
:layout="{
base: 'list', // Mobile
sm: 'list', // Small tablets
md: 'list', // Tablets
lg: 'table', // Desktops
xl: 'table', // Large desktops
'2xl': 'table' // Extra large
}"
/>
</template>
Nuxt Crouton uses Tailwind breakpoints:
All presets use list layout for mobile devices because it's specifically optimized for small screens and touch interfaces:
name, email, avatar)Card components provide complete control over how collection items render in different layouts.
Use custom Card components when:
Stick with defaults when:
Nuxt Crouton automatically discovers Card components using naming conventions:
Collection: "bookings"
Component name: "BookingsCard"
File location: layers/bookings/app/components/Card.vue
Auto-registered as: BookingsCard
card PropYou can specify which card variant to use for any layout via the card prop on CroutonCollection or CroutonCollection:
<template>
<!-- Default: uses {Collection}Card with layout prop -->
<CroutonCollection layout="tree" />
<CroutonCollection layout="list" />
<!-- Specify card variant -->
<CroutonCollection layout="tree" card="CardSmall" />
<CroutonCollection layout="tree" card="CardMini" />
<CroutonCollection layout="list" card="CardMini" />
</template>
Card Resolution Logic:
card="CardSmall" → {Collection}CardSmall (e.g., BookingsCardSmall)card="CardMini" → {Collection}CardMinicard prop → {Collection}Card with layout prop (default behavior)This is particularly useful for tree layouts where you might want a compact card variant instead of the full card component:
<template>
<!-- Tree layout with compact card variant -->
<CroutonCollection
layout="tree"
collection="pages"
card="CardTree"
/>
</template>
Create the variant component in your collection layer:
layers/pages/app/components/
├── Card.vue # Default card (list, grid layouts with size variants)
└── CardTree.vue # Tree-specific compact card
Important for Tree Layout: When using a custom card in tree layout, the card replaces only the content area (icon, label, badge) - NOT the drag handle, expand button, or actions menu. This preserves all tree interaction functionality:
[drag handle] [expand btn] | [CARD CONTENT] | [actions menu]
└─── replaced ───┘
interface CardProps {
item: any // The data object
layout: 'list' | 'grid' | 'tree' // Current layout context
collection: string // Collection name
size?: 'compact' | 'comfortable' | 'spacious' // Grid size variant
pending?: boolean // Loading state
error?: any // Error state
}
<script setup lang="ts">
interface Props {
item: any
layout: 'list' | 'grid'
collection: string
size?: 'compact' | 'comfortable' | 'spacious'
}
const props = defineProps<Props>()
</script>
<template>
<!-- List: horizontal row -->
<div v-if="layout === 'list'" class="flex items-center justify-between">
<span>{{ item.customerName }}</span>
<UBadge>{{ item.status }}</UBadge>
</div>
<!-- Grid: adapts to size prop -->
<UCard
v-else-if="layout === 'grid'"
:class="size === 'spacious' ? 'p-4' : 'p-3'"
>
<h3 :class="size === 'spacious' ? 'text-lg font-semibold' : 'font-medium'">
{{ item.customerName }}
</h3>
<p class="text-sm text-muted">{{ item.date }}</p>
<div v-if="size === 'spacious'" class="mt-2 space-y-1">
<p class="text-sm">{{ item.time }}</p>
<p class="text-sm">{{ item.location }}</p>
</div>
</UCard>
</template>
List Layout - Best for scannable rows, mobile-first, quick actions:
<template>
<div v-if="layout === 'list'" class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-calendar" class="text-primary" />
<div>
<p class="font-medium">{{ item.customerName }}</p>
<p class="text-sm text-muted">{{ formatDate(item.date) }}</p>
</div>
</div>
<UBadge :color="getStatusColor(item.status)">
{{ item.status }}
</UBadge>
</div>
</template>
Grid Layout - Best for visual browsing, adapts to size prop:
<template>
<UCard
v-else-if="layout === 'grid'"
:class="[
'cursor-pointer hover:shadow-lg transition',
size === 'spacious' ? 'p-4' : 'p-3'
]"
@click="handleClick"
>
<template #header>
<div class="flex items-start justify-between">
<h3 :class="size === 'spacious' ? 'font-semibold text-lg' : 'font-semibold truncate'">
{{ item.customerName }}
</h3>
<UBadge size="sm" :color="getStatusColor(item.status)">
{{ item.status }}
</UBadge>
</div>
</template>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-calendar" class="text-muted" />
<span>{{ formatDate(item.date) }}</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-clock" class="text-muted" />
<span>{{ item.time }}</span>
</div>
</div>
<!-- Extra details for spacious size -->
<template v-if="size === 'spacious'" #footer>
<div class="flex justify-between items-center">
<span class="text-xs text-muted">
Created {{ formatRelative(item.createdAt) }}
</span>
<div class="flex gap-2">
<UButton size="xs" variant="ghost" @click.stop="handleEdit">
Edit
</UButton>
</div>
</div>
</template>
</UCard>
</template>
The size prop controls grid density:
| Size | Columns | Spacing | Best For |
|---|---|---|---|
compact | 4 cols | Tight (gap-3, p-3) | Thumbnails, quick scanning |
comfortable | 3 cols | Medium (gap-4, p-4) | General purpose (default) |
spacious | 2-3 cols | Generous (gap-6, p-6) | Detailed cards, dashboards |
<template>
<!-- Compact grid for thumbnails -->
<CroutonCollection layout="grid" grid-size="compact" :rows="items" />
<!-- Default comfortable grid -->
<CroutonCollection layout="grid" :rows="items" />
<!-- Spacious grid for detailed cards -->
<CroutonCollection layout="grid" grid-size="spacious" :rows="items" />
</template>
CardMini is a compact display component that shows related entities in tables and forms. It's a completely separate system from Card components.
When you have reference fields (relationships to other collections), CardMini automatically displays a preview of the referenced item:
By default, CardMini displays:
title fieldCustomize the display for specific collections using convention-based discovery:
Internal Collections:
collections/locations/app/components/
└── CardMini.vue ← Create this file
External Collections:
app/components/
└── UsersCardMini.vue ← For :users collection
<script setup lang="ts">
const props = defineProps<{
item: any | null // The fetched item data
pending: boolean // Loading state
error: any | null // Error state
id: string // Item ID
collection: string // Collection name
refresh: () => Promise<void> // Refetch function
}>()
</script>
Basic Custom CardMini:
<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">
<div class="flex items-start gap-3">
<UIcon name="i-lucide-map-pin" class="text-blue-500 mt-1" />
<div class="flex-1">
<div class="font-medium text-sm">{{ item.name }}</div>
<div class="text-xs text-gray-500">{{ item.address }}</div>
<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"
/>
</div>
</template>
User CardMini with Avatar:
<script setup lang="ts">
const props = defineProps<{
item: any
pending: boolean
error: any
id: string
collection: string
refresh: () => Promise<void>
}>()
const { open } = useCrouton()
const userInitials = computed(() => {
if (!props.item?.full_name) return '?'
return props.item.full_name
.split(' ')
.map((n: string) => n[0])
.join('')
.toUpperCase()
})
</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">
<!-- Avatar -->
<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"
/>
<div v-else class="w-10 h-10 rounded-full bg-blue-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>
<UBadge v-if="item.is_active" color="green" size="xs">
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"
/>
</div>
</template>
The component name must match this format:
users → Component: UsersCardMini.vuebookingsLocations → Component: BookingsLocationsCardMini.vueproductCategories → Component: ProductCategoriesCardMini.vueRules:
CardMini suffixNuxt Crouton has two distinct card systems that serve different purposes:
item, layout, collectionCard.vue)id, collection, item, pending, errorCardMini.vue) or app (ItemCardMini.vue)They are completely separate systems:
<!-- Card: For layout rendering -->
<Card :item="booking" layout="grid" collection="bookings" />
<!-- CardMini: For references -->
<ItemCardMini :id="booking.userId" collection="users" />
✅ DO:
responsive, mobile-friendly, compact)❌ DON'T:
✅ DO:
size prop to adapt grid cards for different densities❌ DON'T:
✅ DO:
❌ DON'T:
item prop)pending and error statesCheck file naming:
bookingsBookingsCard.vue (exact name in Card.vue)collections/bookings/app/components/Card.vueCheck component is auto-imported:
# Restart dev server to refresh auto-imports
pnpm dev
Check file naming:
usersUsersCardMini.vue (exact name)app/components/ or collections/{name}/app/components/The component uses VueUse's useBreakpoints - ensure you're testing with actual browser window resize, not just DevTools responsive mode which can sometimes cache breakpoints.
// Card Props
interface CardProps {
item: any
layout: 'list' | 'grid' | 'tree'
collection: string
size?: 'compact' | 'comfortable' | 'spacious' // Grid size variant
pending?: boolean
error?: any
}
// CardMini Props
interface CardMiniProps {
item: any | null
pending: boolean
error: any | null
id: string
collection: string
refresh: () => Promise<void>
}
// Layout Types
type LayoutType = 'table' | 'list' | 'grid' | 'tree' | 'kanban' | 'workspace'
type GridSize = 'compact' | 'comfortable' | 'spacious'
interface ResponsiveLayout {
base: LayoutType
sm?: LayoutType
md?: LayoutType
lg?: LayoutType
xl?: LayoutType
'2xl'?: LayoutType
}
type LayoutPreset = 'responsive' | 'mobile-friendly' | 'compact' | 'tree-default'
// Collection Props (subset for layouts)
interface CollectionProps {
layout?: LayoutType | ResponsiveLayout | LayoutPreset
gridSize?: GridSize // Grid density (compact, comfortable, spacious)
card?: 'Card' | 'CardMini' | 'CardSmall' | 'CardTree' | string // Card variant
// ... other props
}