diff --git a/src/App.vue b/src/App.vue
index 18ffa7c..9f9bf57 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,5 +1,5 @@
-
+
diff --git a/src/components/InfiniteScrollContainer.vue b/src/components/InfiniteScrollContainer.vue
index fbac68a..b84821a 100644
--- a/src/components/InfiniteScrollContainer.vue
+++ b/src/components/InfiniteScrollContainer.vue
@@ -1,13 +1,12 @@
+
diff --git a/src/components/Layout.vue b/src/components/Layout.vue
index 5047f2d..0f844b9 100644
--- a/src/components/Layout.vue
+++ b/src/components/Layout.vue
@@ -2,7 +2,7 @@
-
+
@@ -17,7 +17,7 @@
diff --git a/src/composables/useInfiniteScroll.js b/src/composables/useInfiniteScroll.js
index 981502b..929d546 100644
--- a/src/composables/useInfiniteScroll.js
+++ b/src/composables/useInfiniteScroll.js
@@ -1,13 +1,16 @@
-import { ref, reactive, computed, nextTick } from 'vue'
+import gsap from 'gsap'
import { useWindowSize } from '@vueuse/core'
import useLenis from '@/composables/useLenis'
+import { ref, reactive, computed, nextTick, onBeforeUnmount, watch } from 'vue'
// Window size for viewport calculations
const { height: wHeight } = useWindowSize()
/**
- * Bidirectional infinite scroll composable
- * Manages virtual viewports with on-demand content generation
+ * Bidirectional infinite virtual scroll composable
+ * Manages virtual viewports with on-demand content generation.
+ * The page stays at 100vh — scroll position is tracked via Lenis
+ * virtual-scroll events and viewports are positioned with CSS transforms.
*
* @param {Object} options Configuration options
* @param {Function} options.generateContent Function to generate content for a viewport
@@ -32,8 +35,10 @@ export const useInfiniteScroll = (options = {}) => {
// Core state
const state = reactive({
- // Current scroll position
- scrollY: 0,
+ // Accumulated virtual scroll position (not the real DOM scroll)
+ scrollY: initialIndex * (viewportHeight || 0),
+ // Active state to stop/start scroll
+ active: true,
// Index of the currently visible viewport
currentViewportIndex: initialIndex,
// Map of loaded viewports { index: { content, element, height, offsetY } }
@@ -41,8 +46,6 @@ export const useInfiniteScroll = (options = {}) => {
// Loading states
isLoadingUp: false,
isLoadingDown: false,
- // Total virtual height (for scrollbar)
- totalHeight: 0,
// Error handling
errors: new Map(),
hasError: false,
@@ -51,7 +54,9 @@ export const useInfiniteScroll = (options = {}) => {
// Initialize with starting viewport
const initializeViewports = async () => {
- // Create initial viewport
+ // Seed scrollY once vpHeight is available
+ state.scrollY = initialIndex * vpHeight.value
+
const initialViewport = {
index: initialIndex,
content: await generateContent(initialIndex),
@@ -61,15 +66,12 @@ export const useInfiniteScroll = (options = {}) => {
state.viewports.set(initialIndex, initialViewport)
- // Load buffer viewports above and below
await loadViewportsInRange(
initialIndex - bufferSize,
initialIndex + bufferSize,
)
- // Update positions and total height
updateViewportPositions()
- updateTotalHeight()
}
// Load viewports in a range
@@ -92,7 +94,6 @@ export const useInfiniteScroll = (options = {}) => {
const currentRetryCount = state.retryCount.get(index) || 0
try {
- // Set loading state based on direction
if (index < state.currentViewportIndex) {
state.isLoadingUp = true
} else if (index > state.currentViewportIndex) {
@@ -109,7 +110,6 @@ export const useInfiniteScroll = (options = {}) => {
state.viewports.set(index, viewport)
- // Clear any previous errors for this viewport
state.errors.delete(index)
state.retryCount.delete(index)
state.hasError = state.errors.size > 0
@@ -118,7 +118,6 @@ export const useInfiniteScroll = (options = {}) => {
} catch (error) {
console.error(`Failed to load viewport ${index}:`, error)
- // Store error
state.errors.set(index, {
message: error.message || 'Failed to load content',
timestamp: Date.now(),
@@ -126,54 +125,26 @@ export const useInfiniteScroll = (options = {}) => {
})
state.hasError = true
- // Retry logic
if (currentRetryCount < maxRetries && !isRetry) {
state.retryCount.set(index, currentRetryCount + 1)
- console.log(
- `Retrying viewport ${index} (attempt ${currentRetryCount + 1}/${maxRetries})`,
- )
-
- // Exponential backoff
const delay = Math.pow(2, currentRetryCount) * 1000
- setTimeout(() => {
- loadViewport(index, true)
- }, delay)
+ setTimeout(() => loadViewport(index, true), delay)
}
return null
} finally {
- // Clear loading states
state.isLoadingUp = false
state.isLoadingDown = false
}
}
- // Update viewport positions based on their order
+ // Update viewport positions based on their index
const updateViewportPositions = () => {
- // Each viewport is positioned at index * viewport height
for (const [index, viewport] of state.viewports) {
viewport.offsetY = index * vpHeight.value
}
}
- // Update total virtual height for scrollbar
- const updateTotalHeight = () => {
- if (state.viewports.size === 0) {
- state.totalHeight = wHeight.value * bufferSize // Minimum height
- return
- }
-
- const indexes = Array.from(state.viewports.keys())
- // const minIndex = Math.min(...indexes)
- const maxIndex = Math.max(...indexes)
-
- // Calculate total height needed to accommodate all viewports
- // Add extra buffer above and below for infinite scroll
- const bufferViewports = 10
- const totalRange = maxIndex + bufferViewports
- state.totalHeight = totalRange * vpHeight.value
- }
-
// Remove viewports outside buffer range
const cleanupViewports = () => {
const currentIndex = state.currentViewportIndex
@@ -185,37 +156,56 @@ export const useInfiniteScroll = (options = {}) => {
state.viewports.delete(index)
}
}
-
- updateTotalHeight()
}
- // Calculate which viewport should be visible based on scroll position
+ // Calculate which viewport should be visible based on virtual scroll position
const calculateVisibleViewport = (scrollY) => {
return Math.floor(scrollY / vpHeight.value)
}
- // Handle scroll events
- const handleScroll = async (scrollData) => {
- state.scrollY = scrollData.scroll
+ // Handle virtual-scroll delta events from Lenis
+ const onVirtualScroll = async ({ deltaY }) => {
+ if (!state.active) return
+
+ deltaY *= 3
+
+ gsap.to(state, {
+ scrollY: state.scrollY + deltaY,
+ duration: 1,
+ ease: 'expo.out',
+ })
const newViewportIndex = calculateVisibleViewport(state.scrollY)
if (newViewportIndex !== state.currentViewportIndex) {
state.currentViewportIndex = newViewportIndex
- // Load new viewports in range
const rangeStart = newViewportIndex - bufferSize
const rangeEnd = newViewportIndex + bufferSize
await loadViewportsInRange(rangeStart, rangeEnd)
-
- // Cleanup old viewports
cleanupViewports()
}
}
- // Setup scroll listener with Lenis
- const lenis = useLenis(handleScroll)
+ // Setup Lenis — we use virtual-scroll instead of scroll so the DOM
+ // height can stay at 100vh with overflow: hidden.
+ const lenis = useLenis()
+
+ // Attach the virtual-scroll listener once Lenis is ready
+ watch(
+ lenis,
+ (instance) => {
+ if (instance) {
+ instance.on('virtual-scroll', onVirtualScroll)
+ }
+ },
+ { immediate: true },
+ )
+
+ onBeforeUnmount(() => {
+ lenis.value?.off('virtual-scroll', onVirtualScroll)
+ })
// Get viewports to render (current + buffer)
const visibleViewports = computed(() => {
@@ -234,14 +224,6 @@ export const useInfiniteScroll = (options = {}) => {
return viewports.sort((a, b) => a.index - b.index)
})
- // Scroll to a specific viewport
- const scrollToViewport = (index) => {
- if (lenis.value) {
- const targetY = index * vpHeight.value
- lenis.value.scrollTo(targetY, { immediate: false })
- }
- }
-
// Retry failed viewport
const retryViewport = async (index) => {
state.errors.delete(index)
@@ -257,11 +239,22 @@ export const useInfiniteScroll = (options = {}) => {
state.retryCount.clear()
state.hasError = false
state.currentViewportIndex = initialIndex
+ state.scrollY = initialIndex * vpHeight.value
state.isLoadingUp = false
state.isLoadingDown = false
await initializeViewports()
}
+ // Start/stop scrolling
+ const stop = () => {
+ state.active = false
+
+ gsap.killTweensOf(state)
+ }
+ const start = () => {
+ state.active = true
+ }
+
// Initialize on creation
nextTick(() => {
initializeViewports()
@@ -274,13 +267,14 @@ export const useInfiniteScroll = (options = {}) => {
visibleViewports,
isLoadingUp: computed(() => state.isLoadingUp),
isLoadingDown: computed(() => state.isLoadingDown),
- totalHeight: computed(() => state.totalHeight),
scrollY: computed(() => state.scrollY),
+ vpHeight,
// Methods
- scrollToViewport,
reset,
retryViewport,
+ stop,
+ start,
// Error state
hasError: computed(() => state.hasError),
diff --git a/src/plugins/gsap.js b/src/plugins/gsap.js
new file mode 100644
index 0000000..cb72813
--- /dev/null
+++ b/src/plugins/gsap.js
@@ -0,0 +1,15 @@
+import gsap from 'gsap'
+import Tempus from 'tempus'
+
+export default {
+ install: () => {
+ gsap.defaults({ ease: 'none' })
+
+ // merge rafs
+ gsap.ticker.lagSmoothing(0)
+ gsap.ticker.remove(gsap.updateRoot)
+ Tempus?.add((time) => {
+ gsap.updateRoot(time / 1000)
+ }, 0)
+ },
+}