estimatedHeightasync
Source
// Social Feed Example
// Demonstrates variable-size items with three independent axes:
// Source: Reddit (live feed via /api/feed) | RSS (any feed URL)
// Layout: Card (social-card style) | Compact (title-dominant, thumbnail right)
// Mode: A (pre-measure all items at init) | B (estimatedSize + ResizeObserver)
//
// Any combination works — you can pre-measure Reddit posts or auto-size RSS items.
import { vlist } from "vlist";
import { createStats } from "../stats.js";
// =============================================================================
// Reddit feed — fetcher + state
// =============================================================================
// Preload-ahead: fetch pages before the user reaches the boundary
const PRELOAD_THRESHOLD = 15; // trigger fetch when within N items of the end
const INITIAL_PAGES = 3; // pages to fetch on init (75 posts)
const INITIAL_PAGE_DELAY = 1500; // ms between initial burst requests
const MIN_FETCH_INTERVAL = 2000; // minimum ms between requests
// =============================================================================
// Preferences — persist source, mode, subreddit, RSS feed in localStorage
// =============================================================================
const STORAGE_KEY = "vlist-social-feed";
const loadPrefs = () => {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
} catch {
return {};
}
};
const savePrefs = (patch) => {
try {
const prefs = { ...loadPrefs(), ...patch };
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// localStorage unavailable — ignore
}
};
const feedState = {
posts: [],
nextCursor: null,
subreddit: "worldnews",
loading: false,
endOfFeed: false,
lastFetchTime: 0,
};
const rssState = {
posts: [],
feedUrl: "https://feeds.bbci.co.uk/news/world/rss.xml",
loading: false,
};
/**
* Fetch a page of posts from /api/feed (proxied through the Bun server).
* @param {string} subreddit
* @param {string|null} after pagination cursor (null = first page)
* @returns {Promise<{posts: object[], nextCursor: string|null}>}
*/
const loadFeedPage = async (subreddit, after = null) => {
const url = new URL("/api/feed", location.origin);
url.searchParams.set("source", "reddit");
url.searchParams.set("target", subreddit);
url.searchParams.set("limit", "25");
if (after) url.searchParams.set("after", after);
const res = await fetch(url);
if (!res.ok) throw new Error(`Feed API error ${res.status}`);
return res.json();
};
const loadRssFeed = async (feedUrl) => {
const url = new URL("/api/feed", location.origin);
url.searchParams.set("source", "rss");
url.searchParams.set("target", feedUrl);
url.searchParams.set("limit", "50");
const res = await fetch(url);
if (!res.ok) throw new Error(`RSS API error ${res.status}`);
return res.json();
};
// =============================================================================
// Helpers
// =============================================================================
/** Format large numbers like Reddit: 1234 → "1.2K", 35000 → "35K" */
const formatCount = (n) => {
if (n >= 100_000) return `${Math.round(n / 1000)}K`;
if (n >= 1_000) {
const k = n / 1000;
return k >= 10 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
}
return String(n);
};
// =============================================================================
// Template — renders any post (Reddit or RSS)
// =============================================================================
/** Image slot for measurement — same container, no <img> tag */
const renderImageSlot = (image) => {
if (!image) return "";
return `<div class="post__image post__image--real"></div>`;
};
/** Thumbnail slot for measurement — compact layout */
const renderThumbnailSlot = (image) => {
if (!image) return "";
return `<div class="rpost__thumb"></div>`;
};
const renderImageReal = (image) => {
if (!image) return "";
if (image.url) {
return `
<div class="post__image post__image--real">
<img src="${image.url}" alt="${image.alt ?? ""}" loading="lazy" onerror="this.parentElement.style.display='none'">
</div>
`;
}
return "";
};
/**
* Light markdown → HTML for body text.
* Converts [text](url) links and bare URLs to clickable <a> tags.
*/
const markdownToHtml = (text) => {
// 1. Convert [text](url) markdown links
let html = text.replace(
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>',
);
// 2. Convert remaining bare URLs (not already inside an href="...")
html = html.replace(
/(?<!href="|">)(https?:\/\/[^\s<]+)/g,
'<a href="$1" target="_blank" rel="noopener">$1</a>',
);
return html;
};
/** End-of-feed sentinel renderer */
const renderEndOfFeed = (post) => `
<div class="post-end">
<div class="post-end__line"></div>
<span class="post-end__label">End of feed · ${post.count} items</span>
<div class="post-end__line"></div>
</div>
`;
/** Card post renderer — social-card style (default) */
const renderCardPost = (post, { measure = false } = {}) => {
if (post._endOfFeed) return renderEndOfFeed(post);
const titleHtml = post.title
? `<div class="post__title">${post.title}</div>`
: "";
const imageHtml = measure
? renderImageSlot(post.image)
: renderImageReal(post.image);
const openAction = post.url
? `<a class="post__action post__action--link" href="${post.url}" target="_blank" rel="noopener">↗ Open</a>`
: "";
const tagsHtml =
post.tags.length > 0
? `<div class="post__tags">${post.tags.map((t) => `<span class="post__tag">${t}</span>`).join(" ")}</div>`
: "";
const likesHtml =
post.likes > 0
? `<button class="post__action">♡ ${post.likes}</button>`
: "";
const commentsHtml =
post.comments > 0
? `<button class="post__action">💬 ${post.comments}</button>`
: "";
return `
<article class="post${post.source ? ` post--${post.source}` : ""}">
<div class="post__header">
<div class="post__avatar" style="background:${post.color}">${post.initials}</div>
<div class="post__meta">
<span class="post__user">${post.user}</span>
<span class="post__time">${post.time}</span>
</div>
</div>
${titleHtml}
${imageHtml}
${post.text ? `<div class="post__body">${markdownToHtml(post.text)}</div>` : ""}
${tagsHtml}
<div class="post__actions">
${likesHtml}
${commentsHtml}
${openAction}
</div>
</article>
`;
};
/** Reddit-native post renderer — title-dominant, thumbnail right, vote pills */
const renderRedditPost = (post, { measure = false } = {}) => {
if (post._endOfFeed) return renderEndOfFeed(post);
const thumbHtml = measure
? renderThumbnailSlot(post.image)
: post.image?.url
? `<a class="rpost__thumb" href="${post.url || "#"}" target="_blank" rel="noopener">
<img src="${post.image.url}" alt="${post.image.alt ?? ""}" loading="lazy" onerror="this.parentElement.classList.add('rpost__thumb--broken')">
</a>`
: "";
const titleHtml = post.title
? `<h3 class="rpost__title">${post.title}</h3>`
: "";
const linkUrl = post.url || "";
const linkHtml = linkUrl ? `<span class="rpost__link">${linkUrl}</span>` : "";
const openHtml = post.url
? `<a class="rpost__more" href="${post.url}" target="_blank" rel="noopener" title="Open">···</a>`
: "";
return `
<article class="rpost">
<div class="rpost__header">
<div class="rpost__avatar" style="background:${post.color}">${post.initials}</div>
<span class="rpost__user">u/${post.user}</span>
<span class="rpost__dot">·</span>
<span class="rpost__time">${post.time}</span>
${openHtml}
</div>
<div class="rpost__body">
<div class="rpost__content">
${titleHtml}
${linkHtml}
${post.text && !post.title ? `<div class="rpost__text">${markdownToHtml(post.text)}</div>` : ""}
</div>
${thumbHtml}
</div>
<div class="rpost__actions">
<span class="rpost__pill">
<span class="rpost__vote rpost__vote--up">⬆</span>
<span class="rpost__count">${post.likes > 0 ? formatCount(post.likes) : "Vote"}</span>
<span class="rpost__vote rpost__vote--down">⬇</span>
</span>
<span class="rpost__pill">
<span class="rpost__pill-icon">💬</span>
<span class="rpost__count">${post.comments > 0 ? formatCount(post.comments) : "0"}</span>
</span>
</div>
</article>
`;
};
/** Dispatch to the right renderer based on current layout */
const renderPost = (post, opts) =>
currentLayout === "compact"
? renderRedditPost(post, opts)
: renderCardPost(post, opts);
// =============================================================================
// Mode A — Pre-measure all items via hidden DOM element
// =============================================================================
/**
* Measure the actual rendered size of every item by inserting its HTML
* into a hidden element that matches the list's inner width.
*
* We cache by a content key (text + hasImage + aspect) so items with
* identical templates share a single measurement.
*/
const measureSizes = (items, container) => {
const measurer = document.createElement("div");
measurer.style.cssText =
"position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;" +
`width:${container.offsetWidth}px;`;
document.body.appendChild(measurer);
const cache = new Map();
let uniqueCount = 0;
for (const item of items) {
const key =
(item.title ?? "") + item.text + (item.image ? item.image.aspect : "");
if (cache.has(key)) {
item.size = cache.get(key);
continue;
}
measurer.innerHTML = renderPost(item, { measure: true });
const measured = measurer.firstElementChild.offsetHeight;
item.size = measured;
cache.set(key, measured);
uniqueCount++;
}
measurer.remove();
return uniqueCount;
};
// =============================================================================
// Constants
// =============================================================================
const ESTIMATED_SIZE = 160;
const ESTIMATED_SIZE_COMPACT = 120;
// =============================================================================
// DOM references
// =============================================================================
// Source panel
const sourceToggleEl = document.getElementById("feed-source");
const sectionRedditEl = document.getElementById("section-reddit");
const sectionRssEl = document.getElementById("section-rss");
const feedSubredditEl = document.getElementById("feed-subreddit");
const layoutToggleEl = document.getElementById("layout-toggle");
const infoFeedStatusEl = document.getElementById("info-feed-status");
const infoFeedCountEl = document.getElementById("info-feed-count");
const feedRssEl = document.getElementById("feed-rss");
const infoRssStatusEl = document.getElementById("info-rss-status");
const infoRssCountEl = document.getElementById("info-rss-count");
// Mode panel
const modeToggleEl = document.getElementById("mode-toggle");
const sectionMeasurementEl = document.getElementById("section-measurement");
// Measurement info
const infoStrategyEl = document.getElementById("info-strategy");
const infoInitEl = document.getElementById("info-init");
const infoUniqueEl = document.getElementById("info-unique");
// List + badge + footer + feed name
const feedNameEl = document.getElementById("feed-name");
const containerEl = document.getElementById("list-container");
const modeBadgeEl = document.getElementById("mode-badge");
const ftModeEl = document.getElementById("ft-mode");
const ftSourceEl = document.getElementById("ft-source");
// =============================================================================
// Stats (footer left — progress, velocity, items)
// =============================================================================
const stats = createStats({
getList: () => list,
getTotal: () => getActiveItems().length,
getItemHeight: () => ESTIMATED_SIZE,
container: "#list-container",
});
// =============================================================================
// State — two independent axes
// =============================================================================
const prefs = loadPrefs();
let currentSource = prefs.source === "rss" ? "rss" : "reddit";
let currentMode = prefs.mode || "a";
let currentLayout = prefs.layout || "card";
let list = null;
/** Return the items array for the active source */
const getActiveItems = () =>
currentSource === "reddit" ? feedState.posts : rssState.posts;
// =============================================================================
// Create / recreate list — called when source OR mode changes
// =============================================================================
function createList() {
// Destroy previous
if (list) {
list.destroy();
list = null;
}
containerEl.innerHTML = "";
// Apply layout class to container
containerEl.classList.toggle("layout--compact", currentLayout === "compact");
const items = getActiveItems();
const ariaLabel =
currentSource === "reddit" ? "Live Reddit feed" : "RSS feed";
const isCompact = currentLayout === "compact";
const estimatedSize = isCompact ? ESTIMATED_SIZE_COMPACT : ESTIMATED_SIZE;
let initTime = 0;
let uniqueSizes = 0;
if (currentMode === "a") {
// Mode A: pre-measure all items, then use size function
const start = performance.now();
if (items.length > 0) {
uniqueSizes = measureSizes(items, containerEl);
}
initTime = performance.now() - start;
list = vlist({
container: containerEl,
ariaLabel,
items,
item: {
height: (index) => getActiveItems()[index]?.size ?? estimatedSize,
template: renderPost,
},
}).build();
} else {
// Mode B: estimated size, auto-measured by ResizeObserver
const start = performance.now();
list = vlist({
container: containerEl,
ariaLabel,
items,
item: {
estimatedHeight: estimatedSize,
template: renderPost,
},
}).build();
initTime = performance.now() - start;
}
// Wire up events
list.on("scroll", stats.scheduleUpdate);
list.on("range:change", ({ range }) => {
stats.scheduleUpdate();
preloadIfNeeded(range.end);
});
list.on("velocity:change", ({ velocity }) => stats.onVelocity(velocity));
list.on("item:click", ({ item, event }) => {
// Only open URL if the click was on the "Open" link itself
const target = event?.target;
if (target && target.closest && target.closest(".post__action--link"))
return;
console.log(
`Clicked post by ${item.user}: "${(item.title || item.text || "").slice(0, 50)}…"`,
);
});
// If no posts loaded yet, trigger initial fetch
if (currentSource === "reddit" && feedState.posts.length === 0) {
loadInitialPages();
}
if (currentSource === "rss" && rssState.posts.length === 0) {
loadRssItems();
}
stats.update();
updatePanelInfo(initTime, uniqueSizes);
}
// =============================================================================
// Reddit feed loading
// =============================================================================
async function loadNextFeedPage({ skipRateLimit = false } = {}) {
if (feedState.loading || feedState.endOfFeed) return;
// Rate-limit: don't fetch faster than MIN_FETCH_INTERVAL
if (!skipRateLimit) {
const now = Date.now();
const elapsed = now - feedState.lastFetchTime;
if (elapsed < MIN_FETCH_INTERVAL) {
setTimeout(() => loadNextFeedPage(), MIN_FETCH_INTERVAL - elapsed);
return;
}
}
feedState.loading = true;
feedState.lastFetchTime = Date.now();
setFeedStatus("loading…");
try {
const data = await loadFeedPage(feedState.subreddit, feedState.nextCursor);
feedState.posts.push(...data.posts);
feedState.nextCursor = data.nextCursor;
if (!data.nextCursor) {
feedState.endOfFeed = true;
// Append end-of-feed sentinel
feedState.posts.push({
id: "__end__",
_endOfFeed: true,
count: feedState.posts.length,
tags: [],
size: 0,
});
}
// Update the live vlist with the new items
if (list && currentSource === "reddit") {
// If Mode A, measure BEFORE setItems so heights are available
let uniqueSizes = 0;
if (currentMode === "a") {
uniqueSizes = measureSizes(data.posts, containerEl);
}
list.setItems(feedState.posts);
stats.update();
updatePanelInfo(0, uniqueSizes);
}
setFeedStatus(feedState.endOfFeed ? "end of feed" : "ready");
infoFeedCountEl.textContent = String(feedState.posts.length);
} catch (err) {
console.error("Feed fetch failed:", err);
setFeedStatus(`error: ${err.message}`);
} finally {
feedState.loading = false;
}
}
/**
* Preload-ahead: called on range:change to auto-fetch more posts
* when the user scrolls near the end of loaded data.
*/
function preloadIfNeeded(rangeEnd) {
if (currentSource !== "reddit") return;
if (feedState.endOfFeed || feedState.loading) return;
const remaining = feedState.posts.length - rangeEnd;
if (remaining <= PRELOAD_THRESHOLD) {
loadNextFeedPage();
}
}
/**
* Load multiple pages sequentially on init for a comfortable buffer.
* Stops early if end-of-feed is reached.
*/
async function loadInitialPages() {
for (let i = 0; i < INITIAL_PAGES; i++) {
if (feedState.endOfFeed) break;
await loadNextFeedPage({ skipRateLimit: true });
// Delay between pages to avoid Reddit rate-limiting
if (i < INITIAL_PAGES - 1 && !feedState.endOfFeed) {
await new Promise((r) => setTimeout(r, INITIAL_PAGE_DELAY));
}
}
}
/** @param {string} msg */
function setFeedStatus(msg) {
if (infoFeedStatusEl) infoFeedStatusEl.textContent = msg;
}
// =============================================================================
// RSS feed loading
// =============================================================================
async function loadRssItems() {
if (rssState.loading) return;
rssState.loading = true;
setRssStatus("loading…");
try {
const data = await loadRssFeed(rssState.feedUrl);
// Append end-of-feed sentinel
rssState.posts = [
...data.posts,
{
id: "__end__",
_endOfFeed: true,
count: data.posts.length,
tags: [],
size: 0,
},
];
if (list && currentSource === "rss") {
// If Mode A, measure BEFORE setItems so heights are available
let uniqueSizes = 0;
if (currentMode === "a") {
uniqueSizes = measureSizes(rssState.posts, containerEl);
}
list.setItems(rssState.posts);
stats.update();
updatePanelInfo(0, uniqueSizes);
}
setRssStatus("ready");
infoRssCountEl.textContent = String(rssState.posts.length);
} catch (err) {
console.error("RSS fetch failed:", err);
setRssStatus(`error: ${err.message}`);
} finally {
rssState.loading = false;
}
}
/** @param {string} msg */
function setRssStatus(msg) {
if (infoRssStatusEl) infoRssStatusEl.textContent = msg;
}
// =============================================================================
// Panel info — update badge, footer, measurement section
// =============================================================================
function updateFeedName() {
if (!feedNameEl) return;
if (currentSource === "reddit") {
const sub = feedSubredditEl.value;
feedNameEl.textContent = `r/${sub}`;
} else {
const selected = feedRssEl.options[feedRssEl.selectedIndex];
feedNameEl.textContent = selected ? selected.textContent.trim() : "RSS";
}
}
function updatePanelInfo(initTime, uniqueSizes) {
const modeLabel = currentMode === "a" ? "Mode A" : "Mode B";
const sourceLabel = currentSource === "reddit" ? "Reddit" : "RSS";
modeBadgeEl.textContent = modeLabel;
ftModeEl.textContent = modeLabel;
ftSourceEl.textContent = sourceLabel;
infoStrategyEl.textContent =
currentMode === "a" ? "size: (index) => px" : "estimatedSize";
infoInitEl.textContent = `${initTime.toFixed(0)}ms`;
infoUniqueEl.textContent = currentMode === "a" ? String(uniqueSizes) : "–";
}
// =============================================================================
// Panel visibility — show/hide source-specific sections
// =============================================================================
function updateSourceSections() {
sectionRedditEl.classList.toggle(
"panel-section--hidden",
currentSource !== "reddit",
);
sectionRssEl.classList.toggle(
"panel-section--hidden",
currentSource !== "rss",
);
}
function updateLayoutToggle() {
if (!layoutToggleEl) return;
layoutToggleEl.querySelectorAll("button").forEach((b) => {
b.classList.toggle(
"panel-segmented__btn--active",
b.dataset.layout === currentLayout,
);
});
}
// =============================================================================
// Source toggle
// =============================================================================
sourceToggleEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-source]");
if (!btn || btn.disabled) return;
const source = btn.dataset.source;
if (source === currentSource) return;
currentSource = source;
savePrefs({ source });
// Update active button
sourceToggleEl.querySelectorAll("button").forEach((b) => {
b.classList.toggle(
"panel-segmented__btn--active",
b.dataset.source === source,
);
});
// Ensure state is ready for the selected source
if (source === "reddit" && feedState.posts.length === 0) {
feedState.subreddit = feedSubredditEl.value;
}
if (source === "rss" && rssState.posts.length === 0) {
rssState.feedUrl = feedRssEl.value;
}
updateSourceSections();
updateFeedName();
createList();
});
// =============================================================================
// Layout toggle
// =============================================================================
layoutToggleEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-layout]");
if (!btn) return;
const layout = btn.dataset.layout;
if (layout === currentLayout) return;
currentLayout = layout;
savePrefs({ layout });
updateLayoutToggle();
// Clear cached sizes — layout changes dimensions
for (const item of getActiveItems()) {
item.size = 0;
}
createList();
});
// =============================================================================
// Mode toggle
// =============================================================================
modeToggleEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-mode]");
if (!btn) return;
const mode = btn.dataset.mode;
if (mode === currentMode) return;
currentMode = mode;
savePrefs({ mode });
// Update active button
modeToggleEl.querySelectorAll("button").forEach((b) => {
b.classList.toggle("panel-segmented__btn--active", b.dataset.mode === mode);
});
createList();
});
// =============================================================================
// Reddit controls — subreddit picker
// =============================================================================
feedSubredditEl.addEventListener("change", () => {
if (currentSource !== "reddit") return;
// Reset feed for the new subreddit
feedState.posts = [];
feedState.nextCursor = null;
feedState.endOfFeed = false;
feedState.lastFetchTime = 0;
feedState.subreddit = feedSubredditEl.value;
savePrefs({ subreddit: feedSubredditEl.value });
infoFeedCountEl.textContent = "–";
updateFeedName();
createList();
});
// =============================================================================
// RSS controls — feed picker
// =============================================================================
feedRssEl.addEventListener("change", () => {
if (currentSource !== "rss") return;
rssState.posts = [];
rssState.feedUrl = feedRssEl.value;
savePrefs({ rssFeed: feedRssEl.value });
infoRssCountEl.textContent = "–";
updateFeedName();
createList();
});
// =============================================================================
// Navigation controls
// =============================================================================
document.getElementById("jump-top").addEventListener("click", () => {
list.scrollToIndex(0, { behavior: "smooth" });
});
document.getElementById("jump-middle").addEventListener("click", () => {
const total = getActiveItems().length;
list.scrollToIndex(Math.floor(total / 2), {
align: "center",
behavior: "smooth",
});
});
document.getElementById("jump-bottom").addEventListener("click", () => {
const total = getActiveItems().length;
list.scrollToIndex(Math.max(0, total - 1), {
align: "end",
behavior: "smooth",
});
});
document.getElementById("jump-random").addEventListener("click", () => {
const total = getActiveItems().length;
const idx = Math.floor(Math.random() * total);
list.scrollToIndex(idx, { align: "center", behavior: "smooth" });
});
// =============================================================================
// Initialise — restore preferences and boot
// =============================================================================
// Restore saved selections into UI controls
if (
prefs.subreddit &&
feedSubredditEl.querySelector(`option[value="${prefs.subreddit}"]`)
) {
feedSubredditEl.value = prefs.subreddit;
feedState.subreddit = prefs.subreddit;
}
if (
prefs.rssFeed &&
feedRssEl.querySelector(`option[value="${prefs.rssFeed}"]`)
) {
feedRssEl.value = prefs.rssFeed;
rssState.feedUrl = prefs.rssFeed;
}
// Restore source toggle UI
sourceToggleEl.querySelectorAll("button").forEach((b) => {
b.classList.toggle(
"panel-segmented__btn--active",
b.dataset.source === currentSource,
);
});
// Restore mode toggle UI
modeToggleEl.querySelectorAll("button").forEach((b) => {
b.classList.toggle(
"panel-segmented__btn--active",
b.dataset.mode === currentMode,
);
});
// Restore layout toggle UI
updateLayoutToggle();
// Update badge / footer to match restored state
modeBadgeEl.textContent = currentMode === "a" ? "Mode A" : "Mode B";
ftModeEl.textContent = currentMode === "a" ? "Mode A" : "Mode B";
ftSourceEl.textContent = currentSource === "reddit" ? "Reddit" : "RSS";
updateSourceSections();
updateFeedName();
createList();
/* Social Feed Example — variable-height posts with Source + Mode controls
Common styles (.container, h1, .description, .stats, footer)
are provided by examples/examples.css using shell.css design tokens.
Panel system (.split-layout, .split-panel, .panel-*)
is also provided by example/example.css. */
/* ==========================================================================
Mode badge
========================================================================== */
.mode-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
margin-left: 8px;
vertical-align: middle;
}
/* ==========================================================================
List container
========================================================================== */
.container.social-feed {
height: 100dvh;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.container > .header {
flex: none;
}
.split-main {
padding: 0 !important;
}
.split-layout {
flex: 1;
min-height: 0;
}
.container > .example-footer {
flex: none;
margin-bottom: 64px;
}
.layout-feed {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
#feed-name {
flex: none;
margin: 0;
padding: 10px 20px;
font-size: 16px;
font-weight: 600;
color: var(--vlist-text-muted);
letter-spacing: 0.2px;
}
#list-container {
flex: 1;
height: 0;
min-height: 0;
overflow: hidden;
background: var(--vlist-bg);
border-radius: 6px;
}
.split-main [id$="-container"] {
width: 100%;
margin: 0 auto;
}
/* ==========================================================================
vlist item overrides
========================================================================== */
#list-container .vlist-item {
padding: 0;
border-bottom: none;
cursor: default;
border-bottom: 1px solid var(--vlist-border);
}
/* ==========================================================================
Post card
========================================================================== */
.post {
padding: 16px 20px;
border-bottom: 1px solid var(--vlist-border);
display: flex;
flex-direction: column;
gap: 10px;
}
.post__header {
display: flex;
align-items: center;
gap: 10px;
}
.post__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
letter-spacing: -0.3px;
}
.post__meta {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.post__user {
font-weight: 600;
font-size: 14px;
color: var(--vlist-text);
}
.post__time {
font-size: 12px;
color: var(--vlist-text-muted);
}
/* ==========================================================================
Post title (live posts have a separate title)
========================================================================== */
.post__title {
font-size: 15px;
font-weight: 600;
color: var(--vlist-text);
line-height: 1.4;
word-break: break-word;
}
/* ==========================================================================
Post body text
========================================================================== */
.post__body {
font-size: 14px;
color: var(--vlist-text);
opacity: 0.88;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.post__body a {
color: var(--accent, #667eea);
text-decoration: none;
font-weight: 500;
}
.post__body a:hover {
text-decoration: underline;
opacity: 0.9;
}
/* ==========================================================================
Image — real <img> (live sources)
========================================================================== */
.post__image--real {
aspect-ratio: 16 / 9;
background: var(--vlist-placeholder-bg, rgba(127, 127, 127, 0.12));
display: block;
padding: 0;
border-radius: 10px;
overflow: hidden;
}
.post__image--real img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
object-position: center;
}
/* ==========================================================================
Tags
========================================================================== */
.post__tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.post__tag {
font-size: 12px;
font-weight: 500;
color: var(--accent, #667eea);
opacity: 0.8;
}
/* ==========================================================================
Action bar
========================================================================== */
.post__actions {
display: flex;
gap: 4px;
padding-top: 2px;
}
.post__action {
padding: 4px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--vlist-text-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.12s ease;
}
.post__action:hover {
background: var(--vlist-bg-hover, rgba(127, 127, 127, 0.08));
color: var(--vlist-text);
}
.post__action:active {
transform: scale(0.96);
}
.post__action--link {
text-decoration: none;
display: inline-flex;
align-items: center;
}
/* ==========================================================================
Source badges — small coloured dot after the username
========================================================================== */
.post--reddit .post__user::after,
.post--rss .post__user::after {
content: "";
display: inline-block;
width: 10px;
height: 10px;
margin-left: 5px;
vertical-align: middle;
border-radius: 50%;
opacity: 0.7;
flex-shrink: 0;
}
.post--reddit .post__user::after {
background: #ff4500;
}
.post--rss .post__user::after {
background: #ee802f;
}
/* ==========================================================================
Compact layout (.rpost)
Title-dominant, thumbnail on right, vote pills — denser news-style cards.
========================================================================== */
.rpost {
padding: 10px 16px;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
/* --- Header: avatar · u/name · time --- */
.rpost__header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--vlist-text-muted);
}
.rpost__more {
margin-left: auto;
font-size: 14px;
line-height: 1;
letter-spacing: 2px;
color: var(--vlist-text-muted);
text-decoration: none;
opacity: 0.5;
transition: opacity 0.12s ease;
flex-shrink: 0;
padding: 2px 4px;
border-radius: 4px;
}
.rpost__more:hover {
opacity: 1;
color: var(--vlist-text);
background: var(--vlist-bg-hover, rgba(127, 127, 127, 0.08));
}
.rpost__avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 9px;
flex-shrink: 0;
letter-spacing: -0.3px;
}
.rpost__user {
font-weight: 600;
color: var(--vlist-text);
font-size: 12px;
}
.rpost__dot {
opacity: 0.4;
}
.rpost__time {
font-size: 12px;
}
/* --- Body: content left + thumbnail right --- */
.rpost__body {
display: flex;
gap: 12px;
align-items: flex-start;
}
.rpost__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rpost__title {
font-size: 17px;
font-weight: 600;
color: var(--vlist-text);
line-height: 1.35;
margin: 0;
word-break: break-word;
}
.rpost__link {
font-size: 12px;
color: var(--accent, #667eea);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
display: block;
}
.rpost__text {
font-size: 13px;
color: var(--vlist-text);
opacity: 0.8;
line-height: 1.45;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rpost__text a {
color: var(--accent, #667eea);
text-decoration: none;
}
/* --- Thumbnail --- */
.rpost__thumb {
width: 128px;
height: 98px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
background: var(--vlist-placeholder-bg, rgba(127, 127, 127, 0.12));
}
.rpost__thumb img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
object-position: center;
}
.rpost__thumb--broken img {
display: none;
}
/* --- Action pills --- */
.rpost__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding-top: 2px;
}
.rpost__pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
background: var(--vlist-bg-hover, rgba(127, 127, 127, 0.08));
font-size: 12px;
font-weight: 500;
color: var(--vlist-text-muted);
cursor: pointer;
transition: background 0.12s ease;
border: none;
text-decoration: none;
line-height: 1;
white-space: nowrap;
}
.rpost__pill:hover {
background: var(--vlist-border);
color: var(--vlist-text);
}
.rpost__pill-icon {
font-size: 13px;
line-height: 1;
}
.rpost__vote {
font-size: 11px;
line-height: 1;
opacity: 0.6;
cursor: pointer;
transition: opacity 0.1s ease;
}
.rpost__vote:hover {
opacity: 1;
}
.rpost__vote--up:hover {
color: #ff4500;
}
.rpost__vote--down:hover {
color: #7193ff;
}
.rpost__count {
font-size: 12px;
font-weight: 600;
}
/* --- Compact layout: skeleton placeholders --- */
.vlist-item--placeholder .rpost__avatar {
background-color: var(--vlist-placeholder-bg) !important;
color: transparent;
}
.vlist-item--placeholder .rpost__user,
.vlist-item--placeholder .rpost__time {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 3px;
min-width: 60px;
}
.vlist-item--placeholder .rpost__time {
min-width: 40px;
}
.vlist-item--placeholder .rpost__dot {
visibility: hidden;
}
.vlist-item--placeholder .rpost__title {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
width: 80%;
min-height: 1.3em;
}
.vlist-item--placeholder .rpost__link {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 3px;
width: 40%;
min-height: 1em;
}
.vlist-item--placeholder .rpost__thumb {
background: var(--vlist-placeholder-bg);
}
.vlist-item--placeholder .rpost__thumb img {
visibility: hidden;
}
.vlist-item--placeholder .rpost__pill {
color: transparent;
min-width: 60px;
}
.vlist-item--placeholder .rpost__count,
.vlist-item--placeholder .rpost__vote,
.vlist-item--placeholder .rpost__pill-icon {
visibility: hidden;
}
.vlist-item--placeholder .rpost__more {
visibility: hidden;
}
/* ==========================================================================
Panel — select dropdown
========================================================================== */
.panel-select {
width: 100%;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--vlist-border);
background: var(--vlist-bg);
color: var(--vlist-text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23888' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
transition: border-color 0.12s ease;
}
.panel-select:focus {
outline: none;
border-color: var(--accent, #667eea);
}
.panel-select optgroup {
font-weight: 600;
font-size: 12px;
color: var(--vlist-text-muted);
}
.panel-select option {
font-weight: 500;
color: var(--vlist-text);
}
/* ==========================================================================
Panel — load more button variant
========================================================================== */
.panel-row--action {
padding-top: 4px;
}
.panel-btn--load {
width: 100%;
justify-content: center;
background: var(--accent, #667eea);
color: white;
opacity: 1;
font-weight: 600;
}
.panel-btn--load:hover:not(:disabled) {
background: var(--accent, #667eea);
filter: brightness(1.1);
color: white;
}
.panel-btn--load:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ==========================================================================
Panel section visibility toggle
========================================================================== */
.panel-section--hidden {
display: none;
}
/* ==========================================================================
End-of-feed indicator
========================================================================== */
.post-end {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 20px;
height: 100%;
}
.post-end__line {
flex: 1;
height: 1px;
background: var(--vlist-border);
}
.post-end__label {
font-size: 12px;
font-weight: 500;
color: var(--vlist-text-muted);
white-space: nowrap;
letter-spacing: 0.3px;
}
/* ==========================================================================
Disabled segmented button
========================================================================== */
.panel-segmented__btn--disabled,
.panel-segmented__btn:disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
/* ==========================================================================
Placeholder skeleton — .vlist-item--placeholder on the wrapper.
Same template renders, CSS hides text and shows skeleton blocks.
========================================================================== */
.vlist-item--placeholder .post {
gap: 12px;
}
.vlist-item--placeholder .post__avatar {
background-color: var(--vlist-placeholder-bg) !important;
color: transparent;
}
.vlist-item--placeholder .post__user,
.vlist-item--placeholder .post__time {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
width: fit-content;
min-width: 60%;
line-height: 1.1;
}
.vlist-item--placeholder .post__time {
min-width: 30%;
}
.vlist-item--placeholder .post__title {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
width: 85%;
line-height: 1.2;
}
.vlist-item--placeholder .post__body {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
width: 100%;
min-height: 2.4em;
line-height: 1.2;
}
.vlist-item--placeholder .post__image--real {
background: var(--vlist-placeholder-bg);
}
.vlist-item--placeholder .post__image--real img {
visibility: hidden;
}
.vlist-item--placeholder .post__tag {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
min-width: 50px;
line-height: 1;
}
.vlist-item--placeholder .post__action {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
min-width: 48px;
}
/* ==========================================================================
Muted label (placeholder text)
========================================================================== */
.panel-label--muted {
color: var(--vlist-text-muted);
font-style: italic;
font-size: 12px;
}
<div class="container social-feed">
<header>
<h1>
Social Feed
<span class="mode-badge" id="mode-badge">Mode A</span>
</h1>
<p class="description">
Social feed where item sizes are <strong>unknown upfront</strong>.
Choose a <strong>source</strong> (live Reddit or RSS) and a
<strong>mode</strong> to control how vlist handles variable sizes:
<strong>A</strong> (pre-measure all items at init via hidden DOM
element) or <strong>B</strong> (estimated size, let
<code>ResizeObserver</code> measure on the fly and correct scroll
position).
</p>
</header>
<div class="split-layout">
<div class="split-main layout-feed">
<h2 id="feed-name">Feed</h2>
<div id="list-container"></div>
</div>
<aside class="split-panel">
<!-- Source -->
<section class="panel-section">
<h3 class="panel-title">Source</h3>
<div class="panel-row">
<div class="panel-segmented" id="feed-source">
<button
class="panel-segmented__btn panel-segmented__btn--active"
data-source="reddit"
>
Reddit
</button>
<button class="panel-segmented__btn" data-source="rss">
RSS
</button>
</div>
</div>
</section>
<!-- Reddit options (only visible when source=reddit) -->
<section class="panel-section" id="section-reddit">
<h3 class="panel-title">Subreddit</h3>
<div class="panel-row">
<select class="panel-select" id="feed-subreddit">
<option value="worldnews">r/worldnews</option>
<option value="technology">r/technology</option>
<option value="science">r/science</option>
<option value="music">r/music</option>
<option value="movies">r/movies</option>
<option value="dataisbeautiful">
r/dataisbeautiful
</option>
<option value="todayilearned">r/todayilearned</option>
<option value="space">r/space</option>
<option value="explainlikeimfive">
r/explainlikeimfive
</option>
<option value="AskScience">r/AskScience</option>
</select>
</div>
<div class="panel-row">
<span class="panel-label">Status</span>
<span class="panel-value" id="info-feed-status">–</span>
</div>
<div class="panel-row">
<span class="panel-label">Posts loaded</span>
<span class="panel-value" id="info-feed-count">–</span>
</div>
</section>
<!-- RSS options (only visible when source=rss) -->
<section
class="panel-section panel-section--hidden"
id="section-rss"
>
<h3 class="panel-title">RSS Feed</h3>
<div class="panel-row">
<select class="panel-select" id="feed-rss">
<optgroup label="News">
<option
value="https://feeds.bbci.co.uk/news/world/rss.xml"
>
BBC World News
</option>
<option
value="https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml"
>
New York Times
</option>
<option
value="https://www.theguardian.com/world/rss"
>
The Guardian World
</option>
<option
value="https://feeds.reuters.com/reuters/topNews"
>
Reuters Top News
</option>
</optgroup>
<optgroup label="Tech">
<option value="https://hnrss.org/frontpage">
Hacker News
</option>
<option
value="https://feeds.arstechnica.com/arstechnica/index"
>
Ars Technica
</option>
<option
value="https://www.theverge.com/rss/index.xml"
>
The Verge
</option>
<option value="https://twobithistory.org/feed.xml">
Two-Bit History
</option>
</optgroup>
<optgroup label="Science & Space">
<option
value="https://www.nasa.gov/rss/dyn/breaking_news.rss"
>
NASA Breaking News
</option>
<option
value="https://www.newscientist.com/section/news/feed/"
>
New Scientist
</option>
<option
value="https://www.quantamagazine.org/feed/"
>
Quanta Magazine
</option>
</optgroup>
<optgroup label="Culture & Music">
<option
value="https://pitchfork.com/feed/feed-news/rss"
>
Pitchfork
</option>
<option value="https://www.openculture.com/feed">
Open Culture
</option>
<option
value="https://feeds.feedburner.com/brainpickings/rss"
>
The Marginalian
</option>
<option value="https://lithub.com/feed/">
Literary Hub
</option>
</optgroup>
<optgroup label="Design & Visual">
<option value="https://www.designboom.com/feed/">
Designboom
</option>
<option value="https://www.creativebloq.com/feed">
Creative Bloq
</option>
<option value="https://colossal.art/feed/">
Colossal
</option>
</optgroup>
</select>
</div>
<div class="panel-row">
<span class="panel-label">Status</span>
<span class="panel-value" id="info-rss-status">–</span>
</div>
<div class="panel-row">
<span class="panel-label">Items loaded</span>
<span class="panel-value" id="info-rss-count">–</span>
</div>
</section>
<!-- Layout -->
<section class="panel-section">
<h3 class="panel-title">Layout</h3>
<div class="panel-row">
<div class="panel-segmented" id="layout-toggle">
<button
class="panel-segmented__btn panel-segmented__btn--active"
data-layout="card"
>
Card
</button>
<button
class="panel-segmented__btn"
data-layout="compact"
>
Compact
</button>
</div>
</div>
</section>
<!-- Mode -->
<section class="panel-section">
<h3 class="panel-title">Mode</h3>
<div class="panel-row">
<div class="panel-segmented" id="mode-toggle">
<button
class="panel-segmented__btn panel-segmented__btn--active"
data-mode="a"
>
A · Pre-measure
</button>
<button class="panel-segmented__btn" data-mode="b">
B · Auto-size
</button>
</div>
</div>
</section>
<!-- Measurement -->
<section class="panel-section" id="section-measurement">
<h3 class="panel-title">Measurement</h3>
<div class="panel-row">
<span class="panel-label">Strategy</span>
<span class="panel-value" id="info-strategy"
>size: (index) => px</span
>
</div>
<div class="panel-row">
<span class="panel-label">Init time</span>
<span class="panel-value" id="info-init">–</span>
</div>
<div class="panel-row">
<span class="panel-label">Unique sizes</span>
<span class="panel-value" id="info-unique">–</span>
</div>
</section>
<!-- Navigation -->
<section class="panel-section">
<h3 class="panel-title">Navigation</h3>
<div class="panel-row">
<div class="panel-btn-group">
<button
id="jump-top"
class="panel-btn panel-btn--icon"
title="Top"
>
<i class="icon icon--up"></i>
</button>
<button
id="jump-middle"
class="panel-btn panel-btn--icon"
title="Middle"
>
<i class="icon icon--center"></i>
</button>
<button
id="jump-bottom"
class="panel-btn panel-btn--icon"
title="Bottom"
>
<i class="icon icon--down"></i>
</button>
<button
id="jump-random"
class="panel-btn panel-btn--icon"
title="Random"
>
<i class="icon icon--shuffle"></i>
</button>
</div>
</div>
</section>
</aside>
</div>
<footer class="example-footer" id="example-footer">
<div class="example-footer__left">
<span class="example-footer__stat">
<strong id="ft-progress">0%</strong>
</span>
<span class="example-footer__stat">
<span id="ft-velocity">0.00</span> /
<strong id="ft-velocity-avg">0.00</strong>
<span class="example-footer__unit">px/ms</span>
</span>
<span class="example-footer__stat">
<span id="ft-dom">0</span> /
<strong id="ft-total">0</strong>
<span class="example-footer__unit">items</span>
</span>
</div>
<div class="example-footer__right">
<span class="example-footer__stat" id="ft-source-stat">
<span id="ft-source">Reddit</span>
·
<strong id="ft-mode">Mode A</strong>
</span>
</div>
</footer>
</div>