Skip to content

useScrollPosition

Track the scroll position of an element ref, a raw HTMLElement, window or document. Returns two reactive refs — x and y — that update on every scroll event and re-read when the target changes.

Demo

y: 0pxx: 0px0%

Scroll this box ↓

Line 1

Line 2

Line 3

Line 4

Line 5

Line 6

Line 7

Line 8

Line 9

Line 10

Line 11

Line 12

You reached the bottom 🎉

<script setup lang="ts">
    import { computed, useTemplateRef } from 'vue';
    import { useScrollPosition } from '@basmilius/common';

    const scroller = useTemplateRef<HTMLDivElement>('scroller');
    const {x, y} = useScrollPosition(scroller);

    const progress = computed(() => {
        const element = scroller.value;

        if (!element) {
            return 0;
        }

        const max = element.scrollHeight - element.clientHeight;

        return max > 0 ? Math.round((y.value / max) * 100) : 0;
    });
</script>

<template>
    <div class="demo">
        <div class="readout">
            <span>y: <b>{{ y }}</b>px</span>
            <span>x: <b>{{ x }}</b>px</span>
            <span><b>{{ progress }}</b>%</span>
        </div>

        <div class="track">
            <div class="fill" :style="{width: `${progress}%`}"/>
        </div>

        <div ref="scroller" class="scroller">
            <div class="content">
                <p>Scroll this box ↓</p>
                <p v-for="line in 12" :key="line">Line {{ line }}</p>
                <p>You reached the bottom 🎉</p>
            </div>
        </div>
    </div>
</template>

<style scoped>
    .demo {
        padding: 24px;
        border: 1px solid var(--vp-c-border);
        border-radius: 12px;
        background: var(--vp-c-bg-soft);
    }

    .readout {
        display: flex;
        gap: 20px;
        margin-bottom: 12px;
        font-family: var(--vp-font-family-mono);
        font-size: 14px;
        color: var(--vp-c-text-2);
    }

    .readout b {
        color: var(--vp-c-brand-1);
    }

    .track {
        height: 6px;
        margin-bottom: 16px;
        border-radius: 999px;
        overflow: hidden;
        background: var(--vp-c-bg);
    }

    .fill {
        height: 100%;
        border-radius: 999px;
        background: var(--vp-c-brand-1);
        transition: width .05s linear;
    }

    .scroller {
        height: 180px;
        overflow: auto;
        border-radius: 8px;
        background: var(--vp-c-bg);
    }

    .content {
        padding: 16px;
    }

    .content p {
        margin: 0 0 24px;
        color: var(--vp-c-text-2);
    }

    .content p:first-child {
        font-weight: 600;
        color: var(--vp-c-text-1);
    }
</style>

Importing

ts
import { useScrollPosition } from '@basmilius/common';

Usage

vue
<script setup lang="ts">
    import { useScrollPosition } from '@basmilius/common';

    const {x, y} = useScrollPosition(window);
</script>

<template>
    <div class="indicator">Scrolled to {{ x }} × {{ y }}</div>
</template>

Pass a template ref to track a scrollable container instead of the viewport:

vue
<script setup lang="ts">
    import { useTemplateRef } from 'vue';
    import { useScrollPosition } from '@basmilius/common';

    const scroller = useTemplateRef<HTMLDivElement>('scroller');
    const {y} = useScrollPosition(scroller);
</script>

<template>
    <div ref="scroller" class="scroller">
        <p :class="{shadow: y > 0}">…</p>
    </div>
</template>

For window the position comes from scrollX / scrollY, for document from documentElement.scrollLeft / scrollTop, and for elements from scrollLeft / scrollTop. The scroll listener is registered through useEventListener with { passive: true }; element and component refs are unwrapped via unwrapTarget.

Type signature

ts
type EligibleTarget = HTMLElement | ComponentPublicInstance | Window | Document;

declare function useScrollPosition(
    target: MaybeRefOrGetter<EligibleTarget | null | undefined>
): {
    readonly x: Ref<number>;
    readonly y: Ref<number>;
};

See also