Skip to content

Visibility Tracking

useVisibilityTracker() — per-key "entered viewport" / "left viewport" tracking, backed by a real IntersectionObserver rather than diffing visibleRange — so it's accurate even with a large overscan buffer or partial-visibility thresholds. Dataset-agnostic: you decide which elements to observe(), each under whatever key you like (a row id, an index, anything).

Typical use: highlight a nav/minimap entry, a table-of-contents item, or a "jump to" button in a control panel while the corresponding row is actually on screen in a virtualized list or table — turning it off the instant the row scrolls back out.

ts
function useVisibilityTracker(options: UseVisibilityTrackerOptions): UseVisibilityTrackerReturn

Options

root

() => HTMLElement | null

Returns the scroll container to intersect against. Omit to use the browser viewport.

rootMargin

string · default: '0px'

IntersectionObserver rootMargin — grow/shrink the root's effective bounds (e.g. trigger slightly early).

threshold

number | number[] · default: 0

Fraction of the element that must be visible to count as "visible".

onVisible

(key: string | number) => void

Called when a tracked key becomes visible.

onHidden

(key: string | number) => void

Called when a tracked key becomes hidden (including via unobserve while it was visible).

Return value

visibleKeys

Readonly<Ref<Set<string | number>>>

Keys currently intersecting the root.

isVisible

(key: string | number) => boolean

Whether key is currently visible.

observe

(el: Element | null, key: string | number) => void

Start tracking an element under key — bind via a template ref callback.

unobserve

(key: string | number) => void

Stop tracking key (e.g. on row unmount).

root may resolve after this composable's own setup runs (e.g. a sibling VirtualList's template ref) — it's polled for a few frames, and rebuilt automatically if the resolved root element ever changes (such as after a :key-forced remount).

Example

Watch specific rows and mirror their visibility into a sidebar panel — same pattern used by the VirtualList demo's "Watchlist":

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualList, useVisibilityTracker } from 'vue-virtual-scroller-kit'
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'

interface Row {
  id: number
  title: string
}
const items = ref<Row[]>(
  Array.from({ length: 100_000 }, (_, i) => ({ id: i + 1, title: `Row ${i + 1}` })),
)
const listRef = ref<VirtualListExpose | null>(null)
const watchedIds = ref<Set<number>>(new Set([1, 50_000]))

const tracker = useVisibilityTracker({
  root: () => listRef.value?.getScrollElement() ?? null,
})

// Rows unmount when scrolled out of the virtualized range, so track/untrack on
// mount/unmount rather than assuming an observed element stays alive.
function onRowMount(el: Element, id: number) {
  if (watchedIds.value.has(id)) tracker.observe(el, id)
}
function onRowUnmount(id: number) {
  tracker.unobserve(id)
}
</script>

<template>
  <VirtualList
    ref="listRef"
    :items="items"
    key-field="id"
    :estimated-item-size="48"
    style="height: 500px"
  >
    <template #default="{ item }">
      <div
        :ref="(el) => el && onRowMount(el as Element, item.id)"
        @vue:unmounted="onRowUnmount(item.id)"
        :style="{
          background:
            watchedIds.has(item.id) && tracker.isVisible(item.id) ? '#fef08a' : 'transparent',
        }"
      >
        {{ item.title }}
      </div>
    </template>
  </VirtualList>
</template>