Live demo
1,250 generated users, filtered, sorted and paginated in the browser with wrapObjectArrayWithGrid. Cells show what slots can render: images, badges, status dots, links, tags, progress bars, star ratings, toggles, inline SVG charts and buttons.
Try adding filters, sorting by a column, hiding columns with Columns, and scrolling sideways: User and Actions stay pinned. On a phone (or a narrow browser window) rows turn into cards.
Source
The component behind this page. It uses the local source (../../src) and VitePress's useData() for the theme; in your app import from "li-grid" instead.
vue
<script setup lang="ts">
import {reactive, watchEffect} from "vue";
import {useData} from "vitepress";
import {LiGrid, wrapObjectArrayWithGrid, GridFormatter} from "../../src";
import type {GridConfig} from "../../src";
import {dummyUsersDataGenerator, roles, statuses, countries} from "./dummy-users-data";
const users = dummyUsersDataGenerator();
const dateFormat = new Intl.DateTimeFormat("en-GB", {day: "numeric", month: "short", year: "numeric"});
const {isDark} = useData();
const config = reactive<GridConfig>({
columns: [
{label: "User", slotName: "user", sortable: "name", width: "220px", sticky: true},
{label: "Role", slotName: "role", sortable: "role", contentAlign: "center"},
{label: "Status", slotName: "status", sortable: "status"},
{label: "Email", slotName: "email"},
{label: "Photo", value: "avatar", formatter: GridFormatter.image, contentAlign: "center"},
{label: "Country", value: (row: any) => `${row.flag} ${row.country}`, sortable: "country"},
{label: "Skills", slotName: "tags", width: "160px"},
{label: "Progress", slotName: "progress", sortable: "progress", width: "140px"},
{label: "Rating", slotName: "rating", sortable: "rating", headerSlotName: "ratingHeader"},
{label: "Verified", slotName: "verified", sortable: "verified", contentAlign: "center"},
{label: "Activity", slotName: "activity", contentAlign: "center"},
{label: "Balance", value: "balance", sortable: "balance", formatter: GridFormatter.number, contentAlign: "right"},
{label: "Joined", value: (row: any) => dateFormat.format(new Date(row.createdAt)), sortable: "createdAt"},
{label: "Actions", slotName: "actions", contentAlign: "center", sticky: true},
],
filters: [
{label: "Name", inputName: "name", type: "text"},
{label: "Email", inputName: "email", type: "text"},
{label: "Role", inputName: "role", type: "select", selectItems: roles.map(r => ({key: r, value: r}))},
{label: "Status", inputName: "status", type: "select", selectItems: statuses.map(s => ({key: s, value: s}))},
{label: "Country", inputName: "country", type: "select", selectItems: countries.map(c => ({key: c.name, value: `${c.flag} ${c.name}`}))},
{label: "Joined", inputName: "createdAt", type: "date-range"},
],
defaultSort: {column: "id", order: "ASC"},
columnsToggle: true,
gridKey: "docs-demo-v3",
mobileLayout: "stack",
dataLoader: (filters, sort, page) => wrapObjectArrayWithGrid(filters, sort, page, users, 20),
});
// Follow the docs site's light/dark switch
watchEffect(() => {
config.theme = isDark.value ? "dark" : "light";
});
// Tiny inline SVG line chart
const sparkline = (values: number[]) => values
.map((v, i) => `${(i / (values.length - 1)) * 80},${22 - (v / 100) * 20}`)
.join(" ");
const greet = (row: any) => alert(`Hello, ${row.name}!`);
</script>
<template>
<ClientOnly>
<!-- vp-raw opts out of VitePress content styles (tables, lists), like any host page CSS -->
<div class="vp-raw">
<li-grid :config="config">
<template #user="{ row }">
<span class="demo-user">
<img :src="row.avatar" :alt="row.name" width="36" height="36" loading="lazy">
<span>
<b>{{ row.name }}</b>
<small>#{{ row.id }}</small>
</span>
</span>
</template>
<template #role="{ row }">
<span :class="['demo-badge', `demo-badge--${row.role}`]">{{ row.role }}</span>
</template>
<template #status="{ row }">
<span class="demo-status">
<i :class="`demo-status--${row.status}`"></i>{{ row.status }}
</span>
</template>
<template #email="{ row }">
<a :href="`mailto:${row.email}`" class="demo-link">{{ row.email }}</a>
</template>
<template #tags="{ row }">
<span class="demo-tags">
<span v-for="tag in row.tags" :key="tag" class="demo-tag">{{ tag }}</span>
<span v-if="!row.tags.length" class="demo-muted">none</span>
</span>
</template>
<template #progress="{ row }">
<span class="demo-progress" :title="`${row.progress}%`">
<span :style="{width: `${row.progress}%`}"></span>
</span>
<small>{{ row.progress }}%</small>
</template>
<template #ratingHeader="{ column }">
{{ column.label }} <span title="Average customer rating, 1–5">ⓘ</span>
</template>
<template #rating="{ row }">
<span class="demo-stars" :title="`${row.rating} / 5`">
<span v-for="n in 5" :key="n" :class="{on: n <= row.rating}">★</span>
</span>
</template>
<template #verified="{ row }">
<label class="demo-switch" :title="row.verified ? 'Verified' : 'Not verified'">
<input type="checkbox" v-model="row.verified">
<span></span>
</label>
</template>
<template #activity="{ row }">
<svg class="demo-sparkline" width="80" height="24" viewBox="0 0 80 24">
<polyline :points="sparkline(row.activity)" fill="none" stroke="currentColor" stroke-width="1.5"/>
</svg>
</template>
<template #actions="{ row }">
<span class="demo-actions">
<button class="demo-button" @click="greet(row)">👋 Say hi</button>
<a :href="row.avatar" target="_blank" class="demo-button">View</a>
</span>
</template>
</li-grid>
</div>
</ClientOnly>
</template>
<style>
/* Show off built-in zebra rows (odd rows), in both themes */
.vp-raw .li-grid {
--li-grid-row-stripe: var(--vp-c-bg-soft);
}
</style>
<style scoped>
.demo-user {
display: inline-flex;
align-items: center;
gap: 10px;
text-align: left;
}
.demo-user img {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
background: var(--vp-c-bg-soft);
}
.demo-user span {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.demo-user small, .demo-muted {
opacity: 0.6;
}
.demo-badge {
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
text-transform: capitalize;
}
.demo-badge--admin { background: #fee2e2; color: #991b1b; }
.demo-badge--editor { background: #dbeafe; color: #1e40af; }
.demo-badge--viewer { background: #e5e7eb; color: #374151; }
.demo-status {
display: inline-flex;
align-items: center;
gap: 6px;
text-transform: capitalize;
}
.demo-status i {
width: 8px;
height: 8px;
border-radius: 50%;
}
.demo-status--active { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2); }
.demo-status--away { background: #f59e0b; }
.demo-status--blocked { background: #ef4444; }
.demo-link {
color: var(--vp-c-brand-1);
text-decoration: underline;
}
.demo-tags {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.demo-tag {
padding: 1px 6px;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
font-size: 12px;
font-family: var(--vp-font-family-mono);
}
.demo-progress {
display: inline-block;
width: 80px;
height: 6px;
margin-right: 6px;
border-radius: 3px;
background: var(--vp-c-default-soft);
overflow: hidden;
vertical-align: middle;
}
.demo-progress span {
display: block;
height: 100%;
background: linear-gradient(90deg, #6366f1, #22c55e);
}
.demo-stars {
letter-spacing: 1px;
color: var(--vp-c-default-3);
}
.demo-stars .on {
color: #f59e0b;
}
.demo-switch {
position: relative;
display: inline-block;
width: 34px;
height: 20px;
cursor: pointer;
}
.demo-switch input {
opacity: 0;
width: 0;
height: 0;
}
.demo-switch span {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--vp-c-default-soft);
transition: background 0.2s;
}
.demo-switch span::before {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.demo-switch input:checked + span {
background: #22c55e;
}
.demo-switch input:checked + span::before {
transform: translateX(14px);
}
.demo-sparkline {
display: inline-block;
color: var(--vp-c-brand-1);
vertical-align: middle;
}
.demo-actions {
display: inline-flex;
gap: 6px;
}
.demo-button {
padding: 2px 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 13px;
white-space: nowrap;
cursor: pointer;
}
.demo-button:hover {
background: var(--vp-c-default-soft);
}
</style>