Skip to main content

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
This means you can integrate VueList with any CSS framework or design system.

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.

Build docs developers (and LLMs) love