Designing one table component for three products
MSN’s knowledge agents answer questions with rich, structured content, and one shape kept showing up in the designs: a table. A finance agent comparing credit cards, a sports agent showing league standings, a weather agent laying out the week’s forecast. Different products, same pattern.
The twist is that nobody writes these tables by hand. The agent, an LLM, decides at runtime which columns to show, what data goes in them, and which interactions make sense. The front end only sees the result. So the real question wasn’t “how do we build a table?” but “how do we build a table that an AI can describe?”
Three options on the table
We considered three paths:
- A table per agent. Each one optimized for its use case, with no unused features. But it means duplicated code, inconsistent behavior, and three times the testing and maintenance, which gets worse with every new agent.
- A third-party library like TanStack Table. Battle-tested, feature-rich, TypeScript-native. But it’s an external dependency to track through breaking changes, it brings a learning curve, and it’s more than we needed.
- A hybrid, using TanStack’s logic with our own UI. It’s faster to get a rich feature set, but it still requires all the UI work and adds the dependency anyway.
We chose a fourth: one dynamic table, built in-house, driven entirely by a shared schema. It takes more work upfront, since sorting, filtering, and actions are ours to build. In return, we build only what we need, keep full control over behavior, and optimize for things a generic library wouldn’t care about, like rendering markdown inside a cell because that’s how LLMs like to write.
A schema an LLM can fill
The heart of the design is a schema. The agent has it on the backend and fills it in: columns, rows, optional filters, and actions. The front end turns that into a table, with no code change per agent.
A simplified version of what an agent might send:
{
"columns": [
{ "id": "card", "header": "Card", "field": "name", "cellType": "image" },
{ "id": "fee", "header": "Annual fee", "field": "fee", "cellType": "currency", "sortable": true },
{ "id": "rewards", "header": "Rewards", "field": "rewards", "cellType": "percentage", "sortable": true },
{ "id": "notes", "header": "Why it fits", "field": "summary", "cellType": "markdown" }
],
"data": []
}
Each column declares a cell type: text, numeric, percentage, currency, badge, avatar, icon, image, markdown, or a “dynamic component” that renders another web component from configuration. The table never needs to know what a credit card or a football team is. It only knows how to render a currency cell next to a markdown cell.
Layers: who owns what
The most important decision was drawing clear lines of responsibility:
flowchart TD
accTitle: Layers of the dynamic table
accDescr: An agent response passes through a renderer, which picks the table by content type. An experience layer adds custom cells, sorting, filtering, and actions. A shared table component owns state, styling, accessibility, and semantic HTML.
A["Agent response<br/>(table schema)"] --> R["Page renderer"]
R --> CT{{"Content-type registry<br/>type: table"}}
CT --> X
subgraph X ["Experience layer"]
CR["Cell registry"]
L["Custom sort, filter<br/>and action logic"]
end
X --> T
subgraph T ["Shared table component"]
S["Sort and filter state"]
ST["Styling and accessibility"]
H["Semantic table HTML"]
end
- The shared table component owns the fundamentals: internal sort and filter state, styling, accessibility, and rendering semantic table HTML from the schema. It exposes callbacks instead of hard-coding behavior.
- The experience layer owns everything specific to a use case: which custom cells exist, how a column sorts, which filters are offered, and what happens when someone clicks a row.
- The page just looks up the content type and hands over the schema.
Filtering is a good example of this split. The table doesn’t know what “only no-fee cards” means. The experience supplies a filter function, and the table applies it and keeps the state. Sorting and filtering also emit events to the parent, which is where telemetry hooks in.
Actions
Tables on these pages aren’t just for reading. We support two kinds of actions:
- Row actions, like following a team or opening a card’s details.
- Table actions, which apply to the whole table.
Like everything else, actions are declared in the schema and handled through callbacks. The table renders the buttons; the experience decides what they do.
Why a web component
We had three ways to build the shared piece: a React component wrapping a web component, a pure React component, or a pure web component. React would have matched the existing pages and let us reuse React cell components directly. But a pure web component can be dropped into any page across the platform, React or not. The cost was rewriting some existing React-based cells as web components, and we decided reach was worth it.
Accessibility, for free
Because the component renders real, semantic table HTML (<table>, <th>, <td>, proper headers) instead of a grid of divs, screen readers understand it out of the box. Sort state is reflected on the headers, and all of it lives in the shared component, so every agent’s table inherits it automatically. A fix made once ships everywhere.
The real problem: rich cells and a growing bundle
The first version handled text and numbers and stayed small. Then the cell types grew: badges, avatars, images, markdown, and eventually arbitrary components. Each request made sense on its own. Together, they meant every page would ship code for cell types it might never render. A weather table doesn’t need a markdown renderer, and a standings table doesn’t need a credit card image cell.
That’s the trap with shared components: every feature you add for one consumer becomes a cost for all of them.
Our fix was to load rich cell components only when a table actually uses them. The core ships with the basics. When a schema includes a heavier cell type, its component is fetched and registered on demand:
const cellLoaders: Record<string, () => Promise<unknown>> = {
markdown: () => import('./cells/markdown-cell.js'),
avatar: () => import('./cells/avatar-cell.js'),
dynamicComponent: () => import('./cells/dynamic-cell.js'),
};
async function ensureCells(columns: { cellType: string }[]) {
const needed = new Set(columns.map((c) => c.cellType));
await Promise.all([...needed].filter((t) => t in cellLoaders).map((t) => cellLoaders[t]()));
}
Now each table pays only for the cells it renders, and adding a rich cell type for one agent doesn’t make every other page heavier.
Questions we had to answer along the way
Some of the most interesting decisions weren’t about rendering at all:
- Which custom cells become generic types, and which stay one-offs?
- Pagination or virtualization for larger row counts, or both?
- How do dynamic components get registered safely across agents, and what’s the contract when one of them changes version?
- What exactly is the keyboard model: row focus, cell focus, actions, sort controls?
None of these has a universal answer, but deciding them deliberately, and writing the answers down, is most of what makes a shared component last.
Takeaways
- Design for the producer, not just the consumer. When an LLM writes the config, the schema is the API.
- Share structure, let the experience own behavior. Keep the core generic and pass in what’s specific through callbacks.
- Every shared feature has a cost for every consumer. Load optional code only when it’s used.
- Start with semantic HTML. It gives you accessibility for free, and it’s much harder to add later.