CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-vueuse--components

Renderless Vue.js components that expose VueUse composable functionality through declarative template-based interfaces

Pending
Overview
Eval results
Files

scroll-resize.mddocs/

Scroll and Resize Directives

Directives for handling scroll events, resize observations, and scroll locking.

Capabilities

vScroll Directive

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>

vResizeObserver Directive

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>

vScrollLock Directive

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>

vInfiniteScroll Directive

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>

Type Definitions

/** 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

docs

browser-apis.md

browser-events.md

device-sensors.md

element-tracking.md

index.md

mouse-pointer.md

scroll-resize.md

theme-preferences.md

utilities-advanced.md

window-document.md

tile.json