Skip to content

Data grid page

The layout every operational screen ends up wanting: the chrome — title, filter chips, toolbar, pager — stays put, and the grid takes whatever height is left so only the grid scrolls, both axes, under sticky multi-level headers and frozen columns. Filter input lives in a dialog, because twenty conditions do not belong above the data they filter.

This is the CRUD list page’s sibling: that one is about the contracts a list screen needs (paging, bulk actions, dialog editing, undo), this one is about the frame they sit in when the grid is the whole point of the screen. The Data grid guide maps every operation this frame is grown with.

Order desk
  • Ship date
  • Order
Shown columns
    OrderOrderShipmentPeek
    Ordered onCustomerItemShip dateCarrier service levelAmount

    Filters

    Ship date

    Save view

    Who can see it

    Captures the conditions, the sort and the column set — not the page you are on.

    Open the full-size preview ↗

    A full-height screen shown inside a documentation column is a picture of the idea rather than the idea: the whole point is that the chrome is fixed and the grid takes the rest of the viewport. The preview above is scaled down to fit here; the link opens the same template with the screen to itself.

    Scroll the grid: the toolbar and the header rows stay, the first two columns stay, and the page itself never moves. Open Filters… and scroll the panel: its body scrolls while Apply stays reachable. The menu beside the title names the saved view currently applied — switching views is one interaction, from the screen.

    Both ship in this kit, and the fixed-height grid is not automatically the better one. It is the operational shape: SAP Fiori’s list report, Salesforce list views, ServiceNow lists, AG Grid, Ant Design’s scroll.y — back-office screens converge on it because the header, the toolbar and the pager must stay put while somebody works a queue for an hour.

    Page scroll is the content shape, and the default everywhere else: GitHub, Jira’s backlog, Linear, every mobile view. It costs nothing to build, and the browser’s own behaviours — Find, scroll restoration on Back, the address bar hiding on phones — keep working.

    Choose fixed height only when all four hold:

    1. The screen is an app frame. The page itself must not scroll, or you have two scrollbars and a grid that moves while you read it. That is what hc-shell + hc-fill are for.
    2. There is a fallback under the breakpoint. hc-shell becomes an ordinary scrolling page below 60rem, because a fixed frame on a phone leaves the data a few rows tall.
    3. Print un-caps the scrollport. On paper there is no scrolling: whatever the cap hides is missing, with nothing to say so. The opt-in print sheet does this — load it.
    4. The rows are paged, not infinite. A capped viewport over an endless list needs virtualisation to stay honest about position; a page and a count do not.

    If any of those is false, let the page scroll and keep --hc-datagrid-max-height at its 70vh default: a sticky header alone already buys most of the benefit.

    <main class="hc-shell__main app-main">
    <div class="page">
    …title, conditions, toolbar…
    <form class="hc-fill" id="order-selection">
    <div class="hc-datagrid hc-fill" id="orders"></div>
    </form>
    </div>
    </main>
    /* hc-shell__main becomes the row that holds the page column */
    .app-main {
    display: flex;
    min-block-size: 0;
    min-inline-size: 0;
    }
    /* The page column: fixed regions, then whatever says it fills */
    .page {
    display: flex;
    flex: 1;
    flex-direction: column;
    min-block-size: 0;
    min-inline-size: 0;
    gap: var(--hc-space-3);
    }

    .hc-fill goes on every element between the column and the grid — including the wrapping <form> — and on the grid itself, where it also switches --hc-datagrid-max-height from the default 70vh to 100%.

    It is a class, not a descendant rule, on purpose:

    • A page may hold several grids. A detail screen stacks a header grid, a lines grid and a history grid; only the one that says so may take the remaining height, and the others keep their own caps. A rule like .page > form > .hc-datagrid fills whichever grid the structure happens to match.
    • Structure changes. Wrap the grid in a <div> for a border and a descendant rule stops matching — silently, and the symptom (the page scrolls instead of the grid) shows up nowhere near the change.

    Both minimums are load-bearing, one per axis. A flex item will not shrink below its content, and a datagrid is oversized on both axes: its table is inline-size: max-content and it has as many rows as the page returned.

    • Miss min-block-size: 0 and the grid grows to fit every row, so the page scrolls vertically — the toolbar scrolls away and the header row unsticks.
    • Miss min-inline-size: 0 and the column grows to the table’s width, so hc-shell__main becomes the horizontal scrollport: scrolling right drags the title and the toolbar along, which is precisely what the grid’s own scrollport exists to prevent.

    The second one is easy to miss because the page looks correct until the table is wider than the screen.

    --hc-datagrid-max-height defaults to 70vh, which is right for a grid that sits in a scrolling page. Set it to 100% for a grid that is the page.

    hc-shell already does its half: __main is overflow: auto with min-block-size: 0, so the frame never pushes the viewport.

    Under 60rem hc-shell deliberately becomes an ordinary scrolling page (display: block, overflow: visible) — a fixed-height app frame is the wrong shape on a phone. The grid then has no definite parent height, so 100% caps nothing and it would render every row at full height. Restore a viewport cap:

    @media (width <= 60rem) {
    .page > .hc-datagrid {
    --hc-datagrid-max-height: 70vh;
    }
    }

    Real-app-shaped URLs; render the first page of rows server-side so the grid is full before any JavaScript runs.

    <body>
    <div class="hc-shell">
    <header class="hc-shell__header">
    <button class="hc-button hc-shell__toggle" data-variant="ghost" type="button"
    data-hc-shell-toggle aria-label="Open navigation"></button>
    <strong>Order desk</strong>
    </header>
    <nav class="hc-shell__sidebar" aria-label="Primary">
    <a href="/orders" aria-current="page">Orders</a>
    <a href="/shipments">Shipments</a>
    <a href="/customers">Customers</a>
    </nav>
    <main class="hc-shell__main app-main">
    <div class="page">
    <!-- Identity row: the heading names the screen, the menu names
    the saved view currently applied. -->
    <div class="hc-cluster" style="justify-content: space-between;">
    <div class="hc-cluster">
    <h1>Orders</h1>
    <button class="hc-button" data-variant="ghost" type="button"
    id="view-trigger" popovertarget="views">
    <span>Overdue shipments</span>
    <span class="hc-badge" data-variant="warning">Modified</span>
    <span aria-hidden="true"></span>
    </button>
    <div class="hc-menu" id="views" popover role="menu"
    aria-labelledby="view-trigger">
    <div role="group" aria-labelledby="views-pinned">
    <span class="hc-menu__label" id="views-pinned">Pinned</span>
    <a class="hc-menu__item" role="menuitemradio" aria-checked="true"
    href="/orders?view=overdue">Overdue shipments</a>
    <a class="hc-menu__item" role="menuitemradio" aria-checked="false"
    href="/orders?view=unapproved">Awaiting approval</a>
    </div>
    <hr class="hc-menu__separator">
    <a class="hc-menu__item" role="menuitemradio" aria-checked="false"
    href="/orders">Show everything</a>
    <hr class="hc-menu__separator">
    <!-- Discarding a tweak is a link back to the view's own
    URL — a form reset would restore the modified state. -->
    <a class="hc-menu__item" role="menuitem"
    href="/orders?view=overdue">Reset to saved conditions</a>
    <a class="hc-menu__item" role="menuitem" href="/views">Manage views…</a>
    </div>
    </div>
    <button class="hc-button" data-variant="secondary" type="button"
    onclick="document.getElementById('filters').showModal()">Filters (2)</button>
    </div>
    <!-- Applied conditions: one chip per condition, each opening the
    panel; each remove link is this URL minus that one param. -->
    <div class="hc-filterbar">
    <ul class="hc-filterbar__list">
    <li class="hc-filterbar__item">
    <button class="hc-filterbar__chip" type="button"
    onclick="document.getElementById('filters').showModal()">
    <span class="hc-filterbar__label">Ship date</span>
    <span class="hc-filterbar__op">from</span>
    <span class="hc-filterbar__value">start of this week (2026-08-10)</span>
    </button>
    <a class="hc-filterbar__remove" href="/orders?f-carrier=road"
    aria-label="Remove Ship date filter">×</a>
    </li>
    <!-- …one chip per applied condition (here: Carrier)… -->
    </ul>
    <a class="hc-filterbar__clear" href="/orders">Clear all</a>
    </div>
    <!-- Toolbar: actions on the DATA only. Selection-scoped
    actions are in their own bar below; navigation is under
    the grid. -->
    <div class="hc-toolbar" role="toolbar" aria-label="Order actions">
    <!-- Sort is a read-out first: the sorted column is usually
    scrolled out of view. See recipes/datagrid-sort. -->
    <button class="hc-button" data-variant="secondary" type="button"
    id="sort-trigger" popovertarget="sort-panel">
    Sort (2): Ship date ↓, Order ↑
    </button>
    <!-- The columns entry point. See recipes/datagrid-columns:
    a chooser form GETs the grid with repeated cols= params,
    in the order the rows were dragged into. -->
    <button class="hc-button" data-variant="ghost" type="button"
    id="columns-trigger" popovertarget="columns-panel">
    Columns (7 of 12)
    </button>
    <button class="hc-button" data-variant="ghost" type="button"
    data-hx-get="/orders/rows" data-hx-target="#order-rows">Refresh</button>
    <!-- Export carries the row count in its label and the current
    conditions in its href — a download means this question,
    not the rows on screen. -->
    <a class="hc-button" data-variant="ghost"
    href="/orders.csv?f-ship-from=@week-start&amp;f-carrier=road&amp;sort=-ship,order">Export 5,000 rows</a>
    <hr role="separator" aria-orientation="vertical">
    </div>
    <!-- Selection-scoped actions: installDatagridActions() reveals
    this bar when rows are ticked and hides it again. -->
    <div class="hc-toolbar" role="toolbar" aria-label="Selected orders"
    data-hc-datagrid-actions="#orders" hidden>
    <strong data-hc-datagrid-count></strong>
    <div class="hc-button-group" role="group" aria-label="Approval">
    <button class="hc-button" data-variant="ghost" type="submit"
    form="order-selection" name="action" value="approve">Approve</button>
    <button class="hc-button" data-variant="ghost" type="submit"
    form="order-selection" name="action" value="reject">Reject</button>
    </div>
    </div>
    <!-- The grid takes the rest. One form so checked ids serialize. -->
    <form class="hc-fill" id="order-selection" method="post" action="/orders/bulk">
    <div class="hc-datagrid hc-fill" id="orders" data-hc-zebra data-hc-datagrid-pending>
    <div class="hc-datagrid__scroll">
    <table class="hc-datagrid__table">
    <thead class="hc-datagrid__head">
    <tr>
    <th class="hc-datagrid__headcell" data-frozen rowspan="2" scope="col"
    style="--hc-datagrid-left: 0;">
    <!-- Select-all: no name — it must never serialize. -->
    <input type="checkbox" class="hc-checkbox" aria-label="Select all">
    </th>
    <th class="hc-datagrid__headcell" data-frozen data-frozen-edge rowspan="2"
    scope="col" style="--hc-datagrid-left: 2.5rem;">Order</th>
    <th class="hc-datagrid__headcell" colspan="3">Order</th>
    <th class="hc-datagrid__headcell" colspan="4">Shipment</th>
    </tr>
    <tr>
    <th class="hc-datagrid__headcell" scope="col">Ordered on</th>
    <th class="hc-datagrid__headcell" scope="col">Customer</th>
    <th class="hc-datagrid__headcell" scope="col">Item</th>
    <th class="hc-datagrid__headcell" scope="col">Ship date</th>
    <!-- A long label reads down a narrow column instead of
    widening it. `sideways` rotates the whole line. -->
    <th class="hc-datagrid__headcell" scope="col"
    data-orientation="vertical">Carrier service level</th>
    <th class="hc-datagrid__headcell" scope="col" data-numeric>Quantity</th>
    <th class="hc-datagrid__headcell" scope="col" data-numeric>Amount</th>
    </tr>
    </thead>
    <tbody class="hc-datagrid__body" id="order-rows">
    <tr class="hc-datagrid__row" id="row-4901">
    <td class="hc-datagrid__cell" data-frozen style="--hc-datagrid-left: 0;">
    <input type="checkbox" class="hc-checkbox" name="ids" value="4901"
    aria-label="Select order SO-4901">
    </td>
    <th class="hc-datagrid__cell" data-frozen data-frozen-edge scope="row"
    style="--hc-datagrid-left: 2.5rem;">SO-4901</th>
    <td class="hc-datagrid__cell">2026-07-14</td>
    <td class="hc-datagrid__cell">Northwind</td>
    <td class="hc-datagrid__cell">Bearing assembly 1000</td>
    <td class="hc-datagrid__cell" data-editable data-col="ship"
    data-value="2026-08-14">2026-08-14</td>
    <td class="hc-datagrid__cell">Road</td>
    <td class="hc-datagrid__cell" data-numeric>18</td>
    <td class="hc-datagrid__cell" data-numeric>2,610</td>
    </tr>
    <!-- …the rest of the page's rows… -->
    </tbody>
    </table>
    </div>
    </div>
    </form>
    <!-- Navigation under the data it moves through. -->
    <div class="hc-toolbar" role="toolbar" aria-label="Rows">
    <!-- Where you ARE at the start, where you GO at the end. -->
    <span aria-live="polite">4,901–4,940 of 5,000</span>
    <span data-hc-spacer="true"></span>
    <nav class="hc-pagination" aria-label="Pages">
    <a class="hc-pagination__item" data-hc-rel="prev" href="/orders?page=122">Previous</a>
    <a class="hc-pagination__item" data-hc-rel="next" href="/orders?page=124">Next</a>
    </nav>
    </div>
    </div>
    </main>
    </div>
    <!-- Filter panel. One form around header + body (the BODY is what
    scrolls); the footer sits OUTSIDE the form so Cancel can be its
    own <form method="dialog">, and Apply reaches the form via the
    `form` attribute. A `formmethod="dialog"` submit button inside
    the form would die the moment the form is enhanced with
    `data-hx-get`: htmx cancels the native submit, then refuses a
    non-HTTP formmethod. -->
    <dialog class="hc-dialog" id="filters" aria-labelledby="filters-title"
    style="--hc-dialog-max-width: 52rem;">
    <form method="get" action="/orders" id="filters-form">
    <!-- The panel edits conditions and closes the composition by
    naming it. Recall lives on the screen, not in here. -->
    <div class="hc-dialog__header">
    <div class="hc-cluster" style="justify-content: space-between;">
    <h2 class="hc-dialog__title" id="filters-title">Filters</h2>
    <div class="hc-cluster">
    <button class="hc-button" data-size="sm" type="button"
    data-hx-put="/views/Overdue%20shipments"
    data-hx-include="closest form"
    data-hx-target="#views">Update</button>
    <!-- Opens the save dialog: name, scope, make default. -->
    <button class="hc-button" data-size="sm" type="button">Save as new…</button>
    </div>
    </div>
    </div>
    <div class="hc-dialog__body">
    <!-- data-align="start": a three-row textarea must not stretch its
    neighbour into a tall empty box. -->
    <div class="hc-grid" data-align="start" style="--hc-grid-min: 18rem;">
    <div class="hc-field">
    <label class="hc-field__label" for="f-order">Order number</label>
    <!-- Value + operator share one bordered surface and one ring;
    the operator is secondary, so it renders quiet. -->
    <div class="hc-input-group">
    <input class="hc-input" type="text" id="f-order" name="f-order">
    <select class="hc-select" data-quiet name="op-order" aria-label="Order number operator">
    <option value="eq">equals</option>
    <option value="contains">contains</option>
    </select>
    </div>
    </div>
    <!-- Pasted lists become repeated params — one value per line. -->
    <div class="hc-field" data-span="full">
    <label class="hc-field__label" for="f-item">Item codes</label>
    <textarea class="hc-input" id="f-item" name="f-item" rows="3" data-hc-multi="lines"
    placeholder="One per line — paste a column from a spreadsheet"></textarea>
    </div>
    <!-- …the remaining conditions… -->
    <!-- data-applied marks the fields that are currently set. -->
    <div class="hc-field" data-applied>
    <span class="hc-field__label" id="ship-label">Ship date</span>
    <div class="hc-cluster" role="group" aria-labelledby="ship-label">
    <input class="hc-input" type="date" name="f-ship-from" aria-label="Ship date from">
    <span aria-hidden="true"></span>
    <input class="hc-input" type="date" name="f-ship-to" aria-label="Ship date to">
    </div>
    </div>
    </div>
    </div>
    </form>
    <div class="hc-dialog__footer">
    <form method="dialog"><button class="hc-button">Cancel</button></form>
    <button class="hc-button" data-variant="primary" type="submit"
    form="filters-form">Apply</button>
    </div>
    </dialog>
    </body>
    .page {
    display: flex;
    flex: 1;
    flex-direction: column;
    min-block-size: 0;
    min-inline-size: 0;
    gap: var(--hc-space-3);
    }
    /* Below hc-shell's breakpoint the shell is an ordinary scrolling page,
    so a filling grid has no definite parent height to measure against. */
    @media (width <= 60rem) {
    .hc-datagrid.hc-fill {
    --hc-datagrid-max-height: 70vh;
    }
    }

    The chain matters: every element between the page column and the grid carries hc-fill, including the selection <form>. Miss one and that axis scrolls the page instead of the grid.

    Views belong on the screen, not in the panel

    Section titled “Views belong on the screen, not in the panel”

    A list screen is asked four questions — what am I looking at, narrowed how, in what order, showing which columns — and each reads best with exactly one home. The first one is answered beside the title, by a menu whose label is the applied view’s name:

    • A saved view is a named URL, so recall is navigation, not filter editing. Bookmarks do not live inside an “edit address bar” dialog — and behind the panel, the screen’s most frequent act cost four interactions.
    • The items are role="menuitemradio" because exactly one view is applied at a time, with Show everything as the none-of-them option — the way back when a default view redirected the bare list URL.
    • They are also real <a href>s, so a view stays bookmarkable, middle-clickable, and works without JavaScript.
    • Pinned first, then recent, then Manage views…. Chips or tabs are fine when a team standardises on two or three views; the menu is the shape that still works at thirty.

    The panel keeps the two terminal actions of composing a condition set — Update and Save as new… — and nothing else. That leaves one undo (Cancel) instead of two sitting far apart, and it is why Reset is a link to the view’s own URL rather than a type="reset" button: a native reset restores the values the server rendered, which are the modified state, so the control that promised to undo the tweak was the one control guaranteed not to.

    Four kinds of control end up on a list screen, and they read as clutter the moment they share a strip. Each kind gets one home, chosen by what it changes:

    KindExamplesHome
    shapes the answerview, Filters, Sort, Columnsbeside the title, next to the view they belong to
    reads out the answerthe conditions chips, the result countits own line under them
    acts on the dataRefresh, Import, Exportthe toolbar
    acts on the selectionApprove, Reject, Open selecteda bar that appears when rows are ticked
    moves through the datapager, Go to rowunder the grid, where the movement happens

    Within the navigation strip, position carries meaning too: where you are (4,901–4,940 of 5,000) sits at the start, because it is a read-out and the frozen identity column it refers to is on that side; where you go (the pager) sits at the end, for two reasons that are about hands rather than taste — after scrolling the grid the pointer is already at the trailing edge, where the scrollbar lives, and Next is pressed far more often than anything else on the strip. Logical properties, so RTL swaps both without a second rule.

    Two of those are worth stating plainly:

    • Selection actions do not belong in the toolbar. Approve and Reject apply for the minutes a selection exists, and sit there being read all day for the rest of the time. installDatagridActions() reveals a bar when rows are ticked and hides it again — and a bar that appears is a better cue than a button that greys out, because a disabled button explains nothing.
    • Filters, Sort and Columns are one group. They answer the same question — what am I looking at — so splitting them (a Filters button by the title, Sort and Columns down in the toolbar) makes the screen look busier than it is.

    Sort and columns get controls of their own

    Section titled “Sort and columns get controls of their own”

    The other two of the four questions — in what order and showing which columns — get a control each, next to the filter entry point:

    • Sort says the whole set in its label (Sort (2): Ship date ↓, Order ↑) and opens the ordered, reorderable list — see datagrid-sort for why headers alone cannot answer it.
    • Columns opens the chooser. The recipe already existed; what the screen lacked was a way in. The count rides in the label (Columns (7 of 12)), because a grid missing the column you are looking for is indistinguishable from a grid whose data is missing.

    A column set is a preference, not a condition. It follows a user between screens and devices, so it is stored per user rather than retyped into every URL — while a link that names columns still wins, which is what makes sharing work:

    URL → user preference → app default

    A saved view may pin a column set (“Shipping check” usually means the filters and the columns for that job); when it does, applying it visibly changes the columns and offers the way back.

    The strip under the title is not decoration: it is what the screen is currently filtered by, and each chip is the way to change that condition. One of them is relativestart of this week (2026-08-10) — showing the expression that was stored and the date it resolved to today, so the condition is never a guess and a saved view built on it is still right next week.

    Three consequences the template shows:

    • Export carries the query. The button says Export 5,000 rows and its href holds the same conditions and columns — a download means this question, not the rows on screen.
    • Sort travels with the conditions. The sort panel serializes the whole set as one sort=-ship,order param, so it survives an Apply and a saved view captures it.
    • A tweaked view says so. The view menu’s label carries the Modified badge, because applying a view and changing one condition is the commonest thing users do with saved views — and the badge is only truthful where the comparison happens, next to the name it is comparing against.

    A filter panel is read far more often than it is filled in, so it is laid out for scanning:

    • Labels stay above their control. Faster to scan and to complete, and robust to label length — “Buyer” and 発注者コード differ by a factor no fixed label column absorbs.
    • Fields align to the top of their row (hc-grid + data-align="start"), so a three-row textarea stops stretching its neighbour into a tall empty box.
    • Anything multi-line takes a whole row (data-span="full"). hc-grid is auto-fit, so which fields pair up changes with width; nothing may depend on a particular pairing.
    • The operator is secondary (data-quiet on the select inside the input group). equals is what nearly every row uses; rendering it at the value’s weight doubles the cost of finding what is actually set.
    • Applied fields are marked (data-applied on the field), so “what is currently set” is answerable at a glance rather than by reading eight rows. The dot is a scanning aid — the announcement is the conditions bar above the data.
    • One vocabulary: Apply. Not Search here and Apply in the sort panel.
    • Cancel is its own <form method="dialog"> — the native, JS-free close, the same idiom the remote-dialog contract prescribes. Not a formmethod="dialog" submit button inside the filter form: that dies the moment the form is enhanced with data-hx-get, because htmx cancels the native submit of a form it manages and then refuses to issue a request for a non-HTTP formmethod — a button that does nothing. The footer therefore sits outside the form, and Apply reaches it via the form attribute.

    Icons follow the same rule the rest of the screen does: icon + label for anything not universal, icon-only for close / overflow / pager chevrons, and the count in the label rather than a badge (Filters (3)). See icons.

    The layout rule has a corollary that is easy to miss until a bulk action fails in fifteen different ways:

    Anything whose height grows with the data lives in the scrolling area, or in an overlay — never in the chrome.

    The chrome is what the grid’s height is subtracted from. A bulk-error report rendered above the grid is O(number of reasons), so on a laptop it squeezes the grid to nothing — hiding exactly the rows it is telling the user to go and fix.

    So the chrome carries one line:

    <div id="bulk-report" aria-live="polite">
    <div class="hc-alert" data-variant="warning" role="status">
    <p class="hc-alert__body">
    <strong>12 of 40 rows could not be updated.</strong>
    <a href="/orders?f-last-result=failed">Show only failed (12)</a> ·
    <a href="/orders/bulk/report">Review reasons</a>
    </p>
    </div>
    </div>
    /* The backstop: unbounded server content, bounded region. */
    #bulk-report {
    max-block-size: min(25vh, 12rem);
    overflow: auto;
    }

    The line also carries the moves, because twelve failures scattered through five thousand rows is a queue:

    <a href="#row-4903">Previous</a>
    <span>Error 3 of 12 — row 137</span>
    <a href="#row-5012">Next</a>

    Real fragment links naming rows by id, with the ordinal shown beside them — installDatagrid() lands the active cell on the row a fragment names, so “next error” is focus movement with no client state, and Back still works. A Go to row control in the toolbar (?goto=137) covers the number somebody read out loud; the server resolves it to the page that contains it.

    The detail lives where growth is already handled: the failing rows carry data-attention="error" and their own message row (they scroll, because they are the data), and the grouped breakdown opens in a docked panel beside the grid — a side panel spends horizontal space, which this layout has. A modal is right only when nothing was applied and the user owes a decision.

    The same rule governs anything else a server fills: a validation summary, an import preview, a “3 filters could not be applied” notice.

    RegionComponentServer contract
    App framehc-shell
    Applied conditionshc-filterbardatagrid-filter — one chip per condition, each opening its own editor; remove links drop one param
    Toolbarhc-toolbar + installToolbar()— (one Tab stop, arrow keys within)
    Sorta toolbar control + installSortList()datagrid-sort — the trigger says the whole set, the panel reorders it (sort=-ship,order)
    Selection actionsinstallDatagridActions()datagrid-bulk-actions, and datagrid-bulk-errors for failures at scale
    Pagerhc-paginationdatagrid-pager
    Gridhc-datagriddatagrid-filter for f-<col> params
    Inline editsdata-editable + editor <template>datagrid-edit-errors (422 + confirmable warnings), datagrid-edit-conflict (409)
    Row → detailthe identity cell’s <a href> + installRowLink()row-detail — Back to list returns to this URL + #row-<id>; Open selected (N) walks an ordered snapshot
    Filter panelhc-dialog + hc-grid + hc-input-groupdatagrid-filter
    Saved viewshc-menu beside the title + Update / Save as new… in the panelsaved-views — apply is a GET link, PUT /views/<name> updates in place, and the server owns the modified comparison
    Multi-value entry<textarea data-hc-multi="lines">datagrid-filter — pasted lists become repeated params
    Relative datescondition values like @week-startdatagrid-filter — stored as the expression, resolved by the server
    Exporta link carrying the current querydatagrid-filter — same conditions, same columns, all pages
    Column set / widthsa toolbar control opening the chooserdatagrid-columns — repeated cols= params, in the dragged order — and datagrid-prefs for widths
    • Header levels. Sticky offsets are measured for three levels (--hc-datagrid-head-1-h, -2-h). A fourth needs its own rule.
    • Long column labels. data-orientation="vertical" rotates the label so it reads down a narrow column instead of widening it; sideways rotates the whole line the other way.
    • Column bands. Tint a column with data-highlight on its cells, or colour a header band by overriding --hc-datagrid-head-bg on those header cells — the grid expects to be re-skinned.
    • Filter panel from the server. Swap the inline <dialog> for the remote-dialog recipe when the condition set is per-user or large enough to be worth fetching.
    • No-JS. The panel is a plain <form method="get"> and the pager is plain links, so the screen filters and pages without JavaScript. The grid renders; only inline editing and the roving toolbar need it.
    • Density. data-density="compact" on the shell tightens the whole screen; check touch targets if your users are on tablets (see themes and runtime axes).