Feed view
Show a view as a feed of posts, loaded page by page from your server, newest first.
The Feed view works like Notion's Feed: a view of the table shows each record as a post in a centered column (720px wide at most), with its title, author and date, a body, media and a row of properties. Posts are loaded page by page from your server, newest first, and follow the view's search, filters and grouping. React and Vue behave the same.
Enable
The Feed mode ships in the table items and adds no dependency. List "feed" in displayModes:
export const updatesConfig = defineTableConfig({
...projectConfig,
table: {
...projectConfig.table,
displayModes: ["table", "feed"],
feed: {
titleColumn: "name",
authorColumn: "author",
dateColumn: "postedAt",
bodyColumn: "update",
propertyColumnIds: ["status", "category", "dueDate"],
},
},
});table.feed is optional: an object of default settings (the shape below) that every Feed view starts from, plus the renderBody hook, or false to turn the mode off. With false, "feed" is not offered even if listed in displayModes, and a link asking for it falls back to the default mode. A renderer passed as displayModeRenderers.feed replaces the built-in one.
Try the feed
Each post is a project's latest update, newest first and five per page: the title, the owner with initials, a relative date, a body clamped to three lines with Show more, and the status, category and start date. Open View settings › Card settings to change the columns, the number of lines or the page size. The preview uses the store-opening projects shared by every view guide; see the example setup for project-config.ts and the in-memory host.
Sample data. Changes stay in this preview.
import { defineTableConfig } from "@/components/ui/yayaw-table";
import { projectConfig } from "./project-config";
export const exampleConfig = defineTableConfig({
...projectConfig,
// Newest posts first; without a sort, the feed also sorts by its date column.
columns: { ...projectConfig.columns, sort: [{ id: "postedAt", desc: true }] },
table: {
...projectConfig.table,
displayModes: ["feed", "table"],
defaultDisplayMode: "feed",
feed: {
titleColumn: "name",
authorColumn: "author",
dateColumn: "postedAt",
bodyColumn: "update",
propertyColumnIds: ["status", "category", "start"],
bodyLines: 3,
pageSize: 5,
},
},
});Sample data. Changes stay in this preview.
import { defineTableConfig } from "@/components/ui/yayaw-table-vue";
import { projectConfig } from "./project-config";
export const exampleConfig = defineTableConfig({
...projectConfig,
// Newest posts first; without a sort, the feed also sorts by its date column.
columns: { ...projectConfig.columns, sort: [{ id: "postedAt", desc: true }] },
table: {
...projectConfig.table,
displayModes: ["feed", "table"],
defaultDisplayMode: "feed",
feed: {
titleColumn: "name",
authorColumn: "author",
dateColumn: "postedAt",
bodyColumn: "update",
propertyColumnIds: ["status", "category", "start"],
bodyLines: 3,
pageSize: 5,
},
},
});Posts
Each post shows:
Title: a button that opens the record view, like a row click. Empty titles read "Untitled".
Author: a text value, or a person-like value
{ name, avatarUrl }(labeloremailalso work as the name). Without an avatar, the author's initials are shown.Date: relative by default in the table locale ("3 hr. ago", "yesterday", "il y a 3 h"), with the full date on hover and in a
<time datetime>element. Date-only values such as2026-09-24compare by local calendar day. SetdateDisplay: "absolute"to show the full date instead.Body: plain text with its line breaks kept, clamped to
bodyLineslines. Show more and Show less are buttons (witharia-expanded), offered only when the body overflows, so they work from the keyboard.Media: up to four images from the media column (URLs, lists of URLs or
{ url, name, type }objects, with the same safe URL rule as the gallery), with a "+N" count for the rest. Other files are listed as links. Images load lazily in fixed-ratio boxes (loading="lazy",widthandheight), so nothing shifts when they arrive. Videos show their poster with native controls, never autoplay and load nothing before they play. Whentable.gallery.media.enabledcovers the feed's media column, the source it resolves is used.Properties: option columns as tags (colored-tags settings apply), numbers and dates formatted like the table, safe links, and text otherwise. Empty values are left out.
When a view is grouped (one level), posts are shown in sections under the group's label and count, in order of appearance.
Feed settings
Choose Feed under View settings › Display mode, then open Card settings in the same menu. Settings are saved with the view (config.feed) and in the <tableId>-feed URL key, for example ?projects-display=feed&projects-feed={"bodyLines":8}. Reset returns to table.feed, then to the defaults below.
| Setting | Default | Description |
|---|---|---|
titleColumn | First text column | The post title. |
authorColumn | Guessed from the column name (author, owner, created by…) | The author. null shows none. |
dateColumn | Guessed from the column name (posted, published, created…), else the first date column | The publication date, also used to sort the feed. null shows none. |
dateDisplay | "relative" | "relative" ("3 hr. ago", full date on hover) or "absolute" (full date). |
bodyColumn | Guessed from the column name (body, content, update, description…) among text columns | The post body. null shows none. |
mediaColumn | First image or files column | Images and files shown under the body. null shows none. |
propertyColumnIds | Option columns (select, multi-select, tags) | Columns shown at the bottom of each post, in order. The title, author, date, body, media and grouped columns are never repeated. |
showPropertyLabels | false | Show each property's column name before its value. |
bodyLines | 4 | Lines of body shown before Show more, 0 to 20. 0 always shows the full text. The panel offers 2, 3, 4, 6, 8, 12 and full text. |
density | "comfortable" | "comfortable" or "compact" cards. |
pageSize | 10 | Posts requested per page, 1 to 100. The panel offers 5, 10, 20 and 50. |
infiniteScroll | true | Load the next page when the end of the feed comes within one screen of the viewport. false keeps Load more only. |
Columns that no longer exist are ignored and guessed again.
table.feed.windowing is a table option, never saved in views. Past 60 loaded posts (or the number you set), only the posts near the viewport are rendered; the others keep their measured height, so the scroll position holds. The focused post and posts playing a video stay rendered. false renders every post, for example so the browser's find in page sees them all.
Loading from your server
The feed asks actions.list for one page at a time, with the view's query (search, filters, advanced filters) and:
page(starting at 1) andpageSize(also sent aslimit);when the view has no sort of its own, the date column newest first:
sorting: [{ id: dateColumn, desc: true }]andorderBy: { [dateColumn]: "desc" };grouping: [columnId]when the view is grouped.
{
search: "",
filters: {},
sorting: [{ id: "postedAt", desc: true }],
orderBy: { postedAt: "desc" },
page: 2,
pageSize: 10,
limit: 10,
}Return only that page's rows in data, as in the list response, with meta.pageCount or meta.totalCount so the feed knows when it has reached the end. Without either, a page shorter than pageSize ends the feed. A list that returns every row at once shows them all on the first page.
export async function listUpdates(params: Record<string, unknown>) {
const page = Math.max(1, Number(params.page) || 1);
const pageSize = Math.min(100, Math.max(1, Number(params.pageSize) || 10));
const [rows, totalCount] = await Promise.all([
db.updates.findMany({
where: buildWhere(params), // your list filtering
orderBy: buildOrderBy(params.sorting), // e.g. postedAt desc
skip: (page - 1) * pageSize,
take: pageSize,
}),
db.updates.count({ where: buildWhere(params) }),
]);
return {
data: rows,
meta: { pageCount: Math.ceil(totalCount / pageSize), totalCount },
};
}By default (infiniteScroll), the next page loads when the end of the feed comes within one screen of the viewport, or of the nearest scrolling ancestor such as a dashboard widget. Pages load one request at a time, without repeating records already shown. Load more stays as the keyboard and assistive-technology fallback: it is in the tab order, shows when focused, and is visible when infiniteScroll is false, without IntersectionObserver and after an error. Each loaded page is announced, for example "10 more posts loaded, 30 shown." A failed page keeps the posts shown, says so and pauses loading until Retry. At the end the feed reads "You're all caught up". After a create, edit or delete, the pages shown so far are reloaded. Without a list action, the table's local rows are paged and sorted the same way.
While the first page loads, skeleton posts are shown; an empty result shows the table's empty state, and a failed first page shows an error with Retry. The Feed view's code loads with the first feed shown (React lazy, Vue defineAsyncComponent), with the skeleton as the fallback.
Markdown and HTML bodies
Bodies are plain text by default, and nothing is ever inserted as HTML. To render markdown or rich text, set table.feed.renderBody(value, row): React returns a node, Vue a VNode or a string. The rendered body is clamped by height with the same Show more toggle. renderBody is a runtime hook: it is never saved in views or URLs.
If you render HTML, sanitize it first. Body values come from your records, so unsanitized HTML would let anyone who can edit a record inject scripts into every reader's page. This example parses markdown with marked and cleans the result with DOMPurify:
import DOMPurify from "dompurify";
import { marked } from "marked";
function renderBody(value: unknown) {
const markdown = typeof value === "string" ? value : "";
// Sanitize before rendering: record content is untrusted.
const html = DOMPurify.sanitize(marked.parse(markdown, { async: false }));
return (
<div className="prose prose-sm" dangerouslySetInnerHTML={{ __html: html }} />
);
}
export const updatesConfig = defineTableConfig({
...projectConfig,
table: {
...projectConfig.table,
displayModes: ["table", "feed"],
feed: { bodyColumn: "update", renderBody },
},
});import DOMPurify from "dompurify";
import { marked } from "marked";
import { h } from "vue";
function renderBody(value: unknown) {
const markdown = typeof value === "string" ? value : "";
// Sanitize before rendering: record content is untrusted.
const html = DOMPurify.sanitize(marked.parse(markdown, { async: false }));
return h("div", { class: "prose prose-sm", innerHTML: html });
}
export const updatesConfig = defineTableConfig({
...projectConfig,
table: {
...projectConfig.table,
displayModes: ["table", "feed"],
feed: { bodyColumn: "update", renderBody },
},
});A markdown component that renders no raw HTML, such as react-markdown without rehype-raw, needs no sanitizer: return it directly from renderBody.
Translations
The feed has built-in English and French labels; French is used when the locale starts with fr. Override any of them with flat feed.<key> keys in the table's translations, in React and Vue, for example "feed.loadMore": "Older posts".
Feed:
showMore,showLess,loadMore,loading,loadingMore,error,loadMoreError,retry,end,loadedOne(with{total}),loadedMany(with{count}and{total}),untitled,noValue,yes,no,moreImages(with{count}),by,mediaandvideo(with{title}).Settings:
titleColumn,authorColumn,dateColumn,dateDisplay,relative,absolute,bodyColumn,mediaColumn,properties,showPropertyLabels,bodyLines,lines(with{count}),noLimit,density,comfortable,compact,pageSize,infiniteScroll,on,off,none.
The Reset button reads common.reset. The mode's name in the display mode picker is views.display.feed in React and display.feed in Vue.
Compatibility notes
TableDisplayMode now includes "feed". If your code keeps an exhaustive Record<TableDisplayMode, …> map, such as icons or labels per mode, add a feed entry so it keeps compiling:
const modeLabels: Record<TableDisplayMode, string> = {
// ...
feed: "Feed",
};Renderer contexts gain groupBy, which custom display mode renderers can use the same way.