GitHub user rlei-odes edited a discussion: Handlebars charts on 6.1.0: getting 
HTML and CSS to actually render


Hey all, after discovering what a Handlebar chart even is, I was set back to 
discover that the rendering did not work on my installation. After even the llm 
giving up, some more digging got it to work and I was pleasantly surprised to 
see what is possible. In short, get an output from your data with html and css.

So here is the whole thing: the config that works, the two rules that decide
whether your CSS does anything, and a complete worked example you can paste. 
Point your llm of choice to this post.

Everything below was verified on **6.1.0**.

<img width="815" height="415" alt="hb_post_example" 
src="https://github.com/user-attachments/assets/71a67592-243a-48af-bbde-5439ff7d215d";
 />

That is a Handlebars chart on `cleaned_sales_data`, which ships with Superset's
example data — the SQL, template and CSS are all at the bottom.

---

## 1. The config for your installation

```python
# superset_config.py

FEATURE_FLAGS = {
    # ... your existing flags ...
    "ESCAPE_MARKDOWN_HTML": False,
}

# TOP LEVEL, not inside FEATURE_FLAGS.
HTML_SANITIZATION = True

HTML_SANITIZATION_SCHEMA_EXTENSIONS = {
    # Without "style" the chart's CSS panel is stripped and its contents are
    # printed on the chart as visible text.
    "tagNames": ["style"],
    "attributes": {
        "*": ["style", "className"],
    },
}
```

After editing the config **restart the web process** and **hard-reload the 
tab**. Please note these settings come with security trade-offs: anyone who can 
author a chart or markdown tile can then inject CSS app-wide.


---

## 2. Two rules that decide whether your CSS works

### Rule 1 — ids reach the DOM with a `user-content-` prefix

This is the one that cost a couple of experiments to get it right.

The sanitizer sets `clobberPrefix: 'user-content-'`, so:

```html
<div id="card">     →     reaches the DOM as     id="user-content-card"
```

Your CSS must therefore say `#user-content-card { … }`. A `#card` selector
matches nothing, silently. **Classes are not prefixed** — only ids and names.

### Rule 2 — on 6.1.0, `class` works on some tags and not others

Works on `div`, `span`, `tr`, `td`, `th`, `p`, `table`.

Does **not** work on `ul`, `ol`, `li`, `a`, `code`, `h2`, `section`. Those tags
pin `className` to fixed values in the sanitizer's default schema, and on 6.1.0
an override for them is silently discarded. The tell in devtools is that the
attribute survives but arrives empty: `class=""`.

The fix (#42202, merged 2026-08-04) is **not** in 6.1.0. It probably lands in 
the upcoming version. After upgrading past that point you can add per-tag 
entries and `class`
works everywhere:

```python
"attributes": {
    "*": ["style", "className"],
    "ul": ["className"], "ol": ["className"], "li": ["className"],
    "code": ["className"], "a": ["className", "href", "title", "target"],
}
```

**In practice Rule 2 stops mattering**, because a descendant selector off one
id reaches everything:

```css
#user-content-notes li { font-size: 12px; color: #8c8c8c; }
#user-content-grid th  { text-transform: uppercase; }
#user-content-grid tbody tr:hover td { background: #fafafa; }
```

Also works with no extra config: inline `style="…"` on **every** tag including
`<li>`, Handlebars-interpolated styles, `<details>`/`<summary>`, and the
presentational attributes already in the schema (`align`, `width`, `colSpan`,
`title`, …).

---

## 3. The full example

Everything for the screenshot at the top. It uses `cleaned_sales_data` from the
shipped example data, so it should run as-is.

<details>
<summary><b>The query</b> — a virtual dataset in SQL Lab</summary>

Aggregate in SQL and let the template do presentation only. That split matters
more than it looks: the `sum` helper is **binary** (`sum a b`), so there is no
clean way to total an array in Handlebars — you would be abusing `{{#each}}` as
an accumulator.

```sql
SELECT product_line,
       SUM(sales)                                   AS revenue,
       COUNT(DISTINCT order_number)                 AS orders,
       SUM(sales) / COUNT(DISTINCT order_number)    AS avg_order,
       100.0 * SUM(sales) / MAX(SUM(sales)) OVER () AS share_pct
FROM cleaned_sales_data
GROUP BY product_line
ORDER BY revenue DESC
```

`MAX(SUM(sales)) OVER ()` gives every row the largest row's revenue, which is
what the share bar scales against. Scaling against the *total* instead gives
shares of 1–5% that render as invisible slivers.

Save it as a dataset, then in the chart set **Query mode: Raw records** and list
the five columns. In Aggregate mode Superset re-groups the already-grouped
result.

</details>

<details>
<summary><b>The HTML template</b></summary>

```handlebars
<div id="card">

  <div id="hd">Revenue by product line</div>
  <div id="sub">All territories &middot; share is relative to the largest 
line</div>

  <table id="grid">
    <thead>
      <tr>
        <th align="left">Product line</th>
        <th align="right">Revenue</th>
        <th align="right"><span title="Distinct order numbers, not line 
items.">Orders</span></th>
        <th align="right">Avg order</th>
        <th align="left" width="150">Share</th>
      </tr>
    </thead>
    <tbody>
      {{#each data}}
      <tr class="{{#if (gte this.share_pct 
50)}}row-strong{{else}}row-normal{{/if}}">
        <td>{{this.product_line}}</td>
        <td align="right">{{formatCurrency this.revenue "$"}}</td>
        <td align="right">{{formatNumber this.orders}}</td>
        <td align="right">{{formatCurrency this.avg_order "$"}}</td>
        <td>
          <span 
style="display:block;height:10px;border-radius:5px;width:{{sprintf "%.1f" 
this.share_pct}}%;background:{{#if (gte this.share_pct 
50)}}#1890ff{{else}}#91d5ff{{/if}}"></span>
        </td>
      </tr>
      {{/each}}
    </tbody>
  </table>

  <div id="foot">Every selector below hangs off an <code>id</code>, which the
  sanitizer rewrites to <code>user-content-*</code>. The bar width is an inline
  style with an interpolated value.</div>

</div>
```

Three things worth pointing at:

- **The bar is an inline style with an interpolated width** — no CSS needed for
  the part that varies per row.
- **`class` on `<tr>` works** even on 6.1.0, because `<tr>` carries no
  `className` rule in the default schema, so the `"*"` rule reaches it. An id
  would be wrong here: ids must be unique and get the prefix.
- **`title` on an inner `<span>`, not on the `<th>`** — the tooltip cue then
  sits under the label text only, so a column that carries an explanation looks
  visibly different from one that does not.

</details>

<details>
<summary><b>The CSS</b> — goes in the chart's CSS Styles panel</summary>

```css
#user-content-card {
  font-family: Inter, system-ui, sans-serif;
  border: 1px solid #e8e8e8;
  border-radius: 10px;
  padding: 18px 20px;
}
#user-content-hd  { font-size: 16px; font-weight: 700; }
#user-content-sub { font-size: 12px; color: #8c8c8c; margin-top: 2px; }

#user-content-grid { width: 100%; border-collapse: collapse; margin-top: 16px; }
#user-content-grid th {
  font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
  color: #8c8c8c; font-weight: 600;
  border-bottom: 1px solid #d9d9d9; padding: 8px; white-space: nowrap;
}
#user-content-grid td {
  padding: 9px 8px; font-size: 13px; border-bottom: 1px solid #f5f5f5;
}
#user-content-grid td:not(:first-child) { font-variant-numeric: tabular-nums; }
#user-content-grid tbody tr:hover td { background: #fafafa; }

/* A class works on <tr>. It would NOT work on the <code> tags below. */
.row-strong td:first-child { font-weight: 700; }

/* Descendant selectors reach tags where class= is a dead end. */
#user-content-foot { margin-top: 14px; font-size: 11px; color: #8c8c8c; 
line-height: 1.6; }
#user-content-foot code {
  background: #f5f5f5; padding: 1px 5px; border-radius: 3px; font-size: 10px;
}
```

Note the last rule: `#user-content-foot code` styles the `<code>` tags even
though `class` on `<code>` is a dead end on 6.1.0. That is Rule 2's escape
hatch in one line.

</details>

---

## 4. If you put it on a dashboard with filters

Worth knowing before you build on this. A native filter reaches a Handlebars
chart like any other, but Superset applies it to the **outside** of a virtual
dataset:

```sql
SELECT <columns> FROM ( your SQL ) AS virtual_table WHERE <filters>
```

That is *after* your aggregates have already run. The example above has exactly
this problem: filter it to Ships and Trains and both bars collapse to slivers,
because `share_pct` still divides by Classic Cars — a row no longer in the
result. The widest bar goes from 100% to 18.2%. Nothing errors, the table just
quietly stops meaning what it says.

To push the filter into the aggregation, enable `ENABLE_TEMPLATE_PROCESSING`
(this one *is* a feature flag) and read the values with Jinja:

```sql
{% set p_line = filter_values('product_line', remove_filter=True) %}
SELECT product_line, SUM(sales) AS revenue, ...
FROM cleaned_sales_data
WHERE 1 = 1
{% if p_line %} AND product_line IN ({{ "'" ~ p_line | join("','") ~ "'" }}) {% 
endif %}
GROUP BY product_line
```

`remove_filter=True` is the load-bearing part — without it the filter is applied
twice, once where you want it and once on the outer wrapper.

Two things to know before turning that flag on: it applies to **every** virtual
dataset, and Jinja parses the SQL before the database sees it — so a stray `{{`
anywhere, including inside a `--` comment, breaks the dataset.

---

## 5. Quick troubleshooting table

| Symptom | Cause |
| --- | --- |
| Literal `<div class="…">` text on the chart | `ESCAPE_MARKDOWN_HTML` is on |
| Your CSS printed as visible text | `"style"` missing from `tagNames` |
| Tags render, nothing styled at all | Either 
`HTML_SANITIZATION_SCHEMA_EXTENSIONS` is not reaching the frontend, or your CSP 
is blocking inline styles (`style-src` needs `'unsafe-inline'`). Rule them out 
in that order |
| Markup right, ids present, no styling | Missing the `user-content-` prefix on 
your id selectors |
| `class=""` on `<li>`/`<ul>`/`<a>` in devtools | Rule 2 — that tag cannot take 
a class on 6.1.0 |
| Chart blank, `Missing helper: "add"` | you are using an unsupported helper, 
the doc's helper names are wrong as of this post |
| `Parse error … Expecting 'ID', got 'NUMBER'` | `data.0` used as a helper 
argument; write `data.[0]` |


GitHub link: https://github.com/apache/superset/discussions/44019

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: 
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to