Overview
VueList is completely headless — it provides zero styling out of the box. Every component uses slots to let you define your own markup and styles. This gives you complete control over the appearance of your lists.Understanding Headless Components
Headless components separate logic from presentation:- VueList handles: State management, pagination logic, API calls, reactivity
- You handle: HTML structure, CSS styling, animations, UX
Using Component Slots
Every VueList component exposes its state and methods through scoped slots.Basic Pattern
<!-- Default rendering -->
<VueListPagination />
<!-- Custom rendering with full control -->
<VueListPagination v-slot="{ page, hasNext, hasPrev, next, prev }">
<div class="my-custom-pagination">
<button @click="prev" :disabled="!hasPrev">← Prev</button>
<span>Page {{ page }}</span>
<button @click="next" :disabled="!hasNext">Next →</button>
</div>
</VueListPagination>
Styling with Tailwind CSS
Here’s a complete example using Tailwind:<template>
<VueList
endpoint="products"
:per-page="12"
v-model:filters="filters"
>
<!-- Header with search and filters -->
<div class="bg-white shadow-sm rounded-lg p-6 mb-6">
<div class="flex gap-4 mb-4">
<VueListSearch v-slot="{ search, setSearch }" class="flex-1">
<div class="relative">
<input
type="search"
:value="search"
@input="setSearch($event.target.value)"
placeholder="Search products..."
class="w-full px-4 py-2 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</VueListSearch>
<select
v-model="filters.category"
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option :value="null">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
</div>
<VueListSummary v-slot="{ from, to, count }">
<p class="text-sm text-gray-600">
Showing {{ from }}-{{ to }} of {{ count }} products
</p>
</VueListSummary>
</div>
<!-- Loading state -->
<VueListInitialLoader>
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
</VueListInitialLoader>
<!-- Error state -->
<VueListError v-slot="{ error }">
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p class="text-red-800 font-medium">Failed to load products</p>
<p class="text-red-600 text-sm">{{ error.message }}</p>
</div>
</VueListError>
<!-- Products grid -->
<VueListItems>
<template #default="{ items }">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div
v-for="product in items"
:key="product.id"
class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-200"
>
<img
:src="product.image"
:alt="product.name"
class="w-full h-48 object-cover"
/>
<div class="p-4">
<h3 class="font-semibold text-lg mb-2 text-gray-900">
{{ product.name }}
</h3>
<p class="text-gray-600 text-sm mb-4 line-clamp-2">
{{ product.description }}
</p>
<div class="flex justify-between items-center">
<span class="text-2xl font-bold text-blue-600">
${{ product.price }}
</span>
<button class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
Add to Cart
</button>
</div>
</div>
</div>
</div>
</template>
</VueListItems>
<!-- Empty state -->
<VueListEmpty>
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No products found</h3>
<p class="mt-1 text-sm text-gray-500">Try adjusting your search or filters</p>
</div>
</VueListEmpty>
<!-- Pagination -->
<VueListPagination v-slot="{ first, prev, next, last, hasPrev, hasNext, pagesToDisplay, page, setPage }">
<div class="flex justify-center items-center gap-2 mt-8">
<button
@click="first"
:disabled="!hasPrev"
class="px-3 py-2 rounded-lg border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
First
</button>
<button
@click="prev"
:disabled="!hasPrev"
class="px-3 py-2 rounded-lg border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
← Prev
</button>
<div class="flex gap-1">
<button
v-for="p in pagesToDisplay"
:key="p"
@click="setPage(p)"
:class="[
'px-4 py-2 rounded-lg border',
p === page
? 'bg-blue-500 text-white border-blue-500'
: 'border-gray-300 hover:bg-gray-50'
]"
>
{{ p }}
</button>
</div>
<button
@click="next"
:disabled="!hasNext"
class="px-3 py-2 rounded-lg border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Next →
</button>
<button
@click="last"
:disabled="!hasNext"
class="px-3 py-2 rounded-lg border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Last
</button>
</div>
</VueListPagination>
</VueList>
</template>
<script setup>
import { ref } from 'vue'
const filters = ref({
category: null
})
</script>
Styling with Bootstrap
<template>
<VueList endpoint="users" :per-page="15">
<div class="card mb-3">
<div class="card-header">
<VueListSearch v-slot="{ search, setSearch }">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-search"></i>
</span>
<input
type="search"
class="form-control"
:value="search"
@input="setSearch($event.target.value)"
placeholder="Search users..."
/>
</div>
</VueListSearch>
</div>
<VueListInitialLoader>
<div class="card-body text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</VueListInitialLoader>
<VueListError v-slot="{ error }">
<div class="card-body">
<div class="alert alert-danger" role="alert">
<strong>Error!</strong> {{ error.message }}
</div>
</div>
</VueListError>
<VueListItems>
<template #default="{ items }">
<div class="list-group list-group-flush">
<div
v-for="user in items"
:key="user.id"
class="list-group-item list-group-item-action"
>
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ user.name }}</h5>
<small class="text-muted">{{ user.role }}</small>
</div>
<p class="mb-1">{{ user.email }}</p>
</div>
</div>
</template>
</VueListItems>
<VueListEmpty>
<div class="card-body text-center text-muted">
<p>No users found</p>
</div>
</VueListEmpty>
<div class="card-footer">
<div class="d-flex justify-content-between align-items-center">
<VueListSummary v-slot="{ from, to, count }">
<small class="text-muted">
Showing {{ from }}-{{ to }} of {{ count }}
</small>
</VueListSummary>
<VueListPagination v-slot="{ prev, next, hasPrev, hasNext }">
<nav>
<ul class="pagination mb-0">
<li class="page-item" :class="{ disabled: !hasPrev }">
<a class="page-link" href="#" @click.prevent="prev">Previous</a>
</li>
<li class="page-item" :class="{ disabled: !hasNext }">
<a class="page-link" href="#" @click.prevent="next">Next</a>
</li>
</ul>
</nav>
</VueListPagination>
</div>
</div>
</div>
</VueList>
</template>
Custom Loading States
Create sophisticated loading experiences:<template>
<VueList endpoint="articles">
<!-- Initial skeleton loader -->
<VueListInitialLoader>
<div class="space-y-4">
<div
v-for="n in 5"
:key="n"
class="animate-pulse"
>
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
</VueListInitialLoader>
<VueListItems>
<template #default="{ items }">
<article v-for="article in items" :key="article.id">
<h2>{{ article.title }}</h2>
<p>{{ article.excerpt }}</p>
</article>
</template>
</VueListItems>
<!-- Show a subtle loading indicator during pagination -->
<VueList v-slot="{ isLoading }">
<div
v-if="isLoading"
class="fixed top-0 left-0 right-0 h-1 bg-blue-500 animate-pulse"
>
</div>
</VueList>
</VueList>
</template>
Load More Button Styling
<VueListLoadMore v-slot="{ loadMore, hasMoreItems, isLoading }">
<div class="text-center py-8">
<button
v-if="hasMoreItems"
@click="loadMore"
:disabled="isLoading"
class="relative px-8 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold rounded-full shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="isLoading" class="flex items-center gap-2">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading...
</span>
<span v-else>Load More</span>
</button>
<div v-else class="text-gray-500">
<svg class="mx-auto h-8 w-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<p>All items loaded</p>
</div>
</div>
</VueListLoadMore>
Per Page Selector Styling
<VueListPerPage
:options="[12, 24, 48, 96]"
v-slot="{ perPage, options, setPerPage }"
>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">
Items per page:
</label>
<select
:value="perPage"
@change="setPerPage($event.target.value)"
class="px-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</VueListPerPage>
Advanced: Custom Table Component
Create a fully styled, sortable table:<template>
<VueList
endpoint="users"
sort-by="name"
sort-order="asc"
ref="listRef"
>
<template #default="{ context }">
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
v-for="column in columns"
:key="column.key"
@click="column.sortable && sort(column.key)"
:class="[
'px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider',
column.sortable && 'cursor-pointer hover:bg-gray-100'
]"
>
<div class="flex items-center gap-2">
{{ column.label }}
<span v-if="column.sortable && context.sortBy === column.key">
<svg
v-if="context.sortOrder === 'asc'"
class="h-4 w-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M5 10l5-5 5 5H5z"/>
</svg>
<svg
v-else
class="h-4 w-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M15 10l-5 5-5-5h10z"/>
</svg>
</span>
</div>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<VueListInitialLoader>
<tr>
<td :colspan="columns.length" class="px-6 py-12 text-center">
<div class="flex justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
</td>
</tr>
</VueListInitialLoader>
<VueListItems>
<template #item="{ item }">
<tr class="hover:bg-gray-50 transition-colors">
<td
v-for="column in columns"
:key="column.key"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
<slot :name="`cell-${column.key}`" :item="item">
{{ item[column.key] }}
</slot>
</td>
</tr>
</template>
</VueListItems>
<VueListEmpty>
<tr>
<td :colspan="columns.length" class="px-6 py-12 text-center text-gray-500">
No records found
</td>
</tr>
</VueListEmpty>
</tbody>
</table>
<div class="bg-gray-50 px-6 py-3 flex items-center justify-between border-t border-gray-200">
<VueListSummary v-slot="{ from, to, count }">
<p class="text-sm text-gray-700">
Showing {{ from }} to {{ to }} of {{ count }} results
</p>
</VueListSummary>
<VueListPagination />
</div>
</div>
</template>
</VueList>
</template>
<script setup>
import { ref } from 'vue'
const listRef = ref(null)
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'role', label: 'Role', sortable: false },
{ key: 'status', label: 'Status', sortable: true }
]
function sort(by) {
const current = listRef.value?.context
const order = current?.sortBy === by && current?.sortOrder === 'asc' ? 'desc' : 'asc'
listRef.value.setSort({ by, order })
}
</script>
Using CSS Frameworks
VueList works with any CSS framework:Tailwind CSS
Use utility classes for rapid styling
Bootstrap
Use Bootstrap components and utilities
Vuetify
Wrap with Vuetify components
Element Plus
Use Element UI components
Ant Design
Integrate with Ant Design Vue
Custom CSS
Write your own styles from scratch
Since VueList is headless, you’re never fighting against default styles. Every pixel is under your control.