People need answers in a format that makes the information easy to use.
Updated September 12, 2026
Problem
An answer written as paragraphs can make comparisons or sequences harder to follow than they need to be.
Solution
Choose a format that fits the task. Use a table for comparing values, numbered steps for a sequence, and prose for an explanation. Let people request another format when the choice does not help.
Use proper table headers so screen readers can connect values to their rows and columns. Keep wide tables inside their own scroll area on small screens.
Preserve qualifications and sources alongside factual values. Offer copying or export in a useful format when people need to work with the result elsewhere. Handle malformed or incomplete output without breaking the page.
Use when
The information is easier to compare, follow, or look up in a structured format.
Don’t use when
The answer is an argument or an explanation. A table of an idea’s components communicates less than a paragraph, and chopping reasoning into cells removes the connective tissue that made it reasoning.
Trade-offs
Tables can make uncertain information look more precise than it is. Rich formats add rendering work and can copy poorly into other apps. A simple answer may become harder to read if forced into an unnecessary structure.
Checklist
Does the shape of the answer match the shape of the question?
Can a wide table scroll inside its own container?
Are header cells marked as headers for assistive technology?
What happens when a cell contains a pipe, a newline, or a link?
Is per-cell sourcing available where cells carry facts?
Can the viewer copy the table as text and as data?
Is there an export path for anything above a few rows?
How does a partially streamed table render?
Can the viewer ask for a different shape without re-asking the question?
Does the format degrade legibly at a phone width?
Compare
ChatGPT renders markdown tables inline and offers a canvas for output meant to be edited, separating a table read once from one that becomes a working document. Perplexity leans hardest into structured answers, leading with a scannable summary before any prose, which suits a product whose users came for a fact. Claude routes substantial structured output into an artifact where it can be revised across turns rather than regenerated whole. Notion converts the output into native database rows, so a generated table becomes an object with sorting, filtering and permissions instead of formatted text.
Related
Artifact panel gives large outputs more room. Code block actions offers controls for code. Response collapse can hide secondary detail.
Streaming response affects how incomplete structures appear. Citation chip connects facts within the output to sources.
Wireframe — the pattern's anatomy, not any one product's version of it
Implementation
A comparison answered as a grid. Real table markup so a screen reader can associate a value with its row and column, a source chip on any cell that carries a fact, a scrolling container past a few columns, and a control to ask for a different shape without re-asking.
AnswerTable.tsxHeader cells are headers, each cell may carry its own source, the box scrolls sideways on its own, and export builds real CSV with quoting.
import { Button } from "@/components/ui/button";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";/** * A comparison answered as a grid. * * Real table markup, because a grid drawn with spacing is a wall of numbers * to a screen reader; header cells are headers. Each cell can carry its own * source, because a number in a cell is believed in a way the same number in * a sentence is not, and one chip on the whole answer does not say which * cells it covers. The table scrolls inside its own box past a few columns * rather than pushing the page sideways. */export type Source = { id: number; label: string; href?: string };export type Cell = { value: string; source?: Source };export type Shape = "table" | "list";type Props = { columns: string[]; /** One entry per column, in column order. */ rows: Cell[][]; onSource: (source: Source) => void; /** The model routed this answer to a table. The viewer may disagree without * asking the question again. */ onReshape: (shape: Shape) => void; onExport: (csv: string) => void;};/** RFC 4180: quote a field that holds a comma, a quote or a newline. */const field = (s: string) => (/[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);export const toCsv = (columns: string[], rows: Cell[][]) => [columns, ...rows.map((r) => r.map((c) => c.value))].map((r) => r.map(field).join(",")).join("\n");export function AnswerTable({ columns, rows, onSource, onReshape, onExport }: Props) { return ( <div> <div className="overflow-x-auto rounded-lg border bg-muted"> <Table className="min-w-[320px] text-[11px]"> <TableHeader> <TableRow> {columns.map((c) => ( <TableHead key={c} scope="col" className="h-8 px-3">{c}</TableHead> ))} </TableRow> </TableHeader> <TableBody className="tabular-nums text-card-foreground"> {rows.map((cells, i) => ( <TableRow key={i} className="border-0"> {cells.map((cell, j) => ( <TableCell key={j} className="px-3 py-1.5 whitespace-nowrap"> {cell.value} {cell.source && ( <button type="button" onClick={() => onSource(cell.source!)} title={cell.source.label} aria-label={`Source ${cell.source.id}: ${cell.source.label}`} className="ml-1.5 inline-flex h-3.5 min-w-3.5 items-center justify-center rounded-full border px-1 align-middle text-[9px] text-muted-foreground hover:bg-accent" > {cell.source.id} </button> )} </TableCell> ))} </TableRow> ))} </TableBody> </Table> </div> <div className="mt-4 flex gap-2"> <Button variant="outline" size="sm" className="h-7 px-2.5 text-[11px] font-normal" onClick={() => onReshape("list")}> as a list </Button> <Button variant="outline" size="sm" className="h-7 px-2.5 text-[11px] font-normal" onClick={() => onExport(toCsv(columns, rows))}> export csv </Button> </div> </div> );}
demo.tsxHow it is called: three vendors across price and SLA, two prices sourced to workspace documents. As a list reshapes the same cells.
import { useState } from "react";import { AnswerTable, type Cell, type Shape, type Source } from "./AnswerTable";/** * Three vendors across price and SLA, the kind of question that arrives as * five paragraphs. Two of the prices are sourced to a document in the * workspace; the third came from the vendor's public page and is not. * "as a list" reshapes the same cells without re-asking. */const QUOTE: Source = { id: 1, label: "Acme quote, 4 Sep", href: "#acme-quote" };const PROPOSAL: Source = { id: 2, label: "Borden proposal, p. 3", href: "#borden-proposal" };const COLUMNS = ["Vendor", "Price", "SLA"];const ROWS: Cell[][] = [ [{ value: "Acme" }, { value: "£4,200", source: QUOTE }, { value: "99.9%" }], [{ value: "Borden" }, { value: "£3,850", source: PROPOSAL }, { value: "99.5%" }], [{ value: "Curtis" }, { value: "£5,010" }, { value: "99.95%" }],];export default function Demo() { const [shape, setShape] = useState<Shape>("table"); return ( <div className="rounded-lg border bg-card p-4"> {shape === "table" ? ( <AnswerTable columns={COLUMNS} rows={ROWS} onSource={(s) => { if (s.href) location.hash = s.href; }} onReshape={setShape} onExport={(csv) => navigator.clipboard.writeText(csv)} /> ) : ( <div> <ol className="space-y-2 text-[11px] text-card-foreground"> {ROWS.map(([vendor, price, sla]) => ( <li key={vendor.value}> <span className="font-medium">{vendor.value}</span>: {price.value} a month, {sla.value} uptime </li> ))} </ol> <button type="button" className="mt-4 rounded-md border px-2.5 py-1 text-[11px]" onClick={() => setShape("table")}> as a table </button> </div> )} </div> );}
What it renders. Identical markup in both panes, with only the token values changing.