Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/marko-virtual-initial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/marko-virtual': minor
---

Add `@tanstack/marko-virtual` β€” a headless virtualisation adapter for Marko 6 using the runtime-tags API. Provides `<virtualizer>` and `<window-virtualizer>` tags covering fixed, variable, dynamic, grid, smooth-scroll, infinite-scroll, and window virtualisation patterns.
42 changes: 42 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@
"to": "framework/vue/vue-virtual"
}
]
},
{
"label": "marko",
"children": [
{
"label": "Marko Virtual",
"to": "framework/marko/marko-virtual"
}
]
}
]
},
Expand Down Expand Up @@ -249,6 +258,39 @@
"label": "Dynamic"
}
]
},
{
"label": "marko",
"children": [
{
"to": "framework/marko/examples/fixed",
"label": "Fixed"
},
{
"to": "framework/marko/examples/variable",
"label": "Variable"
},
{
"to": "framework/marko/examples/dynamic",
"label": "Dynamic"
},
{
"to": "framework/marko/examples/grid",
"label": "Grid"
},
{
"to": "framework/marko/examples/infinite-scroll",
"label": "Infinite Scroll"
},
{
"to": "framework/marko/examples/smooth-scroll",
"label": "Smooth Scroll"
},
{
"to": "framework/marko/examples/window",
"label": "Window"
}
]
}
]
}
Expand Down
248 changes: 248 additions & 0 deletions docs/framework/marko/marko-virtual.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# Marko Virtual

`@tanstack/marko-virtual` is the Marko 6 adapter for TanStack Virtual. It provides
row, column, and grid virtualisation via two auto-discovered Marko tags:

- **`<virtualizer>`** β€” element-based scrolling (rows, columns, grids)
- **`<window-virtualizer>`** β€” full-page/window scrolling

Tags are discovered automatically by the Marko compiler when the package is
installed. No imports are needed in your `.marko` files.
Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

# Check the context around lines 9-10 in docs/framework/marko/marko-virtual.md
head -20 docs/framework/marko/marko-virtual.md | cat -n

Repository: TanStack/virtual

Length of output: 735


🏁 Script executed:

# Check lines 19-25 in packages/marko-virtual/README.md
sed -n '10,35p' packages/marko-virtual/README.md | cat -n

Repository: TanStack/virtual

Length of output: 669


🏁 Script executed:

# Search for any other references to taglib setup in docs
rg -i 'taglib' docs/framework/marko/marko-virtual.md -B 2 -A 2

Repository: TanStack/virtual

Length of output: 42


🏁 Script executed:

# Check if marko.json exists in packages/marko-virtual
find packages/marko-virtual -name "marko.json" -type f

Repository: TanStack/virtual

Length of output: 93


🏁 Script executed:

# Check package.json exports and configuration
cat packages/marko-virtual/package.json | jq '.exports, .marko' 2>/dev/null | head -50

Repository: TanStack/virtual

Length of output: 393


🏁 Script executed:

# Check the actual structure of packages/marko-virtual
ls -la packages/marko-virtual/ | grep -E "marko|package"

Repository: TanStack/virtual

Length of output: 183


🏁 Script executed:

# Read the marko.json file to see if it enables auto-discovery
cat packages/marko-virtual/marko.json

Repository: TanStack/virtual

Length of output: 109


🏁 Script executed:

# Check if there are any comments or documentation about Marko's auto-discovery mechanism
rg -i 'auto.discover|autodiscover|auto-discov' packages/marko-virtual/ docs/ -B 2 -A 2

Repository: TanStack/virtual

Length of output: 916


🏁 Script executed:

# Check the marko.json format and if there's documentation about its purpose
cat packages/marko-virtual/README.md | head -50

Repository: TanStack/virtual

Length of output: 1145


Correct the auto-discovery claimβ€”manual setup is required.

Lines 9-10 claim tags are auto-discovered on install with no setup needed, but the package's own README explicitly requires users to add @tanstack/marko-virtual/marko.json to their project's marko.json taglib-imports. The npm README is authoritative; the docs should be corrected to reflect the actual onboarding requirement.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/framework/marko/marko-virtual.md` around lines 9 - 10, Update the
incorrect statement that Marko tags are auto-discovered: change the text to say
manual setup is required and instruct users to add
"@tanstack/marko-virtual/marko.json" to their project's marko.json under the
"taglib-imports" array (referencing the package README). Replace the sentence
"Tags are discovered automatically..." with a clear onboarding note that
includes the exact string "@tanstack/marko-virtual/marko.json" and mentions
updating the marko.json "taglib-imports" entry.


## Installation

```sh
npm install @tanstack/marko-virtual
```

## Row virtualisation

```marko
<let/mounted = false/>
<lifecycle onMount() { mounted = true }/>

<if=mounted>
<div/scrollEl
style="height: 400px; width: 400px; overflow-y: auto; position: relative;"
>
<virtualizer|{ virtualItems, totalSize }|
count=10000
estimateSize=() => 35
getScrollElement=scrollEl
>
<div style=`height: ${totalSize}px; width: 100%; position: relative`>
<for|item| of=virtualItems>
<div
style=`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: ${item.size}px;
transform: translateY(${item.start}px);
`
>
Row ${item.index}
</div>
</for>
</div>
</virtualizer>
</div>
</if>
```

## Column virtualisation

Same tag, `horizontal=true`:

```marko
<div/scrollEl
style="width: 400px; height: 100px; overflow-x: auto; position: relative;"
>
<virtualizer|{ virtualItems, totalSize }|
count=10000
estimateSize=() => 100
horizontal=true
getScrollElement=scrollEl
>
<div style=`width: ${totalSize}px; height: 100%; position: relative`>
<for|item| of=virtualItems>
<div
style=`
position: absolute;
top: 0;
left: 0;
height: 100%;
width: ${item.size}px;
transform: translateX(${item.start}px);
`
>
Column ${item.index}
</div>
</for>
</div>
</virtualizer>
</div>
```

## Grid virtualisation

Compose two `<virtualizer>` tags β€” one for rows, one for columns β€” sharing the
same scroll element:

```marko
<div/scrollEl
style="height: 500px; width: 500px; overflow: auto; position: relative;"
>
<virtualizer|{ virtualItems: rowItems, totalSize: rowTotal }|
count=10000
estimateSize=() => 35
getScrollElement=scrollEl
>
<virtualizer|{ virtualItems: colItems, totalSize: colTotal }|
count=200
estimateSize=() => 100
horizontal=true
getScrollElement=scrollEl
>
<div style=`height: ${rowTotal}px; width: ${colTotal}px; position: relative`>
<for|row| of=rowItems>
<for|col| of=colItems>
<div
style=`
position: absolute;
top: 0;
left: 0;
width: ${col.size}px;
height: ${row.size}px;
transform: translateX(${col.start}px) translateY(${row.start}px);
`
>
Cell ${row.index}, ${col.index}
</div>
</for>
</for>
</div>
</virtualizer>
</virtualizer>
</div>
```

## Window virtualisation

Use `<window-virtualizer>` when the entire page scrolls rather than a container:

```marko
<window-virtualizer|{ virtualItems, totalSize }|
count=10000
estimateSize=() => 35
>
<div style=`height: ${totalSize}px; position: relative`>
<for|item| of=virtualItems>
<div
style=`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: ${item.size}px;
transform: translateY(${item.start}px);
`
>
Row ${item.index}
</div>
</for>
</div>
</window-virtualizer>
```

## Dynamic / variable item sizes

For items with unknown heights, use `measureElement` as an effect-driven ref
to measure each element after render:

```marko
<div/scrollEl style="height: 400px; overflow-y: auto">
<virtualizer|{ virtualItems, totalSize, measureElement }|
count=data.length
estimateSize=() => 50
getScrollElement=scrollEl
>
<div style=`height: ${totalSize}px; position: relative`>
<for|item| of=virtualItems>
<div/el
data-index=item.index
style=`position: absolute; top: 0; width: 100%; transform: translateY(${item.start}px)`>
<effect() {
// measureElement reads the actual rendered height and updates the virtualizer
if (el() && measureElement) measureElement(el())
}/>
${data[item.index].text}
</div>
</for>
</div>
</virtualizer>
</div>
```

## Tag variable reference

Both tags expose the same tag variable shape:

| Property | Type | Description |
|---|---|---|
| `virtualItems` | `VirtualItem[]` | The currently visible virtual items |
| `totalSize` | `number` | Total scrollable size in px β€” set as the inner container's `height` (or `width` for columns) |
| `measureElement` | `(el: Element \| null) => void` | Ref callback for dynamic item sizing |
| `scrollToIndex` | `(index: number, options?: ScrollToOptions) => void` | Imperatively scroll to an item by index |
| `scrollToOffset` | `(offset: number, options?: ScrollToOptions) => void` | Imperatively scroll to a pixel offset |

## `<virtualizer>` input reference

| Prop | Type | Default | Description |
|---|---|---|---|
| `count` | `number` | required | Number of items |
| `getScrollElement` | `() => Element \| null` | required | Returns the scroll container |
| `estimateSize` | `(index: number) => number` | `() => 50` | Estimated item size in px |
| `overscan` | `number` | `5` | Items to render beyond the visible area |
| `horizontal` | `boolean` | `false` | Virtualise horizontally (columns) |
| `paddingStart` | `number` | β€” | Padding before first item |
| `paddingEnd` | `number` | β€” | Padding after last item |
| `scrollPaddingStart` | `number` | β€” | Scroll padding for `scrollToIndex` |
| `scrollPaddingEnd` | `number` | β€” | Scroll padding for `scrollToIndex` |
| `gap` | `number` | β€” | Gap between items in px |
| `lanes` | `number` | `1` | Lanes for masonry layouts |
| `initialOffset` | `number \| (() => number)` | β€” | Initial scroll offset |

## `<window-virtualizer>` input reference

Same as `<virtualizer>` except `getScrollElement`, `horizontal`, and
`initialOffset` are not accepted. The scroll element is always `window`,
scrolling is always vertical, and the initial offset is read from
`window.scrollY` automatically.

## SSR note

`<virtualizer>` and `<window-virtualizer>` are client-only tags. The
`<lifecycle>` tag inside them never runs during SSR, so the tag variable
will be empty (`virtualItems: []`, `totalSize: 0`) on the server.

Wrap the tag in `<if=mounted>` (where `mounted` is set by `<lifecycle onMount>`) to
ensure the scroll container exists in the DOM before the virtualizer attaches:

```marko
<let/mounted = false/>
<lifecycle onMount() { mounted = true }/>

<if=mounted>
<div/scrollEl style="height: 400px; overflow-y: auto">
<virtualizer|{ virtualItems, totalSize }|
count=10000
estimateSize=() => 35
getScrollElement=scrollEl
>
...
</virtualizer>
</div>
</if>
```
1 change: 1 addition & 0 deletions examples/marko/dynamic/marko.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "taglib-imports": ["../../../packages/marko-virtual/marko.json"] }
19 changes: 19 additions & 0 deletions examples/marko/dynamic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "tanstack-marko-virtual-example-dynamic",
"private": true,
"type": "module",
"scripts": {
"dev": "marko-run dev",
"build": "marko-run build",
"preview": "marko-run preview"
},
"dependencies": {
"@marko/run": "^0.7.0",
"@tanstack/marko-virtual": "workspace:*",
"marko": "^6.0.0"
},
"devDependencies": {
"typescript": "5.6.3",
"vite": "^6.4.2"
}
}
57 changes: 57 additions & 0 deletions examples/marko/dynamic/src/routes/+page.marko
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Dynamic Virtualisation β€” @tanstack/marko-virtual</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; padding: 24px; }
h1 { font-size: 24px; margin-bottom: 8px; }
p { color: #555; margin-bottom: 16px; font-size: 14px; line-height: 1.5; }
.scroll-container { height: 400px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 6px; }
.item { padding: 10px 12px; font-size: 14px; border-bottom: 1px solid #f3f4f6; box-sizing: border-box; line-height: 1.5; }
.item-even { background: #f9fafb; }
.item-odd { background: #ffffff; }
</style>
</head>
<body>
<h1>Dynamic Virtualisation</h1>
<p>Item sizes are unknown at render time. Each rendered item is measured via measureElement.</p>

<const/items = Array.from({ length: 1000 }, (_, idx) => ({
id: idx,
text: "Item " + idx + " β€” " + "lorem ipsum dolor sit amet ".repeat(1 + (idx % 5)),
}))/>

<let/mounted = false/>
<lifecycle onMount() { mounted = true }/>

<div/scrollEl class="scroll-container">
<if=mounted>
<virtualizer|{ virtualItems, totalSize, measureElement }|
count=items.length
estimateSize=() => 80
getScrollElement=scrollEl
>
<div style=`height: ${totalSize}px; width: 100%; position: relative`>
<for|item| of=virtualItems>
<div/itemEl
data-index=item.index
class=item.index % 2 === 0 ? "item item-even" : "item item-odd"
style=`position: absolute; top: 0; left: 0; width: 100%; transform: translateY(${item.start}px)`
>
<effect() {
const _key = item.key
const node = itemEl()
if (node && measureElement) measureElement(node)
}/>
<strong>${items[item.index]!.id}.</strong> ${items[item.index]!.text}
</div>
</for>
</div>
</virtualizer>
</if>
</div>
</body>
</html>
8 changes: 8 additions & 0 deletions examples/marko/dynamic/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import marko from '@marko/run/vite'

export default defineConfig({
// Cast to any: @marko/run/vite types are built against Vite 6 but this
// workspace uses Vite 5. The plugin works correctly at runtime.
plugins: [marko() as any],
})
Loading
Loading