Renderless Vue.js components that expose VueUse composable functionality through declarative template-based interfaces
—
Directives for handling scroll events, resize observations, and scroll locking.
Directive for handling scroll events with customizable options.
/**
* Directive for handling scroll events
* @example
* <div v-scroll="handleScroll">Scrollable content</div>
* <div v-scroll="[handleScroll, { throttle: 100 }]">With options</div>
*/
type ScrollHandler = (event: Event) => void;
interface VScrollValue {
/** Simple handler function */
handler: ScrollHandler;
/** Handler with options tuple */
handlerWithOptions: [ScrollHandler, UseScrollOptions];
}
interface UseScrollOptions {
/** Throttle scroll events (ms) @default 0 */
throttle?: number;
/** Idle timeout (ms) @default 200 */
idle?: number;
/** Offset threshold before triggering */
offset?: ScrollOffset;
/** Event listener options */
eventListenerOptions?: boolean | AddEventListenerOptions;
/** Behavior when element changes */
behavior?: ScrollBehavior;
/** On scroll callback */
onScroll?: (e: Event) => void;
/** On scroll end callback */
onStop?: (e: Event) => void;
}
interface ScrollOffset {
top?: number;
bottom?: number;
right?: number;
left?: number;
}
type ScrollBehavior = 'auto' | 'smooth';Usage Examples:
<template>
<!-- Basic scroll handling -->
<div
v-scroll="handleScroll"
class="scroll-container"
style="height: 300px; overflow-y: auto;"
>
<div class="scroll-content">
<h3>Scrollable Content</h3>
<p v-for="i in 20" :key="i">
This is line {{ i }} of scrollable content. Scroll to see the handler in action.
</p>
</div>
</div>
<!-- Throttled scroll with options -->
<div
v-scroll="[handleThrottledScroll, { throttle: 100, idle: 500 }]"
class="throttled-scroll"
style="height: 200px; overflow-y: auto;"
>
<div class="scroll-info">
<p>Throttled scroll events (100ms)</p>
<p>Scroll position: {{ scrollPosition }}</p>
<p>Is scrolling: {{ isScrolling ? 'Yes' : 'No' }}</p>
</div>
<div v-for="i in 15" :key="i" class="scroll-item">
Item {{ i }}
</div>
</div>
<!-- Scroll with callbacks -->
<div
v-scroll="[null, {
onScroll: handleScrollEvent,
onStop: handleScrollStop,
offset: { top: 50, bottom: 50 }
}]"
class="callback-scroll"
style="height: 250px; overflow-y: auto;"
>
<div class="callback-info">
<p>Scroll with callbacks and offset</p>
<p>Scroll events: {{ scrollEventCount }}</p>
<p>Stop events: {{ stopEventCount }}</p>
</div>
<div v-for="i in 25" :key="i" class="callback-item">
Callback item {{ i }}
</div>
</div>
<!-- Window scroll tracking -->
<div v-scroll="handleWindowScroll">
<h3>Window Scroll Tracking</h3>
<p>Window scroll Y: {{ windowScrollY }}px</p>
<div style="height: 1500px; background: linear-gradient(to bottom, #f0f0f0, #e0e0e0);">
<p style="position: sticky; top: 20px; padding: 20px; background: white; margin: 50px;">
Scroll the page to see window scroll tracking in action
</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { vScroll } from '@vueuse/components';
const scrollPosition = ref(0);
const isScrolling = ref(false);
const scrollEventCount = ref(0);
const stopEventCount = ref(0);
const windowScrollY = ref(0);
function handleScroll(event) {
console.log('Scroll event:', event.target.scrollTop);
}
function handleThrottledScroll(event) {
scrollPosition.value = event.target.scrollTop;
isScrolling.value = true;
// Reset scrolling state after a delay
setTimeout(() => {
isScrolling.value = false;
}, 600);
}
function handleScrollEvent(event) {
scrollEventCount.value++;
}
function handleScrollStop(event) {
stopEventCount.value++;
}
function handleWindowScroll(event) {
windowScrollY.value = window.scrollY;
}
</script>
<style>
.scroll-container, .throttled-scroll, .callback-scroll {
border: 2px solid #ddd;
border-radius: 8px;
margin: 15px 0;
background: #f9f9f9;
}
.scroll-content, .scroll-info, .callback-info {
padding: 15px;
background: white;
margin-bottom: 10px;
border-radius: 6px;
}
.scroll-item, .callback-item {
padding: 10px 15px;
margin: 5px 15px;
background: white;
border-radius: 4px;
border-left: 3px solid #2196f3;
}
</style>Directive for observing element size changes using ResizeObserver.
/**
* Directive for observing element resize
* @example
* <div v-resize-observer="handleResize">Resizable element</div>
* <div v-resize-observer="[handleResize, { box: 'border-box' }]">With options</div>
*/
type ResizeObserverHandler = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
interface VResizeObserverValue {
/** Simple handler function */
handler: ResizeObserverHandler;
/** Handler with options tuple */
handlerWithOptions: [ResizeObserverHandler, UseResizeObserverOptions];
}
interface UseResizeObserverOptions {
/** ResizeObserver box type @default 'content-box' */
box?: ResizeObserverBoxOptions;
}
type ResizeObserverBoxOptions = 'border-box' | 'content-box' | 'device-pixel-content-box';
interface ResizeObserverEntry {
readonly borderBoxSize: ReadonlyArray<ResizeObserverSize>;
readonly contentBoxSize: ReadonlyArray<ResizeObserverSize>;
readonly contentRect: DOMRectReadOnly;
readonly devicePixelContentBoxSize: ReadonlyArray<ResizeObserverSize>;
readonly target: Element;
}
interface ResizeObserverSize {
readonly blockSize: number;
readonly inlineSize: number;
}Usage Examples:
<template>
<!-- Basic resize observation -->
<div class="resize-demo">
<h3>Resize Observer Demo</h3>
<textarea
v-resize-observer="handleBasicResize"
v-model="textContent"
class="resizable-textarea"
placeholder="Resize this textarea to see the observer in action"
></textarea>
<p>Textarea size: {{ textareaSize.width }} × {{ textareaSize.height }}</p>
</div>
<!-- Box model observation -->
<div class="box-model-demo">
<h3>Box Model Observation</h3>
<div
v-resize-observer="[handleBoxResize, { box: 'border-box' }]"
class="border-box-element"
:style="{ width: boxWidth + 'px', height: boxHeight + 'px' }"
>
<div class="box-content">
<p>Border-box sizing</p>
<p>Size: {{ borderBoxSize.width }} × {{ borderBoxSize.height }}</p>
</div>
</div>
<div class="size-controls">
<label>Width: <input v-model.number="boxWidth" type="range" min="200" max="500" /></label>
<label>Height: <input v-model.number="boxHeight" type="range" min="100" max="300" /></label>
</div>
</div>
<!-- Content box observation -->
<div class="content-box-demo">
<h3>Content Box vs Border Box</h3>
<div class="comparison">
<div
v-resize-observer="[handleContentBoxResize, { box: 'content-box' }]"
class="content-box"
>
<h4>Content Box</h4>
<p>Content: {{ contentBoxSize.width }} × {{ contentBoxSize.height }}</p>
</div>
<div
v-resize-observer="[handleBorderBoxResize, { box: 'border-box' }]"
class="border-box"
>
<h4>Border Box</h4>
<p>Border: {{ borderBoxCompare.width }} × {{ borderBoxCompare.height }}</p>
</div>
</div>
</div>
<!-- Responsive component -->
<div class="responsive-demo">
<h3>Responsive Component</h3>
<div
v-resize-observer="handleResponsiveResize"
class="responsive-container"
:class="responsiveClass"
>
<div class="responsive-content">
<h4>{{ responsiveTitle }}</h4>
<p>Container width: {{ containerWidth }}px</p>
<p>Layout: {{ responsiveClass }}</p>
</div>
</div>
<p class="responsive-info">
Resize the browser window to see responsive changes
</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { vResizeObserver } from '@vueuse/components';
const textContent = ref('Resize me!');
const textareaSize = ref({ width: 0, height: 0 });
const boxWidth = ref(300);
const boxHeight = ref(200);
const borderBoxSize = ref({ width: 0, height: 0 });
const contentBoxSize = ref({ width: 0, height: 0 });
const borderBoxCompare = ref({ width: 0, height: 0 });
const containerWidth = ref(0);
const responsiveClass = computed(() => {
if (containerWidth.value < 400) return 'mobile';
if (containerWidth.value < 768) return 'tablet';
return 'desktop';
});
const responsiveTitle = computed(() => {
switch (responsiveClass.value) {
case 'mobile': return 'Mobile Layout';
case 'tablet': return 'Tablet Layout';
case 'desktop': return 'Desktop Layout';
default: return 'Unknown Layout';
}
});
function handleBasicResize(entries) {
const entry = entries[0];
if (entry) {
textareaSize.value = {
width: Math.round(entry.contentRect.width),
height: Math.round(entry.contentRect.height)
};
}
}
function handleBoxResize(entries) {
const entry = entries[0];
if (entry && entry.borderBoxSize?.[0]) {
borderBoxSize.value = {
width: Math.round(entry.borderBoxSize[0].inlineSize),
height: Math.round(entry.borderBoxSize[0].blockSize)
};
}
}
function handleContentBoxResize(entries) {
const entry = entries[0];
if (entry && entry.contentBoxSize?.[0]) {
contentBoxSize.value = {
width: Math.round(entry.contentBoxSize[0].inlineSize),
height: Math.round(entry.contentBoxSize[0].blockSize)
};
}
}
function handleBorderBoxResize(entries) {
const entry = entries[0];
if (entry && entry.borderBoxSize?.[0]) {
borderBoxCompare.value = {
width: Math.round(entry.borderBoxSize[0].inlineSize),
height: Math.round(entry.borderBoxSize[0].blockSize)
};
}
}
function handleResponsiveResize(entries) {
const entry = entries[0];
if (entry) {
containerWidth.value = Math.round(entry.contentRect.width);
}
}
</script>
<style>
.resize-demo, .box-model-demo, .content-box-demo, .responsive-demo {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
}
.resizable-textarea {
width: 100%;
min-height: 100px;
padding: 10px;
border: 2px solid #ddd;
border-radius: 4px;
resize: both;
font-family: inherit;
}
.border-box-element {
border: 10px solid #2196f3;
padding: 20px;
background: #e3f2fd;
border-radius: 8px;
margin: 15px 0;
transition: all 0.3s;
box-sizing: border-box;
}
.box-content {
text-align: center;
}
.size-controls {
display: flex;
gap: 20px;
margin-top: 15px;
}
.size-controls label {
display: flex;
flex-direction: column;
gap: 5px;
}
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 15px;
}
.content-box, .border-box {
padding: 15px;
border: 5px solid #4caf50;
border-radius: 6px;
background: #e8f5e8;
text-align: center;
}
.responsive-container {
border: 2px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
transition: all 0.3s;
min-height: 150px;
display: flex;
align-items: center;
justify-content: center;
}
.responsive-container.mobile {
background: #ffebee;
border-color: #f44336;
}
.responsive-container.tablet {
background: #e8f5e8;
border-color: #4caf50;
}
.responsive-container.desktop {
background: #e3f2fd;
border-color: #2196f3;
}
.responsive-content {
text-align: center;
}
.responsive-info {
font-size: 0.9em;
color: #666;
font-style: italic;
text-align: center;
}
</style>Directive for preventing or controlling scroll behavior.
/**
* Directive for scroll locking
* @example
* <div v-scroll-lock="true">Scroll locked</div>
* <div v-scroll-lock="scrollLockOptions">With options</div>
*/
interface VScrollLockValue {
/** Simple boolean to enable/disable */
enabled: boolean;
/** Options object for advanced control */
options: ScrollLockOptions;
}
interface ScrollLockOptions {
/** Whether scroll lock is enabled @default true */
enabled?: boolean;
/** Preserve scroll position @default true */
preserveScrollBarGap?: boolean;
/** Allow touch move on target @default false */
allowTouchMove?: (el: EventTarget | null) => boolean;
}Usage Examples:
<template>
<!-- Basic scroll lock toggle -->
<div class="scroll-lock-demo">
<h3>Scroll Lock Demo</h3>
<div class="lock-controls">
<label class="lock-toggle">
<input v-model="isLocked" type="checkbox" />
<span>Lock page scroll</span>
</label>
</div>
<div v-scroll-lock="isLocked" class="lock-indicator" :class="{ locked: isLocked }">
{{ isLocked ? '🔒 Page scroll is locked' : '🔓 Page scroll is unlocked' }}
</div>
<p class="lock-instructions">
Toggle the checkbox and try scrolling the page to see the effect.
</p>
</div>
<!-- Modal with scroll lock -->
<div class="modal-demo">
<h3>Modal with Scroll Lock</h3>
<button @click="showModal = true" class="open-modal-btn">
Open Modal
</button>
<div v-if="showModal" class="modal-overlay" @click="closeModal">
<div v-scroll-lock="showModal" class="modal-content" @click.stop>
<h4>Modal Dialog</h4>
<p>The page scroll is locked while this modal is open.</p>
<div class="modal-scroll-area">
<p v-for="i in 20" :key="i">
This is scrollable content inside the modal: Line {{ i }}
</p>
</div>
<div class="modal-actions">
<button @click="closeModal" class="close-btn">Close Modal</button>
</div>
</div>
</div>
</div>
<!-- Advanced scroll lock with options -->
<div class="advanced-lock-demo">
<h3>Advanced Scroll Lock</h3>
<div class="advanced-controls">
<label>
<input v-model="advancedLock.enabled" type="checkbox" />
Enable Advanced Lock
</label>
<label>
<input v-model="advancedLock.preserveScrollBarGap" type="checkbox" />
Preserve Scrollbar Gap
</label>
</div>
<div
v-scroll-lock="advancedLockOptions"
class="advanced-lock-area"
:class="{ locked: advancedLock.enabled }"
>
<h4>Advanced Lock Area</h4>
<p>Lock status: {{ advancedLock.enabled ? 'Active' : 'Inactive' }}</p>
<p>Scrollbar gap preserved: {{ advancedLock.preserveScrollBarGap ? 'Yes' : 'No' }}</p>
<div class="scrollable-section">
<h5>Allowed Scroll Area</h5>
<div class="allowed-scroll-content">
<p v-for="i in 15" :key="i">
This content can still be scrolled: Item {{ i }}
</p>
</div>
</div>
</div>
</div>
<!-- Conditional scroll lock -->
<div class="conditional-demo">
<h3>Conditional Scroll Lock</h3>
<div class="condition-controls">
<button @click="toggleSidebar" class="sidebar-toggle">
{{ sidebarOpen ? 'Close' : 'Open' }} Sidebar
</button>
</div>
<div class="layout-container">
<div
v-scroll-lock="sidebarOpen && isMobile"
class="sidebar"
:class="{ open: sidebarOpen, mobile: isMobile }"
>
<h4>Sidebar</h4>
<nav class="sidebar-nav">
<a href="#" v-for="i in 10" :key="i">Navigation Item {{ i }}</a>
</nav>
</div>
<div class="main-content">
<h4>Main Content</h4>
<p>Sidebar open: {{ sidebarOpen }}</p>
<p>Mobile mode: {{ isMobile }}</p>
<p>Scroll locked: {{ sidebarOpen && isMobile }}</p>
<div class="content-scroll">
<p v-for="i in 30" :key="i">
Main content line {{ i }}. On mobile, opening the sidebar locks scroll.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { vScrollLock } from '@vueuse/components';
const isLocked = ref(false);
const showModal = ref(false);
const sidebarOpen = ref(false);
const windowWidth = ref(window.innerWidth);
const advancedLock = ref({
enabled: false,
preserveScrollBarGap: true
});
const advancedLockOptions = computed(() => ({
enabled: advancedLock.value.enabled,
preserveScrollBarGap: advancedLock.value.preserveScrollBarGap,
allowTouchMove: (el) => {
// Allow touch move on elements with 'scrollable' class
return el?.closest?.('.scrollable-section') !== null;
}
}));
const isMobile = computed(() => windowWidth.value < 768);
function closeModal() {
showModal.value = false;
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value;
}
function updateWindowWidth() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
window.addEventListener('resize', updateWindowWidth);
});
onUnmounted(() => {
window.removeEventListener('resize', updateWindowWidth);
});
</script>
<style>
.scroll-lock-demo, .modal-demo, .advanced-lock-demo, .conditional-demo {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
}
.lock-controls, .advanced-controls, .condition-controls {
margin: 15px 0;
}
.lock-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.lock-indicator {
padding: 15px;
border-radius: 6px;
text-align: center;
font-weight: bold;
margin: 15px 0;
}
.lock-indicator.locked {
background: #ffebee;
color: #c62828;
}
.lock-indicator:not(.locked) {
background: #e8f5e8;
color: #2e7d32;
}
.lock-instructions {
font-size: 0.9em;
color: #666;
font-style: italic;
}
.open-modal-btn, .close-btn, .sidebar-toggle {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
}
.open-modal-btn, .sidebar-toggle {
background: #2196f3;
color: white;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 20px;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
margin: 20px;
}
.modal-scroll-area {
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
margin: 15px 0;
border-radius: 4px;
}
.modal-actions {
text-align: center;
margin-top: 20px;
}
.close-btn {
background: #f44336;
color: white;
}
.advanced-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.advanced-controls label {
display: flex;
align-items: center;
gap: 8px;
}
.advanced-lock-area {
border: 2px solid #ddd;
border-radius: 6px;
padding: 15px;
margin: 15px 0;
transition: border-color 0.3s;
}
.advanced-lock-area.locked {
border-color: #f44336;
background: #fafafa;
}
.scrollable-section {
margin-top: 15px;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
}
.allowed-scroll-content {
height: 150px;
overflow-y: auto;
padding: 10px;
background: white;
}
.layout-container {
display: flex;
gap: 0;
min-height: 300px;
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
}
.sidebar {
width: 250px;
background: #f5f5f5;
border-right: 1px solid #ddd;
padding: 15px;
transform: translateX(-100%);
transition: transform 0.3s;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar.mobile.open {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar-nav a {
padding: 8px 12px;
text-decoration: none;
color: #333;
border-radius: 4px;
transition: background 0.2s;
}
.sidebar-nav a:hover {
background: #e0e0e0;
}
.main-content {
flex: 1;
padding: 15px;
}
.content-scroll {
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
margin-top: 10px;
border-radius: 4px;
}
</style>Directive for implementing infinite scroll functionality.
/**
* Directive for infinite scroll implementation
* @example
* <div v-infinite-scroll="loadMore">Scroll to load more</div>
* <div v-infinite-scroll="[loadMore, { distance: 100 }]">With options</div>
*/
type InfiniteScrollHandler = () => void | Promise<void>;
interface VInfiniteScrollValue {
/** Simple handler function */
handler: InfiniteScrollHandler;
/** Handler with options tuple */
handlerWithOptions: [InfiniteScrollHandler, UseInfiniteScrollOptions];
}
interface UseInfiniteScrollOptions {
/** Distance from bottom to trigger @default 0 */
distance?: number;
/** Direction to observe @default 'bottom' */
direction?: 'top' | 'bottom' | 'left' | 'right';
/** Preserve scroll position when new content added @default false */
preserveScrollPosition?: boolean;
/** Throttle scroll events (ms) @default 0 */
throttle?: number;
/** Whether infinite scroll is enabled @default true */
canLoadMore?: () => boolean;
}Usage Examples:
<template>
<!-- Basic infinite scroll -->
<div class="infinite-demo">
<h3>Basic Infinite Scroll</h3>
<div
v-infinite-scroll="loadMoreItems"
class="infinite-container"
>
<div class="infinite-list">
<div v-for="item in items" :key="item.id" class="infinite-item">
<span class="item-id">#{{ item.id }}</span>
<span class="item-name">{{ item.name }}</span>
<span class="item-description">{{ item.description }}</span>
</div>
</div>
<div v-if="isLoading" class="loading-indicator">
Loading more items...
</div>
<div v-if="hasReachedEnd" class="end-indicator">
No more items to load
</div>
</div>
</div>
<!-- Infinite scroll with distance -->
<div class="distance-demo">
<h3>Infinite Scroll with Distance</h3>
<div
v-infinite-scroll="[loadMorePhotos, { distance: 200, throttle: 300 }]"
class="photos-container"
>
<div class="photos-grid">
<div v-for="photo in photos" :key="photo.id" class="photo-item">
<img :src="photo.thumbnail" :alt="photo.title" class="photo-image" />
<div class="photo-info">
<h5>{{ photo.title }}</h5>
<p>{{ photo.description }}</p>
</div>
</div>
</div>
<div class="load-info">
<p>Photos loaded: {{ photos.length }}</p>
<p>Scroll position: {{ Math.round(scrollPosition) }}px from bottom</p>
<p v-if="photoLoading" class="loading-text">📸 Loading more photos...</p>
</div>
</div>
</div>
<!-- Bidirectional infinite scroll -->
<div class="bidirectional-demo">
<h3>Bidirectional Infinite Scroll</h3>
<div class="chat-container">
<!-- Load older messages (top) -->
<div
v-infinite-scroll="[loadOlderMessages, { direction: 'top', distance: 100 }]"
class="older-loader"
>
<div v-if="loadingOlder" class="loading-older">
Loading older messages...
</div>
</div>
<!-- Messages list -->
<div class="messages-list">
<div v-for="message in messages" :key="message.id" class="message-item">
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
<div class="message-content">{{ message.content }}</div>
</div>
</div>
<!-- Load newer messages (bottom) -->
<div
v-infinite-scroll="[loadNewerMessages, { direction: 'bottom', distance: 50 }]"
class="newer-loader"
>
<div v-if="loadingNewer" class="loading-newer">
Loading newer messages...
</div>
</div>
</div>
</div>
<!-- Conditional infinite scroll -->
<div class="conditional-demo">
<h3>Conditional Infinite Scroll</h3>
<div class="scroll-controls">
<label>
<input v-model="canLoadMore" type="checkbox" />
Enable infinite scroll
</label>
<button @click="resetData" class="reset-btn">Reset Data</button>
</div>
<div
v-infinite-scroll="[loadConditionalData, {
distance: 150,
canLoadMore: () => canLoadMore && !dataEnded
}]"
class="conditional-container"
:class="{ disabled: !canLoadMore }"
>
<div class="data-list">
<div v-for="item in conditionalData" :key="item.id" class="data-item">
{{ item.name }} - {{ item.value }}
</div>
</div>
<div class="conditional-status">
<p>Items: {{ conditionalData.length }}</p>
<p>Can load more: {{ canLoadMore && !dataEnded ? 'Yes' : 'No' }}</p>
<p v-if="conditionalLoading" class="loading-text">Loading...</p>
<p v-if="dataEnded" class="end-text">All data loaded</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { vInfiniteScroll } from '@vueuse/components';
// Basic infinite scroll data
const items = ref([]);
const isLoading = ref(false);
const itemIdCounter = ref(1);
const hasReachedEnd = ref(false);
// Photos data
const photos = ref([]);
const photoLoading = ref(false);
const photoIdCounter = ref(1);
const scrollPosition = ref(0);
// Messages data
const messages = ref([]);
const loadingOlder = ref(false);
const loadingNewer = ref(false);
const oldestMessageId = ref(100);
const newestMessageId = ref(150);
// Conditional data
const conditionalData = ref([]);
const conditionalLoading = ref(false);
const canLoadMore = ref(true);
const dataEnded = ref(false);
const conditionalIdCounter = ref(1);
// Initialize with some data
initializeData();
async function loadMoreItems() {
if (isLoading.value || hasReachedEnd.value) return;
isLoading.value = true;
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
const newItems = [];
for (let i = 0; i < 10; i++) {
newItems.push({
id: itemIdCounter.value++,
name: `Item ${itemIdCounter.value - 1}`,
description: `Description for item ${itemIdCounter.value - 1}`
});
}
items.value.push(...newItems);
isLoading.value = false;
// Simulate reaching end after 50 items
if (items.value.length >= 50) {
hasReachedEnd.value = true;
}
}
async function loadMorePhotos() {
if (photoLoading.value) return;
photoLoading.value = true;
await new Promise(resolve => setTimeout(resolve, 800));
const newPhotos = [];
for (let i = 0; i < 6; i++) {
const id = photoIdCounter.value++;
newPhotos.push({
id,
title: `Photo ${id}`,
description: `Beautiful photo #${id}`,
thumbnail: `https://picsum.photos/200/200?random=${id}`
});
}
photos.value.push(...newPhotos);
photoLoading.value = false;
}
async function loadOlderMessages() {
if (loadingOlder.value) return;
loadingOlder.value = true;
await new Promise(resolve => setTimeout(resolve, 600));
const olderMessages = [];
for (let i = 0; i < 5; i++) {
olderMessages.unshift({
id: --oldestMessageId.value,
timestamp: Date.now() - (100 - oldestMessageId.value) * 60000,
content: `Older message ${oldestMessageId.value + 1}`
});
}
messages.value.unshift(...olderMessages);
loadingOlder.value = false;
}
async function loadNewerMessages() {
if (loadingNewer.value) return;
loadingNewer.value = true;
await new Promise(resolve => setTimeout(resolve, 600));
const newerMessages = [];
for (let i = 0; i < 5; i++) {
newerMessages.push({
id: ++newestMessageId.value,
timestamp: Date.now() - (200 - newestMessageId.value) * 30000,
content: `Newer message ${newestMessageId.value}`
});
}
messages.value.push(...newerMessages);
loadingNewer.value = false;
}
async function loadConditionalData() {
if (conditionalLoading.value || !canLoadMore.value || dataEnded.value) return;
conditionalLoading.value = true;
await new Promise(resolve => setTimeout(resolve, 700));
const newData = [];
for (let i = 0; i < 8; i++) {
if (conditionalData.value.length >= 40) {
dataEnded.value = true;
break;
}
newData.push({
id: conditionalIdCounter.value++,
name: `Data Item ${conditionalIdCounter.value - 1}`,
value: Math.floor(Math.random() * 1000)
});
}
conditionalData.value.push(...newData);
conditionalLoading.value = false;
}
function initializeData() {
// Initialize items
for (let i = 0; i < 20; i++) {
items.value.push({
id: itemIdCounter.value++,
name: `Item ${itemIdCounter.value - 1}`,
description: `Description for item ${itemIdCounter.value - 1}`
});
}
// Initialize photos
for (let i = 0; i < 12; i++) {
const id = photoIdCounter.value++;
photos.value.push({
id,
title: `Photo ${id}`,
description: `Beautiful photo #${id}`,
thumbnail: `https://picsum.photos/200/200?random=${id}`
});
}
// Initialize messages
for (let i = 0; i < 20; i++) {
const id = 120 + i;
messages.value.push({
id,
timestamp: Date.now() - (150 - id) * 60000,
content: `Message ${id}`
});
}
// Initialize conditional data
for (let i = 0; i < 15; i++) {
conditionalData.value.push({
id: conditionalIdCounter.value++,
name: `Data Item ${conditionalIdCounter.value - 1}`,
value: Math.floor(Math.random() * 1000)
});
}
}
function formatTime(timestamp) {
return new Date(timestamp).toLocaleTimeString();
}
function resetData() {
conditionalData.value = [];
conditionalIdCounter.value = 1;
dataEnded.value = false;
conditionalLoading.value = false;
// Re-initialize
for (let i = 0; i < 15; i++) {
conditionalData.value.push({
id: conditionalIdCounter.value++,
name: `Data Item ${conditionalIdCounter.value - 1}`,
value: Math.floor(Math.random() * 1000)
});
}
}
</script>
<style>
.infinite-demo, .distance-demo, .bidirectional-demo, .conditional-demo {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
}
.infinite-container, .photos-container, .chat-container, .conditional-container {
height: 400px;
border: 2px solid #eee;
border-radius: 6px;
overflow-y: auto;
background: #fafafa;
}
.infinite-list, .data-list {
padding: 10px;
}
.infinite-item, .data-item {
display: flex;
align-items: center;
gap: 15px;
padding: 10px;
margin: 8px 0;
background: white;
border-radius: 6px;
border-left: 3px solid #2196f3;
}
.item-id {
font-weight: bold;
color: #666;
min-width: 50px;
}
.item-name {
font-weight: bold;
min-width: 100px;
}
.item-description {
color: #666;
flex: 1;
}
.loading-indicator, .end-indicator, .loading-text, .end-text {
text-align: center;
padding: 15px;
font-style: italic;
}
.loading-indicator, .loading-text {
color: #2196f3;
}
.end-indicator, .end-text {
color: #666;
}
.photos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
padding: 15px;
}
.photo-item {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.photo-image {
width: 100%;
height: 150px;
object-fit: cover;
}
.photo-info {
padding: 10px;
}
.photo-info h5 {
margin: 0 0 5px 0;
font-size: 14px;
}
.photo-info p {
margin: 0;
font-size: 12px;
color: #666;
}
.load-info {
padding: 15px;
background: white;
margin: 15px;
border-radius: 6px;
text-align: center;
font-size: 14px;
}
.messages-list {
padding: 10px;
flex: 1;
}
.message-item {
padding: 8px 12px;
margin: 5px 0;
background: white;
border-radius: 6px;
border-left: 2px solid #4caf50;
}
.message-time {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.message-content {
font-size: 14px;
}
.loading-older, .loading-newer {
text-align: center;
padding: 10px;
color: #2196f3;
font-style: italic;
font-size: 14px;
}
.scroll-controls {
margin: 15px 0;
display: flex;
align-items: center;
gap: 15px;
}
.scroll-controls label {
display: flex;
align-items: center;
gap: 8px;
}
.reset-btn {
padding: 6px 12px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
background: #f5f5f5;
}
.conditional-container.disabled {
opacity: 0.6;
border-color: #ccc;
}
.conditional-status {
padding: 15px;
background: white;
margin: 10px;
border-radius: 6px;
font-size: 14px;
text-align: center;
}
</style>/** Common types used across scroll and resize directives */
type MaybeRefOrGetter<T> = T | Ref<T> | (() => T);
/** Scroll event and options */
interface ScrollEventOptions extends AddEventListenerOptions {
passive?: boolean;
capture?: boolean;
}
/** ResizeObserver types */
interface ResizeObserver {
disconnect(): void;
observe(target: Element, options?: ResizeObserverOptions): void;
unobserve(target: Element): void;
}
interface ResizeObserverOptions {
box?: ResizeObserverBoxOptions;
}
/** Scroll lock types */
interface ScrollLockState {
isLocked: boolean;
originalOverflow: string;
originalPaddingRight: string;
}
/** Infinite scroll directions */
type InfiniteScrollDirection = 'top' | 'bottom' | 'left' | 'right';Install with Tessl CLI
npx tessl i tessl/npm-vueuse--components