Lattice Grid Buy a licence

demo D138

HTML in cells, safely in htmx

Templates escape by default; the opt-out and what it opens up

allowUnsafeTemplates: true

This grid escapes every templated value by default, then shows the deliberate opt-out for the cases where you do need real markup in a cell. It keeps a grid safe against injected markup out of the box, while making the risk explicit when you choose to allow HTML.

Building…
Loading a live grid…

This is the htmx version. A server-rendered table is upgraded in place, so the page is readable before any script runs and gains the full grid when the module loads, which suits a progressively enhanced app.

The configuration

<link rel="stylesheet" href="https://cdn.latticegrid.dev/lattice-grid@1.107.0/lattice-grid.min.css">

<!-- A real, server-rendered table: readable with JavaScript off. data-field on
     each header names the column that cell becomes, and data-id on each row is
     the key the grid carries it under. -->
<table id="html-in-cells-table" data-lattice-grid>
  <thead>
    <tr>
      <th data-field="service">Service</th>
      <th data-field="label">The stored value</th>
    </tr>
  </thead>
  <tbody>
    <tr data-id="1"><td>edge-router</td><td>&lt;b&gt;10G&lt;/b&gt; uplift</td></tr>
    <tr data-id="2"><td>billing-api</td><td>&lt;span style="color:#b45309"&gt;pending&lt;/span&gt; review</td></tr>
    <tr data-id="3"><td>legacy-vpn</td><td>&lt;em&gt;ceased&lt;/em&gt; on 2026-07-31</td></tr>
    <!-- the rest of the rows, rendered by your server -->
  </tbody>
</table>

<script type="module">
  import { hydrateTable } from 'https://cdn.latticegrid.dev/lattice-grid@1.107.0/modules/htmx.esm.min.js';

  // The markup carries the rows; a server-rendered table's column types are
  // declared in the config hydrateTable takes. The grid replaces the table
  // where it stood.
  const grid = hydrateTable(document.getElementById('html-in-cells-table'), {
    autoHeight: true,
    // The opt-out, and the whole subject of this page: an unsafe interpolation
    // is written as markup rather than escaped. Set it only when the data is
    // trusted, because the value is placed with no filtering of its own.
    allowUnsafeTemplates: true,
    columns: [
      { field: 'service', title: 'Service', layout: { width: 150, pin: 'start' } },
      // No template: the plain text path, which never parses markup.
      { id: 'raw', field: 'label', title: 'The stored value', layout: { width: 260 },
        cell: { wrap: true } },
      // Double braces escape, even with the opt-out set: the choice is per
      // interpolation, not per grid.
      { id: 'escaped', field: 'label', title: '{{value}}', layout: { width: 260 },
        cell: { template: '{{value}}', wrap: true } },
      // Triple braces render as elements, because the opt-out above is set.
      { id: 'unescaped', field: 'label', title: '{{{value}}}', layout: { width: 260 },
        cell: { template: '{{{value}}}', wrap: true } },
      // A render function returning a string goes through the same gate as triple
      // braces: markup without the opt-out is written as text.
      { id: 'fromString', field: 'label', title: 'render to string', layout: { width: 260 },
        cell: { render: (p) => String(p.value ?? ''), wrap: true } },
      // The route that never needs the opt-out: built with createElement and
      // textContent, so a value that contains markup is text by construction.
      { id: 'fromElement', title: 'render to element', layout: { flex: 1, min: 220 },
        cell: {
          render: (p) => {
            const span = document.createElement('span');
            const tier = document.createElement('b');
            tier.textContent = String(p.data?.tier ?? '');
            span.append(tier, document.createTextNode(` and ${String(p.data?.label ?? '')}`));
            return span;
          },
          wrap: true,
        } },
    ],
  });
</script>

Rendering trusted markup with allowUnsafeTemplates

By default, Lattice Grid escapes every value it puts into a cell template, so a string containing <span> or & lands on screen as literal text rather than markup. That default is what keeps a JavaScript data grid safe against XSS when a template pulls in user-submitted or third-party data: a name field, a comment, an imported spreadsheet cell. Setting allowUnsafeTemplates: true on the grid config lifts that escaping for every template column at once, so a developer reaches for it when the data is already trusted, or already sanitised upstream, and the cell genuinely needs to render markup rather than describe it: inline badges built from a template string, a highlighted substring, a small icon plus label composed in one interpolation rather than a full custom renderer. The option is grid-wide rather than per-column, so mixing untrusted and markup-bearing columns in the same grid means sanitising the untrusted values yourself before they reach the template. Turning the option on does not change how the grid virtualises rows: templates still compile once at initialisation and run at the same per-cell cost during scroll, whether their output is escaped or not. Screen readers read whatever text content the rendered markup exposes, so a badge or icon inserted this way needs its own accessible label if the visual marker is not decorative.

How do I render HTML inside a Lattice Grid cell?

Set allowUnsafeTemplates: true in the grid configuration, then include markup directly in a column’s cell.template string. The grid stops escaping template output for every column once this is set, so only enable it when the underlying data is trusted or has already been sanitised, since it removes the default protection against injected markup.