Masonry

A masonry grid that balances items across as many columns as the container can fit. It measures each item, so rows of uneven height pack tightly instead of leaving the gaps a plain CSS grid would.

order decides how items land in columns. balanced-stable (the default) sends each new item to the shortest column and never moves one that is already placed, which is what you want for feeds that append. balanced re-packs everything on every change for the tightest result, at the cost of items jumping around.

0
1
2
3
4
5
6
7
8
9
10
11
svelte<script lang="ts">
  import { Masonry, type MasonryOrder } from 'svelte-widgets'
  import { order_options } from 'svelte-widgets/utils'

  let order = $state<MasonryOrder>(`balanced-stable`)
  let n_items = $state(12)
  const items = $derived(
    Array.from({ length: n_items }, (_, idx) => ({
      id: idx,
      // deterministic pseudo-random heights so the packing is visible but stable
      height: 40 + ((idx * 37) % 90),
    })),
  )
</script>

<label>
  Order
  <select bind:value={order}>
    {#each order_options as option (option)}<option>{option}</option>{/each}
  </select>
</label>
<label>
  Items <input type="number" min="1" max="40" bind:value={n_items} style="width: 4em" />
</label>

<Masonry {items} {order} min_col_width={120} gap={10} style="margin-top: 1em">
  {#snippet children({ item })}
    <div
      style="height: {item.height}px; display: grid; place-items: center; border-radius:
      4pt; background: var(--surface); border: 1px solid var(--border)"
    >
      {item.id}
    </div>
  {/snippet}
</Masonry>

Virtualization

Set virtualize with a height to render only the items near the viewport. Useful past a few hundred items, where the DOM node count starts to cost more than the measuring does.

0 of 500 item nodes mounted
Item 0
Item 13
Item 26
Item 39
Item 52
Item 65
Item 78
Item 91
Item 1
Item 14
Item 27
Item 40
Item 53
Item 66
Item 79
Item 2
Item 15
Item 28
Item 41
Item 54
Item 67
Item 80
Item 93
Item 3
Item 16
Item 29
Item 42
Item 55
Item 68
Item 81
Item 4
Item 17
Item 30
Item 43
Item 56
Item 69
Item 82
Item 95
Item 5
Item 18
Item 31
Item 44
Item 57
Item 70
Item 83
Item 96
Item 6
Item 19
Item 32
Item 45
Item 58
Item 71
Item 84
Item 7
Item 20
Item 33
Item 46
Item 59
Item 72
Item 85
Item 98
Item 8
Item 21
Item 34
Item 47
Item 60
Item 73
Item 86
Item 9
Item 22
Item 35
Item 48
Item 61
Item 74
Item 87
Item 10
Item 23
Item 36
Item 49
Item 62
Item 75
Item 88
Item 101
Item 11
Item 24
Item 37
Item 50
Item 63
Item 76
Item 89
Item 12
Item 25
Item 38
Item 51
Item 64
Item 77
Item 90
Item 103
svelte<script lang="ts">
  import { Masonry } from 'svelte-widgets'

  let virtualize = $state(true)
  let rendered = $state(0)
  let masonry = $state<HTMLDivElement>()
  const count_rendered = (node: Element) => {
    const update = () => (rendered = node.querySelectorAll(`:scope > .col > div`).length)
    const observer = new MutationObserver(update)
    observer.observe(node, { childList: true, subtree: true })
    update()
    return () => observer.disconnect()
  }
  $effect(() => {
    if (masonry) return count_rendered(masonry)
  })
  const items = Array.from({ length: 500 }, (_, idx) => ({
    id: idx,
    height: 48 + ((idx * 29) % 72),
  }))
</script>

<label>
  <input type="checkbox" bind:checked={virtualize} />
  Render only the visible window
</label>
<output>{rendered} of {items.length} item nodes mounted</output>

<Masonry
  bind:div={masonry}
  {items}
  {virtualize}
  height={360}
  overscan={3}
  min_col_width={130}
  gap={8}
  get_estimated_height={(item) => item.height}
  style="margin-top: 0.75em; padding: 8px; border: 1px solid var(--border);
  {virtualize ? `` : `max-height: 360px; overflow-y: auto`}"
>
  {#snippet children({ item })}
    <div
      style="height: {item.height}px; display: grid; place-items: center; background:
      var(--surface); border: 1px solid var(--border)"
    >
      Item {item.id}
    </div>
  {/snippet}
</Masonry>

Two things change while virtualizing, because off-screen items are never measured: order is forced to row-first, and the FLIP animation is switched off. Scroll position is driven entirely by get_estimated_height (default 150px), so the closer that is to your real item heights, the better the scrollbar behaves.