@ozjsey/v-scroll-into-view

Vue 3 directive for reactive scroll-into-view with edge detection, custom scroll container, and sticky-header offset — ideal for chat panes, modal lists, v-for active item, anchor navigation
1,256
Install
npm install @ozjsey/v-scroll-into-view
Latest Version:1.3.4
Vue:3
License:MIT
Last Updated:Sep 20, 2026
Links: GitHub  ·  npm
Maintainer: ozjsey

@ozjsey/v-scroll-into-view

Reactive scroll-into-view for Vue 3 — scrolls when a condition turns true, into the container you pick, with the gap your sticky header needs.

npm license MIT gzipped 3.39 KiB dependencies 0

The problem

By default, native scrollIntoView scrolls every scrollable ancestor — so the page itself moves too, not just the chat pane or modal list you actually wanted to scroll. It is imperative, so keeping "the active row is visible" true costs a watcher, a frame's wait, and a guard so it does not re-scroll on every render that changed nothing. And the heading it lands on slides under your sticky header.

The solution

A Vue 3 directive that calls Element.scrollIntoView() when a boolean condition transitions from false to true — not on every re-render — with a custom scroll container and a sticky-header offset as first-class options.

<script setup lang="ts">
import { ref } from "vue";
const isActive = ref(false);
</script>

<template>
  <div v-scroll-into-view="isActive">…</div>
</template>

Setting container opts out of native scrollIntoView entirely and does the arithmetic here, so the two are measured against each other rather than asserted equal: the parity sweep compares the resting scrollTop exactly against the browser's own scrollIntoView on an identical pane across 192 geometries, and the direction sweep does the same for the horizontal axis across every combination of pane direction, target direction and alignment.

When the container is a template ref, pass the getter. A binding value is computed while the host renders and a parent assigns ref="pane" only after its children have rendered, so on the mount-time scroll paneRef.value is null, every time — and paneRef.value ?? undefined means no container, which falls back to native and moves the page, the one thing container exists to prevent. container: () => paneRef.value resolves at scroll time; all three say so in the console.

Install

npm install @ozjsey/v-scroll-into-view

Requires Vue 3.2 or newer — useScrollIntoView calls getCurrentScope() / onScopeDispose().

import { createApp } from "vue";
import { ScrollIntoViewPlugin } from "@ozjsey/v-scroll-into-view";
import App from "./App.vue";

createApp(App).use(ScrollIntoViewPlugin).mount("#app");

Usage

v-for — scroll the active item, inside its own pane

<script setup lang="ts">
import { ref } from "vue";

const items = ref(Array.from({ length: 24 }, (_, i) => `Item ${i + 1}`));
const activeIndex = ref(0);

function next() {
  activeIndex.value = (activeIndex.value + 1) % items.value.length;
}
</script>

<template>
  <div id="list" style="height: 200px; overflow-y: auto">
    <div
      v-for="(item, i) in items"
      :key="item"
      style="height: 40px"
      v-scroll-into-view="{ condition: i === activeIndex, container: '#list' }"
    >
      {{ item }}
    </div>
  </div>
  <button @click="next">Next</button>
</template>

Only the element whose condition transitions to true scrolls. container is the outermost thing moved, not the only one: scrollers between it and the target are scrolled too, exactly as native walks the chain, so an inner pane cannot leave the target invisible while the outer one reports success. A plain CSS selector is document.querySelector, so in a v-for of panes use :scope .pane, which walks up from the element instead.

Anchor navigation under a sticky header

<script setup lang="ts">
import { ref } from "vue";

const activeId = ref<string | null>(null);
const sections = [{ id: "intro", label: "Introduction" }, { id: "install", label: "Install" }];

function goto(id: string) {
  activeId.value = id;
  // Reset next frame so a second click on the same link still scrolls.
  requestAnimationFrame(() => (activeId.value = null));
}
</script>

<template>
  <nav>
    <a v-for="s in sections" :key="s.id" @click.prevent="goto(s.id)">{{ s.label }}</a>
  </nav>

  <section
    v-for="s in sections"
    :key="s.id"
    :id="s.id"
    v-scroll-into-view="{ condition: s.id === activeId, block: 'start', offset: { top: 64 } }"
  >
    <h2>{{ s.label }}</h2>
  </section>
</template>

offset is a per-side override of the target's CSS scroll-margin, and means the same thing with or without a container. Being a leading-edge gap, it lands where CSS puts a scroll-margin-top: fully on 'start', half on 'center', not at all on 'end'. A condition that goes true and back to false inside one frame still scrolls — the rising edge queues the frame, which is what lets the reset above work.

Everything else

The v-scroll-into-view playground tab is the reference: sixteen cards, every option and every alignment driven in a real browser, each one editable as you read.

CHANGELOG.md · ARCHITECTURE.md

License

MIT

Related Packages

vue3-chat-scroll

Vue directive to keep things scrolled to the bottom

20,446 1
vue-chat-scroll

Automatic, yet conditional, scroll-to-bottom directive for Vue.js 2.0

134,822 571
vue3-smooth-scroll

Simple vue smooth scroll

209,775 34
vue2-smooth-scroll

Simple vue smooth scroll

127,387 121
vue3-sticky-directive

Vue3-sticky-directive is a powerful Vue 3 directive make element sticky. This is...

75,838 4

Version History

Version Released License
1.3.4 MIT
1.3.3 MIT
1.3.2 MIT
1.3.1 MIT
1.3.0 MIT