Data

VirtualTable

A table of thousands of rows that draws only those in view, sorted and searched across all of them.

#data#table#interactive

Demo

Preview
10,000 rows
ORD-10001Acme CorpBerlin2026-01-01Paid5
ORD-10002Tyrell CorpMadrid2026-02-10Open84.19
ORD-10003GlobexParis2026-03-19Overdue163.38
ORD-10004Umbrella HealthToronto2026-04-28Draft242.57
ORD-10005InitechLondon2026-05-09Refunded321.76
ORD-10006Vandelay ImportsOslo2026-06-18Paid400.95
ORD-10007Northwind TradersTokyo2026-08-27Open480.14
ORD-10008Wayne EnterprisesLisbon2026-09-08Overdue559.33
ORD-10009Pied PiperNew York2026-10-17Draft638.52
ORD-10010Wonka FoodsSão Paulo2026-11-26Refunded717.71
ORD-10011SoylentBerlin2026-01-06Paid796.9
ORD-10012CyberdyneMadrid2026-02-15Open876.09
ORD-10013Stark IndustriesParis2026-03-24Overdue955.28
ORD-10014Acme CorpToronto2026-04-05Draft1,034.47
ORD-10015Tyrell CorpLondon2026-05-14Refunded1,113.66
ORD-10016GlobexOslo2026-07-23Paid1,192.85
ORD-10017Umbrella HealthTokyo2026-08-04Open1,272.04

Click a row, or press Enter on one.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/virtual_table.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/virtual_table.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/virtual_table.qwe
let virtual_table_demo_customers = ["Acme Corp", "Globex", "Initech", "Northwind Traders", "Pied Piper", "Soylent",    "Stark Industries", "Tyrell Corp", "Umbrella Health", "Vandelay Imports", "Wayne Enterprises", "Wonka Foods", "Cyberdyne"]let virtual_table_demo_cities = ["Berlin", "Lisbon", "London", "Madrid", "New York", "Oslo", "Paris", "São Paulo", "Tokyo", "Toronto"]let virtual_table_demo_status = ["Paid", "Open", "Overdue", "Draft", "Refunded"]// 10,000 orders, made the same way on the server and in the browser.fn virtual_table_demo_orders(n: int) -> list =    for i in range(0, n) => {        let day = (i * 37) % 365        let month = floor(day / 31) + 1        {            number: `ORD-${10001 + i}`,            customer: virtual_table_demo_customers[(i * 7) % 13],            city: virtual_table_demo_cities[(i * 3) % 10],            placed: `2026-${month < 10 ? "0" : ""}${month}-${day % 28 + 1 < 10 ? "0" : ""}${day % 28 + 1}`,            status: virtual_table_demo_status[(i * 11) % 5],            total: ((i * 7919) % 250000) / 100 + 5,        }    }component VirtualTableDemoLive() {    state last = null    let orders = virtual_table_demo_orders(10000)    <div.demo-stack>        <VirtualTable id="vt-orders" label="Orders" search={true} rows={orders} height={380}                      on_select={(r) => { last = r.number }}                      columns={[                          { key: "number", label: "Order", sortable: true, width: "120px" },                          { key: "customer", label: "Customer", sortable: true },                          { key: "city", label: "City", sortable: true, width: "120px" },                          { key: "placed", label: "Placed", sortable: true, width: "120px" },                          { key: "status", label: "Status", sortable: true, width: "116px" },                          { key: "total", label: "Total", format: "number", sortable: true, width: "110px" },                      ]}/>        <p.demo-note>{last == null ? "Click a row, or press Enter on one." : `Opened ${last}.`}</p>    </div>}view VirtualTableDemo() = <VirtualTableDemoLive/>

Source

ui/virtual_table.qwe
// VirtualTable: a table of thousands of rows that stays fast: only the rows in// view, and a few around them, are on the page, between two empty rows of the// right height. Sorting (by clicking a header) and the search box go through// every row. The header stays in view as it scrolls.////   <VirtualTable id="orders" label="Orders" search={true} order_by="placed" descending={true} rows={orders}//                 columns={[//                     { key: "number", label: "Order", sortable: true, width: "110px" },//                     { key: "customer", label: "Customer", sortable: true },//                     { key: "total", label: "Total", format: "number", sortable: true },//                 ]}/>//// id: a prefix for its ids, unique on the page. columns: what to show, in order,// each { key, label, sortable?, align?, width?, format? }: format "number" groups// the digits (12,345.6) and lines it up on the right, as align: "right" does;// width is a CSS width (the others share what is left). rows: a list of maps.// order_by, descending: how it is sorted at first. search: a box above it that// keeps the rows where any column has what is typed. row_height: each row's// height in pixels (36; longer text is cut with …). height: the table's height in// pixels (420). overscan: rows drawn beyond each edge (6). min_width: the CSS width// below which it scrolls sideways ("720px"), as on a phone. label: the table's name.// empty: what it says with no rows ("Nothing to show").// It is an ARIA grid that knows its full size (aria-rowcount): ↓ ↑, Page Down, Page// Up, Home and End move the row the keys are on, Enter picks it.// on_select(row): inside one of your components, called with the row picked.import ui/theme.qwe use ui_clslet virtual_table_css = css`.ui-vt { display: grid; gap: 10px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-vt-top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 14px; }.ui-vt-search { width: min(100%, 280px); height: 36px; padding: 0 12px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: inherit; }.ui-vt-search::placeholder { color: var(--ui-muted); opacity: 1; }.ui-vt-search:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-vt-count { color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-vt-scroll { overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface);  overscroll-behavior: contain; }.ui-vt table { width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; outline: none; }.ui-vt table:focus-visible { box-shadow: inset var(--ui-focus); }.ui-vt th, .ui-vt td { box-sizing: border-box; height: var(--ui-vt-rh); padding: 0 14px; text-align: left; overflow: hidden;  text-overflow: ellipsis; white-space: nowrap; }.ui-vt td { box-shadow: inset 0 -1px 0 var(--ui-border); }.ui-vt th { position: sticky; top: 0; z-index: 1; background: var(--ui-subtle); color: var(--ui-muted); box-shadow: inset 0 -1px 0 var(--ui-border);  font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }.ui-vt .ui-vt-right { text-align: right; font-variant-numeric: tabular-nums; }.ui-vt-sort { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; margin: 0 -6px; padding: 4px 6px; border: 0;  border-radius: 4px; background: none; color: inherit; font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }.ui-vt-sort:hover { color: var(--ui-fg); }.ui-vt-sort:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-vt-right .ui-vt-sort { flex-direction: row-reverse; }.ui-vt-arrow { flex: none; width: 12px; height: 12px; opacity: .35; }[aria-sort] .ui-vt-sort { color: var(--ui-fg); }[aria-sort] .ui-vt-arrow { opacity: 1; color: var(--ui-accent); }.ui-vt tbody tr:not(.ui-vt-pad) { cursor: pointer; }.ui-vt tbody tr:not(.ui-vt-pad):hover td { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-vt tr[aria-selected="true"] td { background: var(--ui-accent-soft); }.ui-vt table:focus-visible tr.ui-vt-active td { background: var(--ui-subtle); }.ui-vt table:focus-visible tr.ui-vt-active td:first-child { box-shadow: inset 3px 0 0 var(--ui-accent), inset 0 -1px 0 var(--ui-border); }.ui-vt table:focus-visible tr.ui-vt-active[aria-selected="true"] td { background: var(--ui-accent-soft); }.ui-vt .ui-vt-pad td { height: auto; padding: 0; box-shadow: none; }.ui-vt-empty td { color: var(--ui-muted); text-align: center; }`// A whole number with commas, for the count: 10000 is "10,000".fn virtual_table_count(n: int) -> str {    let s = str(n)    len(s) <= 3 ? s : virtual_table_count(floor(n / 1000)) + "," + slice(str(1000 + n % 1000), 1, 4)}// A number with its digits grouped and at most two decimals: 12345.678 is "12,345.68".fn virtual_table_number(v) -> str {    if type_of(v) != "int" && type_of(v) != "float" { return v == null ? "—" : str(v) }    let cents = round(abs(v) * 100)    let frac = cents % 100    let tail = frac == 0 ? "" : (frac % 10 == 0 ? `.${floor(frac / 10)}` : `.${slice(str(100 + frac), 1, 3)}`)    `${v < 0 ? "-" : ""}${virtual_table_count(floor(cents / 100))}${tail}`}fn virtual_table_text(v, format: str?) -> str =    format == "number" ? virtual_table_number(v) : (v == null ? "—" : str(v))fn virtual_table_right(c: map) -> bool = c.align == "right" || (c.align == null && c.format == "number")// The row a key moves to from row `at` of `total`, or null for a key that does not move.// A top-level fn: a `match` in the handler would make what follows it, `preventDefault`// and the move, wait a turn, and a second key pressed in that time would start from the// old row.fn virtual_table_key_to(key: str, at, total, page) = match key {    "ArrowDown" => min([total - 1, at + 1])    "ArrowUp" => max([0, at - 1])    "PageDown" => min([total - 1, at + page])    "PageUp" => max([0, at - page])    "Home" => 0    "End" => total - 1    _ => null}component VirtualTable(id: str, columns: list = [], rows: list = [], order_by: str? = null, descending: bool = false,                       search: bool = false, row_height: int = 36, height: int = 420, overscan: int = 6,                       min_width: str = "720px", label: str? = null, empty: str = "Nothing to show", on_select = null,                       class: str? = null) {    style global virtual_table_css    state sort_key = order_by    state sort_desc = descending    state query = ""    state top = 0    state active = 0    state picked = null      // the index in `rows` of the row picked    let needle = lower(trim(query))    let numbered = for [i, r] in enumerate(rows) => { i, r }    let found = needle == "" ? numbered        : numbered | filter((x) => any(columns, (c) => contains(lower(virtual_table_text(x.r[c.key], c.format)), needle)))    let ordered = sort_key == null ? found        : found | sort_by((x) => {            let v = x.r[sort_key]            type_of(v) == "str" ? lower(v) : v        })    let sorted = sort_desc ? reverse(ordered) : ordered    let total = len(sorted)    let from_i = max([0, floor(top / row_height) - overscan])    let to_i = min([total, ceil((top + height) / row_height) + overscan])    let window = range(from_i, to_i) | map((k) => { k, x: sorted[k] })    let at = active < total ? active : max([0, total - 1])    // Moves the keys to row k, scrolling it into view below the header.    let go = (e, k: int) => {        active = k        let box = e.target.closest(".ui-vt-scroll")        let body = box.clientHeight - row_height        let y = k * row_height        if y < box.scrollTop {            box.scrollTop = y            top = y        } else if y + row_height > box.scrollTop + body {            box.scrollTop = y + row_height - body            top = y + row_height - body        }    }    let pick = (k: int) => {        active = k        picked = sorted[k].i        on_select && on_select(sorted[k].r)    }    let restart = (e) => {        active = 0        top = 0        let box = e.target.closest(".ui-vt").querySelector(".ui-vt-scroll")        box.scrollTop = 0    }    <div class={ui_cls(["ui-vt", class])} style={`--ui-vt-rh: ${row_height}px`}>        <div.ui-vt-top>            {search && <input.ui-vt-search type="search" placeholder="Search" aria-controls={`${id}-grid`}                              aria-label={label != null ? `Search ${lower(label)}` : "Search"} value={query}                              @input={(e) => {                                  query = e.value                                  restart(e)                              }}/>}            <span.ui-vt-count aria-live="polite">                {needle == "" ? `${virtual_table_count(total)} row${total == 1 ? "" : "s"}`                              : `${virtual_table_count(total)} of ${virtual_table_count(len(rows))} rows`}            </span>        </div>        <div.ui-vt-scroll style={`height: ${height}px`} @scroll={(e) => { top = e.scrollTop }}>            <table id={`${id}-grid`} role="grid" tabindex="0" aria-label={label} style={`min-width: ${min_width}`} aria-rowcount={total + 1}                   aria-colcount={len(columns)}                   aria-activedescendant={total > 0 ? `${id}-row-${at}` : null}                   @keydown={(e) => {                       let page = max([1, floor(height / row_height) - 2])                       let to = virtual_table_key_to(e.key, at, total, page)                       if e.target.tagName != "TABLE" || total == 0 {                           null                       } else if to != null {                           e.preventDefault()                           go(e, to)                       } else if e.key == "Enter" || e.key == " " {                           e.preventDefault()                           pick(at)                       }                   }}>                <colgroup>{for c in columns => <col style={c.width != null ? `width: ${c.width}` : null}/>}</colgroup>                <thead>                    <tr aria-rowindex="1">                        {for c in columns => <th scope="col" role="columnheader" class={virtual_table_right(c) ? "ui-vt-right" : null}                                aria-sort={sort_key == c.key ? (sort_desc ? "descending" : "ascending") : null}>                            {c.sortable == true                                ? <button.ui-vt-sort type="button" @click={(e) => {                                      if sort_key == c.key { sort_desc = !sort_desc } else { sort_key = c.key; sort_desc = false }                                      restart(e)                                  }}>                                      <span style="overflow: hidden; text-overflow: ellipsis">{c.label ?? c.key}</span>                                      <svg.ui-vt-arrow viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6"                                           stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">                                          <path d={sort_key != c.key ? "M3.5 4.5L6 2l2.5 2.5M3.5 7.5L6 10l2.5-2.5"                                                   : (sort_desc ? "M6 2v8M3 7l3 3 3-3" : "M6 10V2M3 5l3-3 3 3")}/>                                      </svg>                                  </button>                                : (c.label ?? c.key)}                        </th>}                    </tr>                </thead>                <tbody>                    {total == 0                        ? <tr.ui-vt-empty><td colspan={str(len(columns))}>{empty}</td></tr>                        : [                            <tr.ui-vt-pad aria-hidden="true"><td colspan={str(len(columns))} style={`height: ${from_i * row_height}px`}></td></tr>,                            for w in window => <tr key={w.k} id={`${id}-row-${w.k}`} aria-rowindex={w.k + 2}                                    class={w.k == at ? "ui-vt-active" : null}                                    aria-selected={picked == w.x.i ? "true" : "false"}                                    @mousedown={(e) => { e.preventDefault(); e.target.closest("table").focus() }}                                    @click={() => { pick(w.k) }}>                                {for c in columns => <td class={virtual_table_right(c) ? "ui-vt-right" : null}>                                    {virtual_table_text(w.x.r[c.key], c.format)}                                </td>}                            </tr>,                            <tr.ui-vt-pad aria-hidden="true"><td colspan={str(len(columns))} style={`height: ${(total - to_i) * row_height}px`}></td></tr>,                          ]}                </tbody>            </table>        </div>    </div>}
Thread
67Useful? Like it, or add to its thread below.

Thread 0 posts

On VirtualTable: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.