|
1 | 1 | # @tanstack/marko-virtual |
2 | 2 |
|
3 | | -Marko 6 adapter for [TanStack Virtual](https://tanstack.com/virtual). Provides |
4 | | -row, column, and grid virtualisation via two auto-discovered Marko tags. |
| 3 | +Headless UI for virtualizing scrollable elements in [Marko 6](https://markojs.com), built on top of [`@tanstack/virtual-core`](https://tanstack.com/virtual). |
| 4 | + |
| 5 | +Part of the [TanStack Virtual](https://tanstack.com/virtual) family. |
5 | 6 |
|
6 | 7 | ## Installation |
7 | 8 |
|
8 | | -```sh |
| 9 | +```bash |
9 | 10 | npm install @tanstack/marko-virtual |
| 11 | +# or |
| 12 | +pnpm add @tanstack/marko-virtual |
10 | 13 | ``` |
11 | 14 |
|
12 | | -Tags are auto-discovered by the Marko compiler — no imports needed in your |
13 | | -`.marko` files. Just use `<virtualizer>` or `<window-virtualizer>` directly. |
| 15 | +**Peer dependency:** `marko >= 6.0.0` |
| 16 | + |
| 17 | +## Setup |
| 18 | + |
| 19 | +Add to your project's `marko.json` to make the tags available: |
| 20 | + |
| 21 | +```json |
| 22 | +{ |
| 23 | + "taglib-imports": ["@tanstack/marko-virtual/marko.json"] |
| 24 | +} |
| 25 | +``` |
14 | 26 |
|
15 | | -## Quick start |
| 27 | +## Usage |
| 28 | + |
| 29 | +### `<virtualizer>` — scrollable container |
| 30 | + |
| 31 | +Use when you control the scroll element (a div with `overflow: auto`). |
16 | 32 |
|
17 | 33 | ```marko |
| 34 | +<!DOCTYPE html> |
| 35 | +<html> |
| 36 | +<body> |
| 37 | +
|
18 | 38 | <let/mounted = false/> |
19 | 39 | <script() { mounted = true }/> |
20 | 40 |
|
21 | | -<if=mounted> |
22 | | - <div/$scrollEl style="height: 400px; overflow-y: auto"> |
| 41 | +<div/scrollEl style="height: 400px; overflow-y: auto"> |
| 42 | + <if=mounted> |
23 | 43 | <virtualizer|{ virtualItems, totalSize }| |
24 | 44 | count=10000 |
25 | 45 | estimateSize=() => 35 |
26 | 46 | getScrollElement=scrollEl |
27 | 47 | > |
28 | 48 | <div style=`height: ${totalSize}px; position: relative`> |
29 | 49 | <for|item| of=virtualItems> |
30 | | - <div style=` |
31 | | - position: absolute; top: 0; left: 0; width: 100%; |
32 | | - height: ${item.size}px; |
33 | | - transform: translateY(${item.start}px); |
34 | | - `> |
| 50 | + <div |
| 51 | + data-index=item.index |
| 52 | + style=`position: absolute; transform: translateY(${item.start}px); |
| 53 | + height: ${item.size}px; width: 100%` |
| 54 | + > |
35 | 55 | Row ${item.index} |
36 | 56 | </div> |
37 | 57 | </for> |
38 | 58 | </div> |
39 | 59 | </virtualizer> |
| 60 | + </if> |
| 61 | +</div> |
| 62 | +
|
| 63 | +</body> |
| 64 | +</html> |
| 65 | +``` |
| 66 | + |
| 67 | +> **Why `mounted`?** The virtualizer measures the scroll element's dimensions on mount. During SSR the DOM has no layout, so `offsetHeight` is 0 and no items would be calculated. The `<if=mounted>` guard ensures the virtualizer only renders after the browser has real dimensions available. |
| 68 | +
|
| 69 | +### `<window-virtualizer>` — full-page scrolling |
| 70 | + |
| 71 | +Use when the page itself is the scroll container. |
| 72 | + |
| 73 | +```marko |
| 74 | +<!DOCTYPE html> |
| 75 | +<html> |
| 76 | +<body> |
| 77 | +
|
| 78 | +<window-virtualizer|{ virtualItems, totalSize }| count=10000 estimateSize=() => 35> |
| 79 | + <div style=`height: ${totalSize}px; position: relative`> |
| 80 | + <for|item| of=virtualItems> |
| 81 | + <div |
| 82 | + data-index=item.index |
| 83 | + style=`position: absolute; transform: translateY(${item.start}px); |
| 84 | + height: ${item.size}px; width: 100%` |
| 85 | + > |
| 86 | + Row ${item.index} |
| 87 | + </div> |
| 88 | + </for> |
40 | 89 | </div> |
41 | | -</if> |
| 90 | +</window-virtualizer> |
| 91 | +
|
| 92 | +</body> |
| 93 | +</html> |
42 | 94 | ``` |
43 | 95 |
|
44 | | -## Tags |
| 96 | +## Tag parameters |
45 | 97 |
|
46 | | -### `<virtualizer>` |
| 98 | +Both tags use Marko 6's tag parameters pattern. The body receives virtual state via `|{ ... }|` destructuring: |
47 | 99 |
|
48 | | -Element-based virtualisation. Covers: |
| 100 | +```marko |
| 101 | +<virtualizer|{ virtualItems, totalSize, measureElement, scrollToIndex, scrollToOffset }| |
| 102 | + count=... |
| 103 | + getScrollElement=... |
| 104 | +> |
| 105 | + <!-- virtualItems, totalSize etc are in scope here --> |
| 106 | +</virtualizer> |
| 107 | +``` |
49 | 108 |
|
50 | | -- **Rows** — default (`horizontal` not set) |
51 | | -- **Columns** — `horizontal=true` |
52 | | -- **Grid** — compose two `<virtualizer>` tags sharing the same scroll element |
| 109 | +## API Reference |
| 110 | + |
| 111 | +### `<virtualizer>` |
53 | 112 |
|
54 | | -| Prop | Type | Default | Description | |
| 113 | +| Attribute | Type | Required | Description | |
55 | 114 | |---|---|---|---| |
56 | | -| `count` | `number` | required | Number of items | |
57 | | -| `getScrollElement` | `() => Element \| null` | required | Scroll container | |
58 | | -| `estimateSize` | `(index: number) => number` | `() => 50` | Estimated item size in px | |
59 | | -| `overscan` | `number` | `5` | Items to render beyond the visible area | |
60 | | -| `horizontal` | `boolean` | `false` | Virtualise horizontally | |
61 | | -| `paddingStart` | `number` | — | Padding before first item | |
62 | | -| `paddingEnd` | `number` | — | Padding after last item | |
63 | | -| `gap` | `number` | — | Gap between items | |
64 | | -| `lanes` | `number` | `1` | Lanes for masonry layouts | |
65 | | -| `initialOffset` | `number \| (() => number)` | — | Initial scroll offset | |
| 115 | +| `count` | `number` | ✅ | Total number of items | |
| 116 | +| `getScrollElement` | `() => Element \| null` | ✅ | Returns the scroll container | |
| 117 | +| `estimateSize` | `(index: number) => number` | | Estimated item size in px (default: `50`) | |
| 118 | +| `overscan` | `number` | | Items to render outside the viewport (default: `5`) | |
| 119 | +| `horizontal` | `boolean` | | Enable horizontal scrolling (default: `false`) | |
| 120 | +| `paddingStart` | `number` | | Padding before the first item in px | |
| 121 | +| `paddingEnd` | `number` | | Padding after the last item in px | |
| 122 | +| `scrollPaddingStart` | `number` | | Scroll padding at the start | |
| 123 | +| `scrollPaddingEnd` | `number` | | Scroll padding at the end | |
| 124 | +| `gap` | `number` | | Gap between items in px | |
| 125 | +| `lanes` | `number` | | Number of lanes for grid layouts | |
| 126 | +| `initialOffset` | `number \| (() => number)` | | Initial scroll offset | |
| 127 | + |
| 128 | +**Tag parameters provided to body:** |
| 129 | + |
| 130 | +| Parameter | Type | Description | |
| 131 | +|---|---|---| |
| 132 | +| `virtualItems` | `VirtualItem[]` | Items to render, with `index`, `start`, `size`, `key` | |
| 133 | +| `totalSize` | `number` | Total scrollable size in px — set on the inner container | |
| 134 | +| `measureElement` | `(el: Element \| null) => void` | Pass to `ref` for dynamic size measurement | |
| 135 | +| `scrollToIndex` | `(index: number, options?: ScrollToOptions) => void` | Scroll to an item by index | |
| 136 | +| `scrollToOffset` | `(offset: number, options?: ScrollToOptions) => void` | Scroll to a px offset | |
66 | 137 |
|
67 | 138 | ### `<window-virtualizer>` |
68 | 139 |
|
69 | | -Window-based virtualisation — the entire page scrolls. Same props as |
70 | | -`<virtualizer>` except `getScrollElement` and `horizontal` are not accepted. |
| 140 | +Same as `<virtualizer>` except there is no `getScrollElement`, `horizontal`, or `initialOffset` — the window is always the scroll container. |
71 | 141 |
|
72 | | -### Tag variable |
| 142 | +## Examples |
73 | 143 |
|
74 | | -Both tags expose the same shape via `<tag|{ ... }|>`: |
| 144 | +All examples use `@marko/run`. Run any with: |
75 | 145 |
|
76 | | -| Property | Type | Description | |
77 | | -|---|---|---| |
78 | | -| `virtualItems` | `VirtualItem[]` | Currently visible items | |
79 | | -| `totalSize` | `number` | Total scrollable size in px | |
80 | | -| `measureElement` | `(el: Element \| null) => void` | For dynamic/variable sizes | |
81 | | -| `scrollToIndex` | `(index: number, options?) => void` | Scroll to item by index | |
82 | | -| `scrollToOffset` | `(offset: number, options?) => void` | Scroll to pixel offset | |
| 146 | +```bash |
| 147 | +pnpm --filter tanstack-marko-virtual-example-<name> dev |
| 148 | +``` |
83 | 149 |
|
84 | | -## SSR |
| 150 | +| Example | Description | |
| 151 | +|---|---| |
| 152 | +| `fixed` | Fixed-size rows, columns, and grid | |
| 153 | +| `variable` | Variable sizes via `estimateSize` | |
| 154 | +| `dynamic` | Unknown sizes measured via `measureElement` | |
| 155 | +| `grid` | Two virtualizers sharing one scroll element | |
| 156 | +| `smooth-scroll` | `scrollToIndex` with CSS smooth scrolling | |
| 157 | +| `infinite-scroll` | Lazy data loading with a fixed total count | |
| 158 | +| `window` | Full-page scrolling with `<window-virtualizer>` | |
85 | 159 |
|
86 | | -`<virtualizer>` and `<window-virtualizer>` are client-only — the |
87 | | -`<lifecycle>` tag inside them never runs during SSR. Always wrap in |
88 | | -`<if=mounted>` where `mounted` is set by `<script>`. |
| 160 | +## Dynamic sizing |
89 | 161 |
|
90 | | -## Examples |
| 162 | +Use `measureElement` to measure items whose size isn't known upfront: |
| 163 | + |
| 164 | +```marko |
| 165 | +<virtualizer|{ virtualItems, totalSize, measureElement }| |
| 166 | + count=items.length |
| 167 | + estimateSize=() => 50 |
| 168 | + getScrollElement=scrollEl |
| 169 | +> |
| 170 | + <div style=`height: ${totalSize}px; position: relative`> |
| 171 | + <for|item| of=virtualItems> |
| 172 | + <div |
| 173 | + ref=measureElement |
| 174 | + data-index=item.index |
| 175 | + style=`position: absolute; transform: translateY(${item.start}px); width: 100%` |
| 176 | + > |
| 177 | + ${items[item.index]} |
| 178 | + </div> |
| 179 | + </for> |
| 180 | + </div> |
| 181 | +</virtualizer> |
| 182 | +``` |
| 183 | + |
| 184 | +> `data-index` is required on measured elements — the virtualizer uses it to map measurements back to items. |
| 185 | +
|
| 186 | +## TypeScript |
91 | 187 |
|
92 | | -- [Fixed sizes](https://tanstack.com/virtual/latest/docs/framework/marko/examples/fixed) |
93 | | -- [Variable sizes](https://tanstack.com/virtual/latest/docs/framework/marko/examples/variable) |
94 | | -- [Dynamic sizes](https://tanstack.com/virtual/latest/docs/framework/marko/examples/dynamic) |
95 | | -- [Grid](https://tanstack.com/virtual/latest/docs/framework/marko/examples/grid) |
96 | | -- [Smooth scroll](https://tanstack.com/virtual/latest/docs/framework/marko/examples/smooth-scroll) |
97 | | -- [Infinite scroll](https://tanstack.com/virtual/latest/docs/framework/marko/examples/infinite-scroll) |
98 | | -- [Window](https://tanstack.com/virtual/latest/docs/framework/marko/examples/window) |
| 188 | +The tags are fully typed. The Marko language server reads the source `.marko` files directly — no `.d.ts` generation is needed. Ensure `@marko/language-tools` is installed in your editor for IDE support. |
99 | 189 |
|
100 | 190 | ## License |
101 | 191 |
|
102 | | -MIT |
| 192 | +MIT © [Tanner Linsley](https://github.com/tannerlinsley) |
0 commit comments