npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vangro/virtual-masonry

v1.1.3

Published

Vue 3 virtual masonry layout with timeline scrubber and lazy image loading

Readme

@vangro/virtual-masonry

Vue 3 虛擬化圖片牆套件,包含瀑布流、等高橫排、時間軸捲動條三個元件。

安裝

npm install @vangro/virtual-masonry
# 或
bun add @vangro/virtual-masonry

快速開始

// main.js — 全域註冊
import { VirtualMasonryPlugin } from '@vangro/virtual-masonry'
import '@vangro/virtual-masonry/style.css'
app.use(VirtualMasonryPlugin)
// 單檔按需引入
import { MasonryGrid, JustifiedGrid, TimelineScrubber } from '@vangro/virtual-masonry'
import '@vangro/virtual-masonry/style.css'

資料格式

每個 item 物件需要有 ratio 屬性(寬高比),其餘欄位自訂:

const items = [
  { id: 1, ratio: 1.5, src: '...', title: '...' },
  { id: 2, ratio: 0.75, src: '...', title: '...' },
]

MasonryGrid 瀑布流

虛擬化瀑布流,只渲染視窗內的項目。

基本用法

<MasonryGrid :items="items" :item-width="200" :gap="8">
  <template #default="{ item, focused }">
    <img :src="item.src" style="width:100%;height:100%;object-fit:cover" />
  </template>
</MasonryGrid>

Props

| Prop | 型別 | 預設 | 說明 | |---|---|---|---| | items | Array | [] | 資料陣列,每個 item 需有 ratio 屬性 | | cols | Number | 0 | 固定欄數,0 = 依 itemWidth 自動計算 | | itemWidth | Number | 160 | 自動計算欄數時的目標欄寬(px) | | gap | Number | 8 | 欄間距與列間距(px) | | paddingTop | Number | 0 | 頂部留白(px) | | paddingBottom | Number | 0 | 底部留白(px) | | smallColsRatio | Number | 0.7 | cols=0 時容器寬度佔父元素的比例 | | overscan | Number | 300 | 視窗外預渲染的像素範圍 | | keyboardNav | Boolean | false | 啟用鍵盤導航 | | navMode | String | 'spatial' | 鍵盤導航模式:'spatial'(位置)/ 'linear'(線性) | | radius | Number | 0 | 圓角(px) |

Slot

<template #default="{ item, index, position, focused }">
  <!-- item: 原始資料物件 -->
  <!-- index: 在 items 陣列中的索引 -->
  <!-- position: { left, top, width, height } -->
  <!-- focused: 是否為鍵盤焦點 -->
</template>

Events

| 事件 | 參數 | 說明 | |---|---|---| | layout-ready | — | 佈局計算完成 | | item-click | (item, index) | 點擊項目 | | navigate | { index, item } | 鍵盤移動焦點 | | confirm | { index, item } | 鍵盤 Enter 確認 |

Expose(ref 方法)

const gridRef = ref(null)

// 捲動到指定項目
gridRef.value.scrollTo(index, { position: 'start', behavior: 'smooth' })
gridRef.value.scrollTo(item, { position: 'center' })
gridRef.value.scrollTo('id', itemId, { position: 'top' })
// position: 'start'(預設,距頂 25%)| 'center' | 'top'

// 取得畫面中心對應的 item index(供 TimelineScrubber 使用)
gridRef.value.findIndexAtCenter(scrollTop, viewportHeight)

// 手動更新容器寬度(視窗 resize 後自動觸發,通常不需要手動呼叫)
gridRef.value.updateContainerWidth()

// 鍵盤焦點
gridRef.value.focusedIndex        // 目前焦點的 index(ref)
gridRef.value.focus(index)        // 程式設定焦點

鍵盤導航

| 按鍵 | spatial 模式 | linear 模式 | |---|---|---| | | 移到左側最近的項目 | 前一個項目 | | | 移到右側最近的項目 | 後一個項目 | | | 移到上方同欄最近的項目 | 前一個項目 | | | 移到下方同欄最近的項目 | 後一個項目 | | Enter | 觸發 confirm 事件 | 觸發 confirm 事件 |


JustifiedGrid 等高橫排

等高橫排佈局,每行圖片高度相同、寬度依比例分配撐滿容器。支援群組化。

基本用法

<JustifiedGrid :items="items" :target-height="220" :gap="8">
  <template #default="{ item, focused }">
    <img :src="item.src" style="width:100%;height:100%;object-fit:cover" />
  </template>
</JustifiedGrid>

群組用法

<JustifiedGrid
  :items="items"
  :grouped="true"
  group-key="year"
  :group-label="(key) => `${key} 年`"
  :group-header-height="48"
  :sticky-header="true"
  :sticky-header-top="56"
>
  <template #default="{ item, focused }">
    <img :src="item.src" style="width:100%;height:100%;object-fit:cover" />
  </template>
  <template #group-header="{ label, items }">
    <span style="font-size:16px;font-weight:700;">{{ label }}</span>
    <span style="font-size:12px;color:#999;margin-left:8px;">{{ items.length }} 張</span>
  </template>
</JustifiedGrid>

Props

| Prop | 型別 | 預設 | 說明 | |---|---|---|---| | items | Array | [] | 資料陣列,每個 item 需有 ratio 屬性 | | targetHeight | Number | 220 | 目標行高(px),實際行高會依比例微調 | | gap | Number | 4 | 欄間距與列間距(px) | | paddingTop | Number | 0 | 頂部留白(px) | | paddingBottom | Number | 0 | 底部留白(px) | | overscan | Number | 400 | 視窗外預渲染的像素範圍 | | grouped | Boolean | false | 啟用群組模式 | | groupKey | String\|Function | 'group' | 群組欄位名稱,或 (item) => key 函式 | | groupLabel | Function | null | (key) => string,群組標題顯示文字 | | groupHeaderHeight | Number | 48 | 群組標題高度(px) | | groupGap | Number | 16 | 群組之間的間距(px) | | stickyHeader | Boolean | true | 群組標題是否 sticky | | stickyHeaderTop | Number | 0 | sticky 標題距頂部的距離(px),通常設為工具列高度 | | radius | Number | 4 | 圓角(px) | | lastRowAlign | String | 'left' | 最後一行對齊:'left' / 'center' / 'justify' | | ratioFallback | Number | 0.75 | item 沒有 ratio 時的預設值 | | scrollEl | Object | null | 捲動容器元素,null = 使用 window | | loadMoreThreshold | Number | 400 | 距底部多少 px 時觸發 load-more | | active | Boolean | true | 是否為活躍層(堆疊保活),false 時使用 visibility:hidden 保留佈局 | | keyboardNav | Boolean | false | 啟用鍵盤導航 | | navMode | String | 'spatial' | 鍵盤導航模式:'spatial' / 'linear' |

Slots

<!-- 項目 slot -->
<template #default="{ item, index, focused, row, pos }">
  <!-- row: 所在行索引,pos: 行內位置索引 -->
</template>

<!-- 群組標題 slot(grouped 模式) -->
<template #group-header="{ label, groupKey, items }">
  <!-- label: groupLabel 函式的回傳值 -->
  <!-- groupKey: 原始 group key -->
  <!-- items: 該群組的 item 陣列 -->
</template>

Events

| 事件 | 參數 | 說明 | |---|---|---| | item-click | (item, index) | 點擊項目 | | group-click | (key, items) | 點擊群組標題 | | load-more | — | 捲動到底部附近 | | navigate | { index, item } | 鍵盤移動焦點 | | confirm | { index, item } | 鍵盤 Enter 確認 |

Expose

const gridRef = ref(null)

gridRef.value.scrollTo(index, { position: 'start', behavior: 'smooth' })
gridRef.value.findIndexAtCenter(scrollTop, viewportHeight)
gridRef.value.totalHeight        // 總高度(computed ref)
gridRef.value.focusedIndex       // 目前焦點 index(ref)
gridRef.value.focus(index)       // 程式設定焦點
gridRef.value.refresh()          // 手動同步寬度與捲動位置(用於堆疊保活場景)

堆疊保活(Stack Keep-Alive)

多層頁面場景下,使用 active prop 隱藏非活躍層:

<div v-for="layer in stack" :key="layer.id">
  <JustifiedGrid
    :active="layer.id === activeId"
    :items="layer.items"
  />
</div>

使用 visibility:hidden + content-visibility:hidden 保留佈局空間,切回時自動同步寬度與捲動位置。

性能優化

  • 增量計算:追加新 items 時只重新計算變動部分,相比全量重算快 90%(2-5ms vs 20-50ms)
  • ResizeObserver 保護visibility:hidden 保留寬度,避免 display:none 導致的佈局損壞

TimelineScrubber 時間軸捲動條

固定在右側的捲動條,顯示刻度標籤,支援拖曳快速捲動與預覽圖。

基本用法

<template>
  <!-- 需要一個 fixed 容器 -->
  <div style="position:fixed;right:0;top:0;bottom:0;width:32px;">
    <TimelineScrubber
      :scroll-el="docEl"
      :total-items="items.length"
      :index-at-center="gridRef?.findIndexAtCenter"
      :offset-top="56"
      :offset-bottom="48"
    />
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
const docEl = ref(null)
onMounted(() => { docEl.value = document.documentElement })
</script>

刻度標籤設定

<!-- 預設:顯示 item 索引數字 -->
<TimelineScrubber :total-items="items.length" />

<!-- 自訂標籤:依索引回傳文字 -->
<TimelineScrubber
  :total-items="items.length"
  :label-at="(index) => items[index]?.year ?? ''"
/>

<!-- 日期範例 -->
<TimelineScrubber
  :total-items="items.length"
  :label-at="(index) => items[index]?.date?.slice(0, 7)"
/>

labelAt 函式接收 item 的索引,回傳要顯示的字串。刻度數量會依捲動條高度自動計算,確保標籤不重疊。

預覽圖

<TimelineScrubber
  :total-items="items.length"
  :preview-src="(index) => items[index]?.thumbnailUrl"
/>

拖曳時在捲動條左側顯示預覽圖(120×80px)。

Props

| Prop | 型別 | 預設 | 說明 | |---|---|---|---| | scrollEl | Object | null | 要控制的捲動容器,傳 document.documentElement 控制整頁。支援動態切換(多層 stack 場景) | | totalItems | Number | 0 | 總項目數,少於 20 時不顯示刻度 | | offsetTop | Number | 0 | 頂部偏移(px),通常設為上方工具列高度 | | offsetBottom | Number | 0 | 底部偏移(px),通常設為下方工具列高度 | | dotSize | Number | 14 | 拖曳圓點直徑(px) | | thumbPadTop | Number | 0 | 圓點活動範圍頂部額外留白(px) | | thumbPadBottom | Number | 0 | 圓點活動範圍底部額外留白(px) | | labelAt | Function | null | (index) => string,刻度標籤文字 | | sortKey | String | '' | 影響刻度標籤樣式:'''name' 顯示大字首字,其他顯示完整文字 | | indexAtCenter | Function | null | (scrollTop, viewportH) => index,傳入 grid 的 findIndexAtCenter | | previewSrc | Function | null | (index) => url,拖曳時的預覽圖 URL | | contained | Boolean | false | 保留,未來用於容器內捲動模式 |

Events

| 事件 | 說明 | |---|---| | drag-start | 開始拖曳 | | drag-end | 結束拖曳 |

Expose

const scrubberRef = ref(null)
scrubberRef.value.isDragging  // 是否正在拖曳(ref)

樣式客製化

CSS 變數(需提供)

TimelineScrubber 依賴以下 CSS 變數,若未使用 DaisyUI 需手動提供:

:root {
  --color-primary: #3b82f6;       /* 圓點顏色 */
  --color-base-100: #ffffff;      /* 毛玻璃背景色 */
  --color-base-300: #d1d5db;      /* 拖曳氣泡背景色 */
  --color-base-content: #1f2937;  /* 刻度文字、track 顏色 */
}

或在父元素上設定:

<div :style="{
  '--color-primary': '#0087af',
  '--color-base-100': '#ffffff',
  '--color-base-300': '#d1d5db',
  '--color-base-content': '#1f2937',
}">
  <TimelineScrubber ... />
</div>

MasonryGrid / JustifiedGrid 樣式

元件使用 scoped CSS,外部無法直接覆蓋內部樣式。建議透過 slot 自訂項目外觀:

<template #default="{ item, focused }">
  <div :style="{
    width: '100%',
    height: '100%',
    outline: focused ? '3px solid #0087af' : 'none',
    outlineOffset: '-3px',
  }">
    <img :src="item.src" style="width:100%;height:100%;object-fit:cover" />
  </div>
</template>

焦點樣式建議用 outline 搭配負的 outlineOffset,避免影響佈局。

TimelineScrubber 可覆蓋的 CSS class

| Class | 說明 | |---|---| | .scrubber | 捲動條根元素 | | .scrubber.active | 拖曳中狀態 | | .track | 中間的軌道線 | | .tick | 刻度容器 | | .tick-line | 刻度短線 | | .tick-label | 刻度文字 | | .tick-first-char | sortKey='' 時的大字首字 | | .thumb-dot | 拖曳圓點 | | .thumb-label | hover 時顯示的進度文字 | | .drag-bubble | 拖曳時的預覽氣泡 | | .preview-img | 預覽圖片 | | .preview-label | 預覽氣泡的文字 |

由於使用 scoped,外部覆蓋需用 :deep()

<style scoped>
:deep(.thumb-dot) {
  background: #ff6b6b;
  width: 18px;
  height: 18px;
}
:deep(.track) {
  width: 4px;
  background: rgba(0,0,0,0.15);
}
</style>

完整範例

<script setup>
import { ref, onMounted } from 'vue'
import { MasonryGrid, TimelineScrubber } from '@vangro/virtual-masonry'
import '@vangro/virtual-masonry/style.css'

const TOOLBAR_H = 56
const items = ref([...])
const gridRef = ref(null)
const docEl = ref(null)
onMounted(() => { docEl.value = document.documentElement })
</script>

<template>
  <div style="padding-top:56px;padding-right:32px;">
    <MasonryGrid
      ref="gridRef"
      :items="items"
      :item-width="200"
      :gap="8"
      :radius="6"
      :keyboard-nav="true"
      nav-mode="spatial"
      @item-click="(item, i) => console.log(item, i)"
      @navigate="({ item }) => console.log('focus', item)"
      @confirm="({ item }) => openDetail(item)"
    >
      <template #default="{ item, focused }">
        <img
          :src="item.src"
          style="width:100%;height:100%;object-fit:cover"
          :style="{ outline: focused ? '2px solid #0087af' : 'none' }"
        />
      </template>
    </MasonryGrid>
  </div>

  <div style="position:fixed;right:0;top:0;bottom:0;width:32px;
              --color-primary:#0087af;--color-base-100:#fff;
              --color-base-300:#d1d5db;--color-base-content:#1f2937;">
    <TimelineScrubber
      :scroll-el="docEl"
      :total-items="items.length"
      :offset-top="TOOLBAR_H"
      :label-at="(i) => items[i]?.year"
      :index-at-center="gridRef?.findIndexAtCenter"
      :preview-src="(i) => items[i]?.thumb"
    />
  </div>
</template>

vLazySrc 指令

懶加載圖片指令,進入視窗才載入:

import { vLazySrc } from '@vangro/virtual-masonry'
app.directive('lazy-src', vLazySrc)
<img v-lazy-src="item.src" />

Changelog

1.1.0

  • MasonryGridmargin + rowGap 改為單一 gap,佈局計算正確
  • MasonryGrid / JustifiedGrid:加入 navMode prop(spatial / linear
  • JustifiedGrid:修正切換 groupedcontainerEl 重建導致 containerWidth 歸零的問題
  • JustifiedGrid:修正 keyboardNav 動態切換不生效的問題
  • TimelineScrubber:修正 thumb 上下超出工具列範圍的問題
  • TimelineScrubber:修正拖曳放開後 thumb 回到頂部的問題(scroll listener 重複綁定)
  • TimelineScrubber:加入 dotSizethumbPadTopthumbPadBottom props
  • 所有 CSS 變數加入 fallback 值,非 DaisyUI 環境可正常顯示