Skip to content

Data Tables ​

Patterns for building responsive data tables.

Basic Structure ​

Tables use shadcn-vue's UiTable components wrapped in UiScrollArea:

vue
<template>
  <UiScrollArea class="border border-border rounded-lg h-full">
    <UiTable>
      <UiTableHeader class="bg-light-blue sticky top-0">
        <UiTableRow>
          <UiTableHead>Name</UiTableHead>
          <UiTableHead>Email</UiTableHead>
          <UiTableHead>Actions</UiTableHead>
        </UiTableRow>
      </UiTableHeader>
      
      <UiTableBody>
        <UiTableRow v-for="item in items" :key="item.id">
          <UiTableCell>{{ item.name }}</UiTableCell>
          <UiTableCell>{{ item.email }}</UiTableCell>
          <UiTableCell>
            <UiButton size="sm">Edit</UiButton>
          </UiTableCell>
        </UiTableRow>
      </UiTableBody>
    </UiTable>
    
    <UiScrollBar orientation="horizontal" />
  </UiScrollArea>
</template>

Responsive Columns with Container Queries ​

Use container queries to hide/show columns based on available space:

vue
<template>
  <UiScrollArea class="border border-border rounded-lg h-full">
    <UiTable>
      <UiTableHeader class="bg-light-blue sticky top-0">
        <UiTableRow>
          <!-- Always visible -->
          <UiTableHead>Name</UiTableHead>
          
          <!-- Hide on smaller containers -->
          <UiTableHead class="hidden @2xl:table-cell">Email</UiTableHead>
          <UiTableHead class="hidden @5xl:table-cell">Department</UiTableHead>
          <UiTableHead class="hidden @7xl:table-cell">Hire Date</UiTableHead>
          
          <!-- Always visible -->
          <UiTableHead>Status</UiTableHead>
          <UiTableHead>Actions</UiTableHead>
        </UiTableRow>
      </UiTableHeader>
      
      <UiTableBody>
        <UiTableRow v-for="employee in employees" :key="employee.id">
          <UiTableCell>{{ employee.name }}</UiTableCell>
          <UiTableCell class="hidden @2xl:table-cell">{{ employee.email }}</UiTableCell>
          <UiTableCell class="hidden @5xl:table-cell">{{ employee.department }}</UiTableCell>
          <UiTableCell class="hidden @7xl:table-cell">{{ employee.hireDate }}</UiTableCell>
          <UiTableCell>
            <UiBadge>{{ employee.status }}</UiBadge>
          </UiTableCell>
          <UiTableCell>
            <UiButton size="sm">Edit</UiButton>
          </UiTableCell>
        </UiTableRow>
      </UiTableBody>
    </UiTable>
    <UiScrollBar orientation="horizontal" />
  </UiScrollArea>
</template>

Container Query Pattern

  • Keep essential columns always visible
  • Hide less important columns at smaller sizes
  • Use @2xl, @3xl, @5xl, @7xl breakpoints
  • Apply the same classes to both header and body cells

Loading State ​

Use UiTableSkeletonLoader for loading states:

vue
<template>
  <UiTable>
    <UiTableHeader class="bg-light-blue sticky top-0">
      <!-- Header rows -->
    </UiTableHeader>
    
    <!-- Show skeleton while loading -->
    <UiTableSkeletonLoader 
      v-if="isLoading" 
      :row-count="10" 
      :colspan="6" 
      :row-height="10" 
    />
    
    <!-- Show data when loaded -->
    <UiTableBody v-else>
      <UiTableRow v-for="item in items" :key="item.id">
        <!-- Table cells -->
      </UiTableRow>
    </UiTableBody>
  </UiTable>
</template>

Use sticky top-0 with a background color:

vue
<UiTableHeader class="bg-light-blue sticky top-0">
  <UiTableRow>
    <UiTableHead>Column</UiTableHead>
  </UiTableRow>
</UiTableHeader>

Pagination ​

Use the TablePagination component for API-driven or client-side pagination:

vue
<template>
  <UiCard class="h-full flex flex-col">
    <UiCardContent class="flex flex-col flex-1 min-h-0">
      <!-- Scrollable table -->
      <div class="flex-1 min-h-0">
        <EmployeeTable :employees="paginatedItems" />
      </div>
      
      <!-- Fixed pagination -->
      <TablePagination 
        class="shrink-0"
        :total="totalItems"
        v-model:page="currentPage"
        v-model:page-size="pageSize"
      />
    </UiCardContent>
  </UiCard>
</template>

<script setup>
const currentPage = ref(1)
const pageSize = ref(10)
const totalItems = ref(100)

// Calculate paginated items
const paginatedItems = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return allItems.value.slice(start, end)
})
</script>

Search & Filters ​

Store search/filter state and pass as API query params:

vue
<template>
  <UiCard>
    <UiCardHeader>
      <div class="flex gap-2">
        <UiInput 
          v-model="searchQuery" 
          placeholder="Search..." 
          class="flex-1"
        />
        <FiltersDialog v-model="activeFilters" />
      </div>
    </UiCardHeader>
    
    <UiCardContent>
      <EmployeeTable :employees="employees" :is-loading="isLoading" />
    </UiCardContent>
  </UiCard>
</template>

<script setup>
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const activeFilters = ref({
  departments: [],
  statuses: []
})

// Build query params
const query = computed(() => ({
  search: searchQuery.value,
  offset: (currentPage.value - 1) * pageSize.value,
  limit: pageSize.value,
  departments: activeFilters.value.departments.join(','),
  statuses: activeFilters.value.statuses.join(',')
}))

// Fetch with query params
const { data, pending: isLoading } = useOrgApi('/api/organizations/employees', {
  query
})

const employees = computed(() => data.value?.employees ?? [])
</script>

Separate Table Components ​

Split tables into separate files for better organization:

employees/
  _components/
    table/
      EmployeeTable.vue           # Table structure
      EmployeeTableRow.vue        # Row component
      EmployeeTablePagination.vue # Pagination wrapper
    EmployeeList.vue              # Main component with search/filters

EmployeeTable.vue:

vue
<template>
  <UiScrollArea class="border border-border rounded-lg h-full">
    <UiTable>
      <UiTableHeader class="bg-light-blue sticky top-0">
        <UiTableRow>
          <UiTableHead>Name</UiTableHead>
          <!-- More headers -->
        </UiTableRow>
      </UiTableHeader>
      
      <UiTableSkeletonLoader v-if="isLoading" :row-count="10" :colspan="6" />
      
      <UiTableBody v-else>
        <EmployeeTableRow 
          v-for="employee in employees" 
          :key="employee.id" 
          :employee="employee" 
        />
      </UiTableBody>
    </UiTable>
    <UiScrollBar orientation="horizontal" />
  </UiScrollArea>
</template>

<script setup>
defineProps<{
  employees: Employee[]
  isLoading?: boolean
}>()
</script>

Mobile Alternative ​

For complex tables, provide a mobile alternative:

vue
<template>
  <div class="@container">
    <!-- Desktop: Table -->
    <div class="hidden @md:block">
      <UiTable>
        <!-- Full table -->
      </UiTable>
    </div>
    
    <!-- Mobile: Accordion -->
    <div class="@md:hidden">
      <UiAccordion type="single" collapsible>
        <UiAccordionItem v-for="item in items" :key="item.id" :value="item.id">
          <UiAccordionTrigger>{{ item.name }}</UiAccordionTrigger>
          <UiAccordionContent>
            <!-- Item details -->
          </UiAccordionContent>
        </UiAccordionItem>
      </UiAccordion>
    </div>
  </div>
</template>

Best Practices ​

Do's âś…

  • Use container queries for responsive columns
  • Wrap tables in UiScrollArea with horizontal scrollbar
  • Use UiTableSkeletonLoader for loading states
  • Keep essential columns always visible
  • Separate table into components for reusability
  • Use sticky headers for better UX

Don'ts ❌

  • Don't use viewport queries for column visibility
  • Don't forget horizontal scrollbar
  • Don't show all columns on mobile
  • Don't mix table components with business logic

Related: UI Components → | Styling & Theming → | API Calls →