Skip to main content

Dynamic-height Feed — Self-scrolling

mates-virtual virtualList with scroller: true and items of variable height. Each post has 1–6 sentences so every row is a different size. ResizeObserver measures each rendered item automatically — no itemHeight needed. The host element is the scroller; the parent only provides the height.

Quick start
import { virtualList } from "mates-virtual";

// Items have variable heights — no itemHeight required.
// ResizeObserver measures each row after it renders.
// scroller: true  →  host element scrolls, parent just sets height.
html`
  <div style="height:600px">
    ${virtualList(posts, (p) => p.id, (p) => html`
      <article style="padding:1rem;border-bottom:1px solid …">
        <p>${p.body}</p>   <!-- height varies per post: 1–6 sentences -->
      </article>
    `, { scroller: true })}
  </div>
`

2 000 posts — variable heights, scroller: true

import { html, atom, renderApp } from 'mates';
import { virtualList } from 'mates-virtual';
import { Avatar } from 'mates-ui';
import { ChatBubbleIcon, FavoriteIcon } from 'mates-icons';

const WORDS = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt labore dolore magna".split(" ");
const NAMES = ["Alice Johnson", "Bob Smith", "Carol Davis", "David Brown", "Eve Wilson", "Frank Moore"];
const TAGS = ["design", "engineering", "product", "ux", "infra"];

function randInt(seed, max) { return ((seed * 16807) % 2147483647) % max; }

const POSTS = Array.from({ length: 200 }, (_, i) => {
const nameIdx = randInt(i * 3 + 1, NAMES.length);
const sentLen = 1 + randInt(i * 7 + 2, 5);
const body = Array.from({ length: sentLen }, (_, s) => {
const wc = 8 + randInt(i * 13 + s, 10);
return Array.from({ length: wc }, (_, w) => WORDS[randInt(i + s + w, WORDS.length)]).join(" ") + ".";
}).join(" ");
const initials = NAMES[nameIdx].split(" ").map((n) => n[0]).join("");
return { id: i, author: { name: NAMES[nameIdx], initials, role: "Engineer" }, body, likes: randInt(i * 5, 120), comments: randInt(i * 11, 40), ts: ["2m ago", "1h ago", "yesterday"][i % 3], tags: randInt(i, 3) > 0 ? [TAGS[randInt(i * 2, TAGS.length)]] : [] };
});

const App = () => {
const posts = atom(POSTS);
const query = atom("");
const onSearch = (e) => {
const q = e.target.value.toLowerCase().trim();
query.set(q);
posts.set(q ? POSTS.filter((p) => p.author.name.toLowerCase().includes(q) || p.body.toLowerCase().includes(q) || p.tags.some((t) => t.includes(q))) : POSTS);
};
return () => html`
<x-col gap="var(--md-space-3)" style="max-width:40rem;width:100%;">
<x-row gap="var(--md-space-3)" align="center">
<input type="search" placeholder="Search posts…" @input=${onSearch}
style="flex:1;height:2.25rem;padding:0 var(--md-space-3);border:0.09rem solid var(--md-color-border);border-radius:var(--md-radius-sm);background:transparent;color:var(--md-color-text);font-size:var(--md-text-sm);font-family:inherit;outline:none;" />
<span style="font-size:var(--md-text-xs);color:var(--md-color-text-muted);white-space:nowrap;">${posts().length} posts</span>