Appearance
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,@7xlbreakpoints - 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>Sticky Header ​
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/filtersEmployeeTable.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
UiScrollAreawith horizontal scrollbar - Use
UiTableSkeletonLoaderfor 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 →