Renderless Vue.js components that expose VueUse composable functionality through declarative template-based interfaces
—
Components for utility functions, time management, virtual scrolling, pagination, and other advanced features.
Provides reactive current timestamp with customizable update intervals.
/**
* Component that provides reactive timestamp
* @example
* <UseTimestamp v-slot="{ timestamp }">
* <div>Current time: {{ new Date(timestamp).toLocaleString() }}</div>
* </UseTimestamp>
*/
interface UseTimestampProps {
/** Offset to add to timestamp (ms) @default 0 */
offset?: number;
/** Update interval in milliseconds @default 1000 */
interval?: number | 'requestAnimationFrame';
/** Start immediately @default true */
immediate?: boolean;
/** Update on focus changes @default true */
controls?: boolean;
}
/** Slot data exposed by UseTimestamp component */
interface UseTimestampReturn {
/** Current timestamp in milliseconds */
timestamp: Ref<number>;
/** Pause timestamp updates */
pause: () => void;
/** Resume timestamp updates */
resume: () => void;
}Usage Examples:
<template>
<!-- Basic timestamp -->
<UseTimestamp v-slot="{ timestamp }">
<div class="timestamp-display">
<h3>Live Clock</h3>
<div class="clock">
<div class="time">{{ formatTime(timestamp) }}</div>
<div class="date">{{ formatDate(timestamp) }}</div>
</div>
</div>
</UseTimestamp>
<!-- High frequency timestamp -->
<UseTimestamp :interval="16" v-slot="{ timestamp }">
<div class="high-freq-demo">
<h3>High Frequency Timer (60 FPS)</h3>
<div class="milliseconds">{{ timestamp % 1000 }}ms</div>
<div class="animation-box" :style="{ transform: getAnimationTransform(timestamp) }">
🚀
</div>
</div>
</UseTimestamp>
<!-- Controlled timestamp -->
<UseTimestamp
:immediate="false"
:interval="100"
v-slot="{ timestamp, pause, resume }"
>
<div class="controlled-timestamp">
<h3>Controlled Timer</h3>
<div class="timer-display">{{ Math.floor((timestamp % 60000) / 1000) }}s</div>
<div class="timer-controls">
<button @click="resume">Start</button>
<button @click="pause">Stop</button>
</div>
</div>
</UseTimestamp>
<!-- Timezone display -->
<UseTimestamp v-slot="{ timestamp }">
<div class="timezone-demo">
<h3>World Clock</h3>
<div class="timezone-grid">
<div v-for="tz in timezones" :key="tz.zone" class="timezone-item">
<div class="tz-name">{{ tz.name }}</div>
<div class="tz-time">{{ formatTimeInTimezone(timestamp, tz.zone) }}</div>
</div>
</div>
</div>
</UseTimestamp>
</template>
<script setup>
import { ref } from 'vue';
import { UseTimestamp } from '@vueuse/components';
const timezones = [
{ name: 'New York', zone: 'America/New_York' },
{ name: 'London', zone: 'Europe/London' },
{ name: 'Tokyo', zone: 'Asia/Tokyo' },
{ name: 'Sydney', zone: 'Australia/Sydney' }
];
function formatTime(timestamp) {
return new Date(timestamp).toLocaleTimeString();
}
function formatDate(timestamp) {
return new Date(timestamp).toLocaleDateString();
}
function formatTimeInTimezone(timestamp, timezone) {
return new Date(timestamp).toLocaleTimeString('en-US', { timeZone: timezone });
}
function getAnimationTransform(timestamp) {
const angle = (timestamp / 10) % 360;
return `rotate(${angle}deg) translateX(50px)`;
}
</script>
<style>
.timestamp-display, .high-freq-demo, .controlled-timestamp, .timezone-demo {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
text-align: center;
}
.clock {
font-family: 'Courier New', monospace;
background: #1a1a1a;
color: #00ff00;
padding: 20px;
border-radius: 8px;
margin: 15px 0;
}
.time {
font-size: 2em;
font-weight: bold;
}
.date {
font-size: 1.2em;
margin-top: 5px;
opacity: 0.8;
}
.milliseconds {
font-family: 'Courier New', monospace;
font-size: 1.5em;
font-weight: bold;
color: #2196f3;
margin: 15px 0;
}
.animation-box {
display: inline-block;
font-size: 2em;
margin: 20px;
}
.timer-display {
font-size: 3em;
font-weight: bold;
color: #4caf50;
font-family: 'Courier New', monospace;
margin: 15px 0;
}
.timer-controls {
display: flex;
justify-content: center;
gap: 15px;
}
.timer-controls button {
padding: 10px 20px;
font-size: 16px;
border: none;
border-radius: 6px;
cursor: pointer;
background: #2196f3;
color: white;
}
.timezone-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.timezone-item {
padding: 15px;
border: 1px solid #ddd;
border-radius: 6px;
background: #f9f9f9;
}
.tz-name {
font-weight: bold;
margin-bottom: 8px;
color: #666;
}
.tz-time {
font-family: 'Courier New', monospace;
font-size: 1.1em;
color: #333;
}
</style>Formats timestamps as relative time strings with automatic updates.
/**
* Component that formats dates as relative time
* @example
* <UseTimeAgo :time="date" v-slot="{ timeAgo }">
* <div>{{ timeAgo }}</div>
* </UseTimeAgo>
*/
interface UseTimeAgoProps {
/** The date/time to format */
time: MaybeRefOrGetter<Date | number | string>;
/** Update interval in milliseconds @default 30000 */
updateInterval?: number;
/** Maximum unit to display @default 'month' */
max?: TimeAgoUnit;
/** Full date format when max exceeded @default 'YYYY-MM-DD' */
fullDateFormatter?: (date: Date) => string;
/** Custom messages for different time units */
messages?: TimeAgoMessages;
/** Show just now instead of 0 seconds @default false */
showSecond?: boolean;
/** Rounding method @default 'round' */
rounding?: 'floor' | 'ceil' | 'round';
}
/** Slot data exposed by UseTimeAgo component */
interface UseTimeAgoReturn {
/** Formatted relative time string */
timeAgo: Ref<string>;
}
type TimeAgoUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
interface TimeAgoMessages {
justNow?: string;
past?: string | ((n: string) => string);
future?: string | ((n: string) => string);
month?: string | string[];
year?: string | string[];
day?: string | string[];
week?: string | string[];
hour?: string | string[];
minute?: string | string[];
second?: string | string[];
}Usage Examples:
<template>
<!-- Basic time ago -->
<div class="time-ago-demo">
<h3>Time Ago Examples</h3>
<div class="time-examples">
<div v-for="example in timeExamples" :key="example.label" class="time-example">
<div class="example-label">{{ example.label }}:</div>
<UseTimeAgo :time="example.time" v-slot="{ timeAgo }">
<div class="example-time">{{ timeAgo }}</div>
</UseTimeAgo>
</div>
</div>
</div>
<!-- Custom messages -->
<UseTimeAgo
:time="customTime"
:messages="customMessages"
v-slot="{ timeAgo }"
>
<div class="custom-time-ago">
<h3>Custom Messages</h3>
<p>{{ timeAgo }}</p>
</div>
</UseTimeAgo>
<!-- Activity feed -->
<div class="activity-feed">
<h3>Activity Feed</h3>
<div class="activities">
<div v-for="activity in activities" :key="activity.id" class="activity-item">
<div class="activity-icon">{{ activity.icon }}</div>
<div class="activity-content">
<div class="activity-text">{{ activity.text }}</div>
<UseTimeAgo :time="activity.timestamp" v-slot="{ timeAgo }">
<div class="activity-time">{{ timeAgo }}</div>
</UseTimeAgo>
</div>
</div>
</div>
</div>
<!-- Blog posts with different formats -->
<div class="blog-demo">
<h3>Blog Posts</h3>
<div class="posts">
<div v-for="post in blogPosts" :key="post.id" class="post-item">
<h4>{{ post.title }}</h4>
<p>{{ post.excerpt }}</p>
<div class="post-meta">
<span>By {{ post.author }}</span>
<UseTimeAgo
:time="post.publishedAt"
:max="'week'"
:full-date-formatter="formatFullDate"
v-slot="{ timeAgo }"
>
<span class="post-time">{{ timeAgo }}</span>
</UseTimeAgo>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { UseTimeAgo } from '@vueuse/components';
const now = Date.now();
const timeExamples = [
{ label: 'Just now', time: now - 5000 },
{ label: '2 minutes ago', time: now - 2 * 60 * 1000 },
{ label: '1 hour ago', time: now - 60 * 60 * 1000 },
{ label: 'Yesterday', time: now - 25 * 60 * 60 * 1000 },
{ label: 'Last week', time: now - 8 * 24 * 60 * 60 * 1000 },
{ label: 'Last month', time: now - 35 * 24 * 60 * 60 * 1000 }
];
const customTime = ref(now - 30 * 60 * 1000);
const customMessages = {
justNow: 'right now',
past: n => `${n} ago`,
future: n => `in ${n}`,
second: ['second', 'seconds'],
minute: ['minute', 'minutes'],
hour: ['hour', 'hours'],
day: ['day', 'days'],
week: ['week', 'weeks'],
month: ['month', 'months'],
year: ['year', 'years']
};
const activities = ref([
{
id: 1,
icon: '📝',
text: 'John created a new document',
timestamp: now - 5 * 60 * 1000
},
{
id: 2,
icon: '💬',
text: 'Sarah commented on your post',
timestamp: now - 15 * 60 * 1000
},
{
id: 3,
icon: '❤️',
text: 'Mike liked your photo',
timestamp: now - 2 * 60 * 60 * 1000
},
{
id: 4,
icon: '🔗',
text: 'Anna shared your article',
timestamp: now - 6 * 60 * 60 * 1000
},
{
id: 5,
icon: '🎉',
text: 'You reached 100 followers!',
timestamp: now - 2 * 24 * 60 * 60 * 1000
}
]);
const blogPosts = ref([
{
id: 1,
title: 'Getting Started with Vue 3',
excerpt: 'Learn the basics of Vue 3 composition API...',
author: 'Alex Chen',
publishedAt: now - 3 * 24 * 60 * 60 * 1000
},
{
id: 2,
title: 'Advanced TypeScript Tips',
excerpt: 'Discover advanced TypeScript patterns...',
author: 'Maria Garcia',
publishedAt: now - 10 * 24 * 60 * 60 * 1000
},
{
id: 3,
title: 'Building Scalable Applications',
excerpt: 'Best practices for large-scale development...',
author: 'David Kim',
publishedAt: now - 45 * 24 * 60 * 60 * 1000
}
]);
function formatFullDate(date) {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
</script>
<style>
.time-ago-demo, .custom-time-ago, .activity-feed, .blog-demo {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
}
.time-examples {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.time-example {
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
background: #f9f9f9;
}
.example-label {
font-weight: bold;
color: #666;
margin-bottom: 8px;
}
.example-time {
color: #2196f3;
font-size: 1.1em;
}
.custom-time-ago {
text-align: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.activities {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 15px;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 15px;
border: 1px solid #eee;
border-radius: 8px;
background: #fafafa;
}
.activity-icon {
font-size: 1.5em;
flex-shrink: 0;
}
.activity-content {
flex: 1;
}
.activity-text {
font-weight: 500;
margin-bottom: 4px;
}
.activity-time {
font-size: 0.9em;
color: #666;
}
.posts {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 15px;
}
.post-item {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
}
.post-item h4 {
margin: 0 0 10px 0;
color: #333;
}
.post-item p {
margin: 0 0 15px 0;
color: #666;
line-height: 1.5;
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9em;
color: #888;
border-top: 1px solid #eee;
padding-top: 15px;
}
.post-time {
font-weight: 500;
}
</style>Implements virtual scrolling for large datasets with optimal performance.
/**
* Component that implements virtual scrolling for large lists
* @example
* <UseVirtualList :list="items" :options="{ itemHeight: 50 }" v-slot="{ list, containerProps, wrapperProps }">
* <div v-bind="containerProps">
* <div v-bind="wrapperProps">
* <div v-for="{ data, index } in list" :key="index">{{ data.name }}</div>
* </div>
* </div>
* </UseVirtualList>
*/
interface UseVirtualListProps<T = any> {
/** Array of items to virtualize */
list: T[];
/** Virtual list options */
options: UseVirtualListOptions;
/** Container height @default 300 */
height?: number;
}
/** Slot data exposed by UseVirtualList component */
interface UseVirtualListReturn<T = any> {
/** Visible list items with their data and indices */
list: ComputedRef<UseVirtualListItem<T>[]>;
/** Props to bind to the scrollable container */
containerProps: ComputedRef<UseVirtualListContainerProps>;
/** Props to bind to the wrapper element */
wrapperProps: ComputedRef<UseVirtualListWrapperProps>;
/** Scroll to specific index */
scrollTo: (index: number) => void;
}
interface UseVirtualListOptions {
/** Height of each item in pixels @default 50 */
itemHeight: number | ((index: number) => number);
/** Number of extra items to render outside viewport @default 5 */
overscan?: number;
}
interface UseVirtualListItem<T = any> {
data: T;
index: number;
}
interface UseVirtualListContainerProps {
ref: Ref<HTMLElement | undefined>;
onScroll: (e: Event) => void;
style: CSSProperties;
}
interface UseVirtualListWrapperProps {
style: ComputedRef<CSSProperties>;
}Usage Examples:
<template>
<!-- Basic virtual list -->
<div class="virtual-list-demo">
<h3>Virtual List - {{ largeList.length.toLocaleString() }} Items</h3>
<UseVirtualList
:list="largeList"
:options="{ itemHeight: 50, overscan: 10 }"
:height="400"
v-slot="{ list, containerProps, wrapperProps, scrollTo }"
>
<div class="virtual-controls">
<button @click="scrollTo(0)">Go to Top</button>
<button @click="scrollTo(Math.floor(largeList.length / 2))">Go to Middle</button>
<button @click="scrollTo(largeList.length - 1)">Go to Bottom</button>
<input
type="number"
:max="largeList.length - 1"
placeholder="Jump to index"
@keyup.enter="scrollTo(parseInt($event.target.value))"
/>
</div>
<div v-bind="containerProps" class="virtual-container">
<div v-bind="wrapperProps" class="virtual-wrapper">
<div
v-for="{ data, index } in list"
:key="index"
class="virtual-item"
>
<div class="item-index">#{{ index }}</div>
<div class="item-name">{{ data.name }}</div>
<div class="item-value">{{ data.value }}</div>
</div>
</div>
</div>
</UseVirtualList>
</div>
<!-- Variable height virtual list -->
<div class="variable-height-demo">
<h3>Variable Height Virtual List</h3>
<UseVirtualList
:list="variableHeightList"
:options="{ itemHeight: getItemHeight, overscan: 3 }"
:height="350"
v-slot="{ list, containerProps, wrapperProps }"
>
<div v-bind="containerProps" class="variable-container">
<div v-bind="wrapperProps" class="variable-wrapper">
<div
v-for="{ data, index } in list"
:key="index"
class="variable-item"
:class="data.type"
>
<div class="item-header">
<span class="item-type">{{ data.type }}</span>
<span class="item-id">#{{ index }}</span>
</div>
<div class="item-content">{{ data.content }}</div>
<div v-if="data.details" class="item-details">
{{ data.details }}
</div>
</div>
</div>
</div>
</UseVirtualList>
</div>
<!-- Chat message virtual list -->
<div class="chat-demo">
<h3>Virtual Chat Messages</h3>
<UseVirtualList
:list="chatMessages"
:options="{ itemHeight: getChatMessageHeight, overscan: 5 }"
:height="300"
v-slot="{ list, containerProps, wrapperProps, scrollTo }"
>
<div class="chat-controls">
<button @click="addMessage">Add Message</button>
<button @click="scrollTo(chatMessages.length - 1)">Scroll to Latest</button>
</div>
<div v-bind="containerProps" class="chat-container">
<div v-bind="wrapperProps" class="chat-wrapper">
<div
v-for="{ data, index } in list"
:key="index"
class="chat-message"
:class="{ own: data.isOwn }"
>
<div class="message-info">
<span class="message-author">{{ data.author }}</span>
<span class="message-time">{{ formatChatTime(data.timestamp) }}</span>
</div>
<div class="message-text">{{ data.text }}</div>
</div>
</div>
</div>
</UseVirtualList>
</div>
<!-- Performance comparison -->
<div class="performance-demo">
<h3>Performance Comparison</h3>
<div class="performance-controls">
<button @click="showVirtual = !showVirtual">
{{ showVirtual ? 'Show Regular List' : 'Show Virtual List' }}
</button>
<span class="performance-info">
Rendering {{ showVirtual ? 'virtualized' : 'all' }} items
</span>
</div>
<div v-if="!showVirtual" class="regular-list">
<div
v-for="(item, index) in performanceList.slice(0, 1000)"
:key="index"
class="performance-item"
>
{{ item.name }} - {{ item.description }}
</div>
</div>
<UseVirtualList
v-else
:list="performanceList"
:options="{ itemHeight: 60 }"
:height="400"
v-slot="{ list, containerProps, wrapperProps }"
>
<div v-bind="containerProps" class="performance-container">
<div v-bind="wrapperProps" class="performance-wrapper">
<div
v-for="{ data, index } in list"
:key="index"
class="performance-item"
>
{{ data.name }} - {{ data.description }}
</div>
</div>
</div>
</UseVirtualList>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { UseVirtualList } from '@vueuse/components';
// Generate large list
const largeList = ref([]);
for (let i = 0; i < 100000; i++) {
largeList.value.push({
name: `Item ${i + 1}`,
value: Math.floor(Math.random() * 1000)
});
}
// Variable height list
const variableHeightList = ref([]);
const itemTypes = ['header', 'content', 'footer'];
for (let i = 0; i < 1000; i++) {
const type = itemTypes[Math.floor(Math.random() * itemTypes.length)];
variableHeightList.value.push({
type,
content: `This is ${type} item ${i + 1}`,
details: type === 'content' ? `Additional details for item ${i + 1}` : null
});
}
// Chat messages
const chatMessages = ref([]);
const authors = ['Alice', 'Bob', 'Charlie', 'Diana'];
for (let i = 0; i < 500; i++) {
const author = authors[Math.floor(Math.random() * authors.length)];
chatMessages.value.push({
author,
text: `Message ${i + 1}: ${generateRandomMessage()}`,
timestamp: Date.now() - (500 - i) * 60000,
isOwn: author === 'Alice'
});
}
// Performance list
const performanceList = ref([]);
for (let i = 0; i < 50000; i++) {
performanceList.value.push({
name: `Performance Item ${i + 1}`,
description: `This is a description for performance testing item number ${i + 1}`
});
}
const showVirtual = ref(true);
function getItemHeight(index) {
const item = variableHeightList.value[index];
if (!item) return 50;
switch (item.type) {
case 'header': return 80;
case 'content': return item.details ? 120 : 80;
case 'footer': return 60;
default: return 50;
}
}
function getChatMessageHeight(index) {
const message = chatMessages.value[index];
if (!message) return 60;
// Estimate height based on message length
const baseHeight = 60;
const extraHeight = Math.ceil(message.text.length / 50) * 20;
return Math.min(baseHeight + extraHeight, 150);
}
function addMessage() {
const newMessage = {
author: 'Alice',
text: `New message: ${generateRandomMessage()}`,
timestamp: Date.now(),
isOwn: true
};
chatMessages.value.push(newMessage);
}
function generateRandomMessage() {
const messages = [
"Hello everyone!",
"How are you doing today?",
"This is a test message.",
"Virtual scrolling is amazing!",
"Performance is great with large lists.",
"Check out this new feature.",
"What do you think about this implementation?",
"Looking forward to your feedback!"
];
return messages[Math.floor(Math.random() * messages.length)];
}
function formatChatTime(timestamp) {
return new Date(timestamp).toLocaleTimeString();
}
</script>
<style>
.virtual-list-demo, .variable-height-demo, .chat-demo, .performance-demo {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
}
.virtual-controls, .chat-controls, .performance-controls {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
}
.virtual-controls button, .chat-controls button, .performance-controls button {
padding: 6px 12px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
background: #f5f5f5;
}
.virtual-controls input {
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
width: 120px;
}
.virtual-container, .variable-container, .chat-container, .performance-container {
border: 2px solid #eee;
border-radius: 6px;
background: #fafafa;
}
.virtual-item, .variable-item, .performance-item {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
border-bottom: 1px solid #eee;
background: white;
}
.virtual-item:hover, .variable-item:hover, .performance-item:hover {
background: #f0f8ff;
}
.item-index {
font-weight: bold;
color: #666;
min-width: 60px;
}
.item-name {
font-weight: bold;
flex: 1;
}
.item-value {
color: #2196f3;
font-family: monospace;
}
.variable-item.header {
background: #e3f2fd;
font-weight: bold;
}
.variable-item.footer {
background: #f3e5f5;
font-style: italic;
}
.item-header {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
}
.item-type {
text-transform: uppercase;
font-size: 0.8em;
font-weight: bold;
padding: 2px 8px;
border-radius: 4px;
background: #ddd;
}
.item-content {
margin-top: 8px;
}
.item-details {
margin-top: 5px;
font-size: 0.9em;
color: #666;
}
.chat-message {
padding: 12px 15px;
border-bottom: 1px solid #eee;
background: white;
}
.chat-message.own {
background: #e8f5e8;
}
.message-info {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 0.9em;
}
.message-author {
font-weight: bold;
color: #333;
}
.message-time {
color: #666;
}
.message-text {
line-height: 1.4;
}
.regular-list {
height: 400px;
border: 2px solid #eee;
border-radius: 6px;
overflow-y: auto;
background: #fafafa;
}
.performance-info {
font-size: 0.9em;
color: #666;
font-style: italic;
}
</style>Manages offset-based pagination with reactive state and event emissions.
/**
* Component that manages offset-based pagination
* @example
* <UseOffsetPagination
* v-slot="{ currentPage, pageSize, pageCount, isFirstPage, isLastPage, prev, next }"
* :total="1000"
* :page-size="20"
* >
* <div>Page {{ currentPage }} of {{ pageCount }}</div>
* </UseOffsetPagination>
*/
interface UseOffsetPaginationProps {
/** Total number of items */
total: MaybeRefOrGetter<number>;
/** Number of items per page @default 10 */
pageSize?: MaybeRefOrGetter<number>;
/** Current page number @default 1 */
page?: MaybeRefOrGetter<number>;
/** Callback when page changes */
onPageChange?: (returnValue: UseOffsetPaginationReturn) => unknown;
/** Callback when page size changes */
onPageSizeChange?: (returnValue: UseOffsetPaginationReturn) => unknown;
/** Callback when page count changes */
onPageCountChange?: (returnValue: UseOffsetPaginationReturn) => unknown;
}
/** Slot data exposed by UseOffsetPagination component */
interface UseOffsetPaginationReturn {
/** Current page number */
currentPage: Ref<number>;
/** Current page size */
pageSize: Ref<number>;
/** Total number of pages */
pageCount: ComputedRef<number>;
/** Whether on first page */
isFirstPage: ComputedRef<boolean>;
/** Whether on last page */
isLastPage: ComputedRef<boolean>;
/** Go to previous page */
prev: () => void;
/** Go to next page */
next: () => void;
/** Go to specific page */
goToPage: (page: number) => void;
}
/** Component events */
interface UseOffsetPaginationEmits {
/** Emitted when page changes */
'page-change': (returnValue: UseOffsetPaginationReturn) => void;
/** Emitted when page size changes */
'page-size-change': (returnValue: UseOffsetPaginationReturn) => void;
/** Emitted when page count changes */
'page-count-change': (returnValue: UseOffsetPaginationReturn) => void;
}Usage Examples:
<template>
<!-- Basic pagination -->
<div class="pagination-demo">
<h3>Basic Pagination</h3>
<UseOffsetPagination
:total="500"
:page-size="20"
v-slot="{ currentPage, pageSize, pageCount, isFirstPage, isLastPage, prev, next, goToPage }"
@page-change="handlePageChange"
>
<div class="data-section">
<div class="data-info">
Showing {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, 500) }} of 500 items
</div>
<div class="data-grid">
<div v-for="item in getCurrentPageData(currentPage, pageSize)" :key="item.id" class="data-item">
{{ item.name }} - {{ item.description }}
</div>
</div>
</div>
<div class="pagination-controls">
<button @click="prev" :disabled="isFirstPage" class="nav-btn">
← Previous
</button>
<div class="page-numbers">
<button
v-for="page in getVisiblePages(currentPage, pageCount)"
:key="page"
@click="goToPage(page)"
:class="{ active: page === currentPage, ellipsis: page === '...' }"
:disabled="page === '...'"
class="page-btn"
>
{{ page }}
</button>
</div>
<button @click="next" :disabled="isLastPage" class="nav-btn">
Next →
</button>
</div>
<div class="pagination-info">
Page {{ currentPage }} of {{ pageCount }}
</div>
</UseOffsetPagination>
</div>
<!-- Advanced pagination with size selector -->
<div class="advanced-pagination">
<h3>Advanced Pagination</h3>
<UseOffsetPagination
:total="advancedTotal"
:page-size="advancedPageSize"
:page="advancedPage"
v-slot="pagination"
@page-change="handleAdvancedPageChange"
@page-size-change="handlePageSizeChange"
>
<div class="advanced-controls">
<label class="page-size-selector">
Items per page:
<select v-model="advancedPageSize">
<option :value="10">10</option>
<option :value="25">25</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</label>
<div class="jump-to-page">
<label>
Jump to page:
<input
type="number"
:min="1"
:max="pagination.pageCount"
@keyup.enter="pagination.goToPage(parseInt($event.target.value))"
class="page-input"
/>
</label>
</div>
</div>
<div class="advanced-data">
<div class="results-summary">
Results {{ (pagination.currentPage - 1) * pagination.pageSize + 1 }}-{{
Math.min(pagination.currentPage * pagination.pageSize, advancedTotal)
}} of {{ advancedTotal.toLocaleString() }}
</div>
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<tr v-for="item in getAdvancedPageData(pagination.currentPage, pagination.pageSize)" :key="item.id">
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.category }}</td>
<td>
<span :class="`status-${item.status}`" class="status-badge">
{{ item.status }}
</span>
</td>
<td>{{ formatDate(item.created) }}</td>
</tr>
</tbody>
</table>
<div class="table-pagination">
<button @click="pagination.prev" :disabled="pagination.isFirstPage">
← Prev
</button>
<span class="page-info">
{{ pagination.currentPage }} / {{ pagination.pageCount }}
</span>
<button @click="pagination.next" :disabled="pagination.isLastPage">
Next →
</button>
</div>
</div>
</UseOffsetPagination>
</div>
<!-- Search with pagination -->
<div class="search-pagination">
<h3>Search with Pagination</h3>
<div class="search-controls">
<input
v-model="searchQuery"
placeholder="Search items..."
class="search-input"
@input="handleSearch"
/>
<button @click="clearSearch" class="clear-btn">Clear</button>
</div>
<UseOffsetPagination
:total="filteredTotal"
:page-size="10"
:page="searchPage"
v-slot="pagination"
@page-change="handleSearchPageChange"
>
<div v-if="searchQuery" class="search-results-info">
Found {{ filteredTotal }} results for "{{ searchQuery }}"
</div>
<div class="search-results">
<div v-if="filteredTotal === 0" class="no-results">
No results found for "{{ searchQuery }}"
</div>
<div v-else class="results-list">
<div
v-for="item in getFilteredPageData(pagination.currentPage, pagination.pageSize)"
:key="item.id"
class="result-item"
>
<div class="result-title">{{ highlightSearchTerm(item.name, searchQuery) }}</div>
<div class="result-description">{{ highlightSearchTerm(item.description, searchQuery) }}</div>
</div>
</div>
</div>
<div v-if="filteredTotal > 0" class="search-pagination-controls">
<button @click="pagination.prev" :disabled="pagination.isFirstPage">
← Previous
</button>
<span>{{ pagination.currentPage }} of {{ pagination.pageCount }}</span>
<button @click="pagination.next" :disabled="pagination.isLastPage">
Next →
</button>
</div>
</UseOffsetPagination>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { UseOffsetPagination } from '@vueuse/components';
// Basic pagination data
const basicData = ref([]);
for (let i = 1; i <= 500; i++) {
basicData.value.push({
id: i,
name: `Item ${i}`,
description: `Description for item ${i}`
});
}
// Advanced pagination data
const advancedPageSize = ref(25);
const advancedPage = ref(1);
const advancedTotal = 2547;
const advancedData = ref([]);
const categories = ['Electronics', 'Clothing', 'Books', 'Home & Garden', 'Sports'];
const statuses = ['active', 'inactive', 'pending'];
for (let i = 1; i <= advancedTotal; i++) {
advancedData.value.push({
id: i,
name: `Product ${i}`,
category: categories[Math.floor(Math.random() * categories.length)],
status: statuses[Math.floor(Math.random() * statuses.length)],
created: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000)
});
}
// Search pagination
const searchQuery = ref('');
const searchPage = ref(1);
const searchData = ref([]);
// Initialize search data
for (let i = 1; i <= 1000; i++) {
searchData.value.push({
id: i,
name: `Item ${i} - ${generateRandomWords()}`,
description: `This is a detailed description for item ${i}. ${generateRandomDescription()}`
});
}
const filteredData = computed(() => {
if (!searchQuery.value) return searchData.value;
const query = searchQuery.value.toLowerCase();
return searchData.value.filter(item =>
item.name.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query)
);
});
const filteredTotal = computed(() => filteredData.value.length);
function getCurrentPageData(page, pageSize) {
const start = (page - 1) * pageSize;
const end = start + pageSize;
return basicData.value.slice(start, end);
}
function getAdvancedPageData(page, pageSize) {
const start = (page - 1) * pageSize;
const end = start + pageSize;
return advancedData.value.slice(start, end);
}
function getFilteredPageData(page, pageSize) {
const start = (page - 1) * pageSize;
const end = start + pageSize;
return filteredData.value.slice(start, end);
}
function getVisiblePages(currentPage, totalPages) {
const pages = [];
const maxVisible = 7;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
if (currentPage > 4) {
pages.push('...');
}
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 3) {
pages.push('...');
}
if (totalPages > 1) {
pages.push(totalPages);
}
}
return pages;
}
function handlePageChange(pagination) {
console.log('Page changed:', pagination.currentPage);
}
function handleAdvancedPageChange(pagination) {
advancedPage.value = pagination.currentPage;
}
function handlePageSizeChange(pagination) {
advancedPageSize.value = pagination.pageSize;
advancedPage.value = 1; // Reset to first page
}
function handleSearch() {
searchPage.value = 1; // Reset to first page on search
}
function handleSearchPageChange(pagination) {
searchPage.value = pagination.currentPage;
}
function clearSearch() {
searchQuery.value = '';
searchPage.value = 1;
}
function highlightSearchTerm(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
function formatDate(date) {
return date.toLocaleDateString();
}
function generateRandomWords() {
const words = ['Amazing', 'Fantastic', 'Great', 'Awesome', 'Cool', 'Nice', 'Super', 'Mega'];
return words[Math.floor(Math.random() * words.length)];
}
function generateRandomDescription() {
const descriptions = [
'High quality product with excellent features.',
'Perfect for everyday use and special occasions.',
'Durable construction with modern design.',
'Affordable pricing with premium quality.',
'Innovative technology meets practical design.'
];
return descriptions[Math.floor(Math.random() * descriptions.length)];
}
</script>
<style>
.pagination-demo, .advanced-pagination, .search-pagination {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
}
.data-section {
margin-bottom: 20px;
}
.data-info {
margin-bottom: 15px;
font-weight: 500;
color: #666;
}
.data-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.data-item, .result-item {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: #f9f9f9;
}
.pagination-controls, .table-pagination, .search-pagination-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin: 20px 0;
}
.nav-btn, .page-btn {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
background: white;
}
.nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-numbers {
display: flex;
gap: 2px;
}
.page-btn.active {
background: #2196f3;
color: white;
border-color: #2196f3;
}
.page-btn.ellipsis {
cursor: default;
background: transparent;
border: none;
}
.pagination-info {
text-align: center;
color: #666;
font-size: 0.9em;
}
.advanced-controls {
display: flex;
gap: 20px;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
}
.page-size-selector select, .page-input {
margin-left: 8px;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.page-input {
width: 80px;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}
.data-table th, .data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.data-table th {
background: #f5f5f5;
font-weight: 600;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
text-transform: uppercase;
}
.status-active {
background: #e8f5e8;
color: #2e7d32;
}
.status-inactive {
background: #ffebee;
color: #c62828;
}
.status-pending {
background: #fff3e0;
color: #f57c00;
}
.search-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.search-input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.clear-btn {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
background: #f5f5f5;
}
.search-results-info {
margin-bottom: 15px;
padding: 10px;
background: #e3f2fd;
border-radius: 4px;
color: #1976d2;
}
.no-results {
text-align: center;
padding: 40px;
color: #666;
font-style: italic;
}
.results-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.result-title {
font-weight: bold;
margin-bottom: 5px;
}
.result-description {
color: #666;
font-size: 0.9em;
}
:global(mark) {
background: #ffeb3b;
padding: 0 2px;
border-radius: 2px;
}
</style>/**
* Component that provides network connectivity information
*/
interface UseNetworkReturn {
isOnline: Ref<boolean>;
downlink: Ref<number | undefined>;
downlinkMax: Ref<number | undefined>;
effectiveType: Ref<string | undefined>;
saveData: Ref<boolean | undefined>;
type: Ref<string | undefined>;
}/**
* Component that tracks online/offline status
*/
interface UseOnlineReturn {
isOnline: Ref<boolean>;
}/**
* Component that tracks user idle state
*/
interface UseIdleReturn {
idle: Ref<boolean>;
lastActive: Ref<number>;
reset: () => void;
}/**
* Component that manages image loading state
*/
interface UseImageReturn {
isLoading: Ref<boolean>;
error: Ref<Event | null>;
isReady: Ref<boolean>;
}/**
* Component that creates and manages object URLs
*/
interface UseObjectUrlReturn {
url: Ref<string | undefined>;
release: () => void;
}/** Common types used across utility and advanced components */
type MaybeRefOrGetter<T> = T | Ref<T> | (() => T);
interface RenderableComponent {
/** The element that the component should be rendered as @default 'div' */
as?: object | string;
}
/** Time and timestamp types */
interface UseTimestampOptions {
offset?: number;
interval?: number | 'requestAnimationFrame';
immediate?: boolean;
controls?: boolean;
}
/** Pagination types */
interface UseOffsetPaginationOptions {
total: MaybeRefOrGetter<number>;
pageSize?: MaybeRefOrGetter<number>;
page?: MaybeRefOrGetter<number>;
}
/** Virtual list types */
interface UseVirtualListItem<T = any> {
data: T;
index: number;
}
/** Network information types */
interface NetworkInformation extends EventTarget {
readonly connection?: NetworkConnection;
readonly onLine: boolean;
}
interface NetworkConnection {
readonly downlink: number;
readonly downlinkMax: number;
readonly effectiveType: '2g' | '3g' | '4g' | 'slow-2g';
readonly rtt: number;
readonly saveData: boolean;
readonly type: ConnectionType;
}
type ConnectionType = 'bluetooth' | 'cellular' | 'ethernet' | 'mixed' | 'none' | 'other' | 'unknown' | 'wifi' | 'wimax';Install with Tessl CLI
npx tessl i tessl/npm-vueuse--components