GitHub user rlei-odes created 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, 6.1 or 6.2. 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 · 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]
