@yasanchezz/vue-infinite-grid

An infinite (virtualized) grid component for Vue 3.5+
2,918
Install
npm install @yasanchezz/vue-infinite-grid
Latest Version:0.2.6
Vue:3
Last Updated:Oct 22, 2026
Links: npm
Maintainer: yasanchezz

@yasanchezz/vue-infinite-grid

An infinite (virtualized) grid component for Vue 3.5+

VueGrid renders only a window of rows around the visible viewport and keeps the scroll height consistent by measuring each rendered row with a ResizeObserver. Rows that haven't been rendered yet are counted with an estimated height until they are measured.

It is built on CSS Grid + subgrid: you define the columns once on the grid, and every row inherits them, so cells line up across all rows without fixed widths.

Installation

Install the npm package

npm install @yasanchezz/vue-infinite-grid --save

Register it as a global component in main.js

import { createApp } from 'vue';
import VueGrid from '@yasanchezz/vue-infinite-grid';
import '@yasanchezz/vue-infinite-grid/dist/vue-infinite-grid.css';
import App from './App.vue';

createApp(App)
  .use(VueGrid)
  .mount('#app');

Or import the component directly, without installing the plugin

<script setup>
import { VueGrid } from '@yasanchezz/vue-infinite-grid';
import '@yasanchezz/vue-infinite-grid/dist/vue-infinite-grid.css';
</script>

Don't forget to import the CSS file once, either in main.js or in the component that uses VueGrid.

Usage

A three-column grid of 1000 rows, about 10% of them sticky. Only a window of rows near the viewport is rendered, and the first and last cells stay pinned while scrolling horizontally.

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

const rows = ref(
  Array(1000)
    .fill(undefined)
    .map((_, index) => ({
      id: String(index),
      value: String(index),
      sticky: Math.random() > 0.9,
    })),
)
</script>

<template>
  <VueGrid
    :rows="rows"
    :rows-count="100"
    :row-initial-height="10"
    rows-count-auto-calculated
    :rows-offset="1000"
    class="app-grid"
  >
    <template #row="{ row }">
      <div class="app-row-cell-first">
        {{ row.value }}
      </div>

      <div class="app-row-cell-second">
        second
      </div>

      <div class="app-row-cell-third">
        third
      </div>
    </template>
  </VueGrid>
</template>

<style lang="scss" scoped>
.app-grid {
  grid-template-columns: 100vw 200px 200px;

  &:deep(.grid-row--sticky) {
    border-bottom: 1px solid red;
  }
}

.app-row-cell-first {
  grid-column: 1 / 2;
  height: 50px;
  position: sticky;
  left: 0;
}

.app-row-cell-second {
  grid-column: 2 / 3;
}

.app-row-cell-third {
  grid-column: 3 / 4;
  position: sticky;
  right: 0;
}
</style>

Every rendered row is wrapped in a .grid-row element that already spans grid-column: 1 / -1 with grid-template-columns: subgrid, so the cells in the row slot are placed directly on the columns you define on the grid.

Each item in rows must have a unique id: string, which VueGrid uses as the :key for the row and to track its measured height. Any other fields are up to you: the component is generic, so the row slot prop keeps the type of your items (row.value above is typed). A row can also set sticky: true to be pinned to the top of the scroll container (see Sticky rows).

To change the rows, assign a new array (rows.value = rows.value.concat(more)) instead of mutating it in place: VueGrid watches the rows prop itself, not its contents.

Props

Prop Type Required Default Description
rows GridRow[] ({ id: string, sticky?: boolean } plus your own fields) yes - The full list of rows. Only a window of these is actually rendered at a time.
rowsCount number yes - How many rows to render at once (the size of the rendered window), not counting sticky rows kept from above the window.
rowInitialHeight number yes - Estimated row height in px, used for scroll/offset math before a row has actually been measured.
container Element | null | ((element: Element | null) => Element | null | undefined) no document.documentElement The scrollable element to listen to. Can be a function that receives the grid's root element (null before mount), which is useful for resolving a scroll container from the DOM. If it is null or returns null/undefined, the page itself is used.
rowsCountAutoCalculated boolean no false When true, grows the rendered window beyond rowsCount based on measured row heights, so it covers the height of the browser window (window.innerHeight). Useful when actual rows are shorter than rowInitialHeight. The window never shrinks below rowsCount.
rowsOffset number no rowInitialHeight * Math.trunc(rowsCount / 5) (or 1000 if that's 0) How many px of rows above the visible area stay rendered, to reduce blank space while scrolling fast. 0 falls back to the default. Read once when the component is created.

Custom scroll container

By default VueGrid follows the page scroll. To virtualize inside a scrollable element, pass that element (or a function that finds it):

<template>
  <div class="scroller">
    <VueGrid
      :rows="rows"
      :rows-count="50"
      :row-initial-height="40"
      :container="element => element?.parentElement"
    >
      <template #row="{ row }">
        <div>{{ row.id }}</div>
      </template>
    </VueGrid>
  </div>
</template>

<style scoped>
.scroller {
  position: relative;
  height: 400px;
  overflow: auto;
}
</style>

Give the container a position other than static: the grid's offset is measured through the offsetParent chain, which stops at the container only when the container is positioned.

Slots

Slot Props Description
row { row: GridRow } Renders the cells of a single row. Required.

Exposed methods

Method Signature Description
init () => void Recalculates the rendered window and layout. Call this when the available space changes without the rows prop changing, e.g. on window resize.
scrollTo (id: string) => Promise<HTMLElement> Scrolls the container so the row with the given id is at the top (horizontal scroll is reset to 0) and resolves with that row's DOM element once it has been rendered. Rejects if there is no row with this id, or if the component is unmounted before the row is rendered.

Exposed properties

Both properties are template refs, unwrapped when accessed through the component ref, and are null until the component is mounted.

Property Type Description
element HTMLDivElement | null The grid's root element (.infinite-grid), the one that gets grid-template-columns and the total height of all rows. Useful for measuring the grid or for passing it to your own observers.
content HTMLDivElement | null The wrapper of the currently rendered rows (.content). Each rendered row is its direct child with a data-index attribute equal to the row's id. It has display: contents, so it has no box of its own: use it to query or observe the rendered rows, not to measure them.

Usage as a ref

<script setup lang="ts">
import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue'

const rows = ref(
  Array(1000)
    .fill(undefined)
    .map((_, index) => ({ id: String(index) })),
)

const grid = useTemplateRef('grid')

// recalculate the layout when the browser window is resized
const onResize = () => grid.value?.init()
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))

async function goTo(id: string) {
  // scroll to a row and get its element once it's rendered
  const row = await grid.value?.scrollTo(id)
  console.log(row)

  // measure the whole grid
  console.log(grid.value?.element?.getBoundingClientRect())
  // find a rendered row by its id
  console.log(grid.value?.content?.querySelector(`[data-index="${CSS.escape(id)}"]`))
}
</script>

<template>
  <button @click="goTo('500')">
    Go to row 500
  </button>

  <VueGrid
    ref="grid"
    :rows="rows"
    :rows-count="10"
    :row-initial-height="200"
  >
    <template #row="{ row }">
      <div>
        {{ row.id }}
      </div>
    </template>
  </VueGrid>
</template>

Styling notes

VueGrid only controls vertical layout - you're expected to define the column layout on the root element (e.g. grid-template-columns). Each row wrapper (.grid-row) spans grid-column: 1 / -1 with grid-template-columns: subgrid, so the slot content inherits those columns.

The root element is display: grid; width: max-content; overflow: clip;. All built-in styles live in the VueGrid cascade layer, so any of your own unlayered styles override them regardless of specificity.

The row wrappers are rendered by VueGrid, not by your slot, so in a scoped style block reach them with :deep(), as in .app-grid:deep(.grid-row--sticky) above. Every wrapper has a data-index attribute with the row's id, so you can target a single row too.

Sticky rows

Setting sticky: true on a row makes VueGrid add a grid-row--sticky class to it (position: sticky; top: 0;). Sticky rows stay rendered even after they scroll out of the regular rendered window, so a sticky row stays pinned to the top of the scroll container until a later sticky row scrolls up and covers it.

All sticky rows use top: 0, so they cover each other. To stack them instead, give every next sticky row a larger top. Each wrapper has a data-index attribute, so the offsets can be generated from rows:

<script setup lang="ts">
import { computed } from 'vue'

// every next sticky row sticks 10px lower than the previous one
const stickyStep = 10
const stickyStyles = computed(() =>
  rows.value
    .filter(row => row.sticky)
    .map((row, index) => `.app-grid .grid-row--sticky[data-index="${CSS.escape(row.id)}"] { top: ${index * stickyStep}px; }`)
    .join('\n'),
)
</script>

<template>
  <component :is="'style'">
    {{ stickyStyles }}
  </component>

  <VueGrid
    :rows="rows"
    :rows-count="100"
    :row-initial-height="10"
    class="app-grid"
  >
    <!-- ... -->
  </VueGrid>
</template>

<component :is="'style'"> renders a real <style> element, because Vue doesn't allow a <style> tag inside a template.

Related Packages

@yasanchezz/vue-mask-email

This is an input mask which is based on VueJs 3.2+ and requires modern browsers.

946 0

Version History

Version Released Vue License
0.2.6 ^3.5.39
0.2.5 ^3.5.39
0.2.4 ^3.5.39
0.2.3 ^3.5.39
0.2.2 ^3.5.39