Choicery Tutorial Series

Custom HTML (Free Format)

Build fully custom survey questions with HTML, CSS, and variable capture, a complete guide to what’s allowed, what’s blocked, and how to use it.

Choicery Feature Reference | July 2026

Contents

Part I, Practical Guide
  1. What Is Custom HTML?
  2. Getting Started
  3. Variable Definitions
  4. Security Rules: What’s Allowed and What’s Blocked
    • Blocked (Completely Removed)
    • CSS Restrictions
    • Allowed HTML Tags
    • Allowed Attributes
  5. Examples
    • Simple Text Input
    • Custom Rating Table
    • Range Slider
  6. Research Instrument Recipes
    • Holt-Laury Risk Elicitation
    • Time-Preference (Intertemporal Choice)
    • Star Rating (Pure CSS)
    • Net Promoter Score (0 to 10)
    • Visual Analog Scale Grid
  7. Best Practices
  8. Rendering & Data Collection
Part II, Technical Reference
  1. Sanitization Pipeline
  2. Character Limits
  3. References
Part I

Practical Guide

How to create custom survey questions with raw HTML and CSS in Choicery.

1. What Is Custom HTML?

Custom HTML lets you create free-format survey questions using raw HTML and CSS. Instead of choosing a pre-built question type (like Grid / Likert or Single Choice), you write your own HTML form elements and Choicery automatically captures the responses.

You define form inputs (text fields, radio buttons, sliders, checkboxes, dropdown menus, and more) and then create Variable Definitions that map to the name attributes in your HTML. Choicery reads those named fields when the respondent clicks “Next” and stores the values alongside all other survey data.

Use Cases

When to use Custom HTML vs. built-in types

If a built-in question type (Single Choice, Multiple Choice, Grid / Likert, Bipolar Scale, Ranking, Text Input, Numeric, Constant Sum, Evaluation) can achieve what you need, prefer the built-in type. It handles validation, accessibility, and data export automatically. Custom HTML is for cases where no built-in type fits your requirements.

2. Getting Started

Follow these steps to add a Custom HTML question to your survey:

1

Open the Survey Flow editor for your project.

2

Add a new item and pick “Custom HTML” from the item palette in the sidebar on the right of the Survey Flow screen (on small screens open it with the Add & Manage button).

3

Fill in the editor areas. The Custom HTML editor has four main sections, plus a “Safe HTML subset” summary and a collapsible “Starter example” at the top:

The whole Custom HTML item editor with Question Name, the Safe HTML subset box, Question / Instruction, Variable Definitions, HTML Content and CSS Styles
Figure 1. The Custom HTML item editor (add view): Question Name, the Safe HTML subset box, Question / Instruction, Variable Definitions after Scan HTML, HTML Content, CSS Styles and the footer with Cancel, Preview and Add item.
4

Click “Scan HTML” to auto-detect name attributes from your HTML and fill the Variable Definitions. Next to it, “Add manually” adds an empty variable row, which is only needed if you want to declare a variable before writing its HTML.

The Variable Definitions block after Scan HTML with a Numeric row showing Allow Decimals and Min/Max, a Radio row and a Text row
Figure 2. The Variable Definitions block after Scan HTML: the numeric field became a Numeric row with Allow Decimals and Min/Max (max carried over from the markup), the radio group a Radio row and the textarea a Text row.
5

Use the preview button at the bottom of the item editor, next to Cancel and Add item (Save changes when editing an existing item), to verify how your HTML renders inside the survey frame before publishing. It is labelled “Preview” in both the add and the edit view. The preview opens in its own tab (repeated clicks refresh that same tab) and runs your HTML and CSS through the actual server-side sanitization pipeline without saving the item, so what you see is what respondents will see.

Screenshot needed

Shows: the preview tab with the custom question rendered as a respondent sees it, next to (or below) the editor footer where the Preview button sits.

Where in Choicery: Project → Survey → Custom HTML item → Preview (bottom of the editor).

Save as: images/custom_html/live_preview.png

Tip: Start with the HTML, then scan

Write your HTML first, including all <input>, <select>, and <textarea> elements with name attributes. Then click “Scan HTML” to auto-create variable definitions. This saves time and avoids typos.

3. Variable Definitions

Variable Definitions tell Choicery which form fields to capture from your HTML. Each variable maps to a name attribute on an HTML input element.

Variable Properties

PropertyDescriptionExample
Name Must exactly match a name attribute in your HTML. Becomes the column header in CSV export. Must start with a letter and may contain only letters, digits and underscores, maximum 50 characters. It must be unique across the whole survey (case-insensitive) and must not collide with page-timing names (page1, time_page1), reserved system columns such as participant_id or status, or the survey form’s own field names (action, attempt, csrf_token, numeric_value, page_index). feedback
Type The type dropdown offers exactly four options: Text, Numeric, Radio, and Checkbox. Other inputs (a <select>, a hidden field) are still captured, just classify them as Text (or Numeric for numbers). The type also decides how the value is read: Radio stores the value of the checked button, Checkbox stores all checked values joined by commas, everything else stores the field’s value. A <select multiple> stores only the first selected option; use a checkbox group when several answers are possible. Give every checkbox a value, otherwise the stored value is on. radio
Required Checkbox controlling whether the respondent must provide a value before proceeding. On / Off
Allow Decimals Appears only for the Numeric type. When it is off, a non-integer answer is rejected with the project’s whole-number message (by default “Decimal numbers are not allowed. Please enter a whole number for …”, naming the variable). Which decimal separator respondents may type follows the project’s decimal separator setting. On / Off
Min/Max Appears only for the Numeric type. Two optional bounds; values outside them are rejected when the respondent continues. 0 / 100

How “Scan HTML” Works

When you click the “Scan HTML” button, Choicery parses your HTML Content field and finds every <input>, <textarea> and <select> element that carries a name attribute (hidden inputs included). For each unique name found, it creates a variable definition with the type inferred from the element: type="number" and type="range" become Numeric, type="radio" becomes Radio, type="checkbox" becomes Checkbox, and everything else becomes Text.

Attributes in the markup are carried over: required switches Required on, min and max fill Min/Max, and a step of any or a fractional step switches Allow Decimals on. Scanning replaces the current list rather than merging into it. Settings you made by hand are kept only where the markup does not state a value; names that no longer appear in the HTML disappear from the list. After a scan a message lists how many variables were synced, which are new, which disappeared and how many are required. If two fields share a name (other than a radio or checkbox group), you get a warning, because only one field per name is captured. If the scan finds nothing at all, the list is cleared and you get the warning “No variables found in HTML content. Variable definitions were cleared.”

If you try to save with no variable definitions, a warning dialog (“No variables defined”) offers “Save anyway” or “Back to editor”. If you save anyway, the server runs the same detection on your HTML and stores the variables it finds, all with Required off. Relying on that fallback is not recommended, because you then have no control over the Required flag or the numeric bounds.

Data Flow

The data is collected via postMessage between a sandboxed iframe (which renders your HTML) and the parent survey form. When the respondent clicks “Next”, the parent frame sends a message to the iframe requesting all named field values. The iframe collects them and sends them back. Choicery then stores each value keyed by the variable name.

Variable names must match exactly

If the name attribute in your HTML is quality_rating but your variable definition says quality-rating, no data will be captured for that field. Use “Scan HTML” to avoid mismatches.

4. Security Rules: What’s Allowed and What’s Blocked

Custom HTML is rendered inside a sandboxed iframe for security. To protect respondents and the survey platform, Choicery applies a strict sanitization pipeline to all user-submitted HTML and CSS. This section is your definitive reference.

Security is non-negotiable

All HTML and CSS is sanitized server-side before storage. There is no way to bypass these rules. Content that violates the rules is silently stripped: your question will render, but the blocked elements will be missing.

4.1 Blocked (Completely Removed)

The following elements and attributes are completely stripped from your HTML during sanitization:

WhatDetails
JavaScript No <script> tags. A legacy js_content config key is silently removed on save (not exposed in the editor). Any existing JavaScript is not executed.
Dangerous tags <script>, <iframe>, <object>, <embed> are stripped entirely.
<form> wrappers The <form> tag itself is removed (unwrapped), but inner fields (<input>, <select>, etc.) are preserved.
Event handlers onclick, onchange, onload, onerror, onmouseover, and all on* attributes are removed.
External resources src, href, action, formaction, poster, data attributes pointing to http://, https://, //, javascript:, or vbscript: URLs are blocked.

4.2 CSS Restrictions

CSS is sanitized separately. The following rules apply to the CSS Styles field. <style> blocks pasted into HTML Content survive sanitization but are blocked by the survey page’s Content Security Policy, so they have no effect. Always use the CSS Styles field.

RuleDetails
Max CSS length 12,000 characters for the CSS Styles field. Anything above the limit is silently truncated, not rejected.
@import Removed entirely.
External url() url(http://...), url(https://...), url(//...) are removed.
url(javascript:...) Removed.
expression(...) Removed (legacy IE attack vector).
< and > characters Stripped from all CSS. This includes the CSS child combinator: write .parent .child (descendant) or use ~ / + (sibling) instead of .parent > .child.
Inline style vs. the CSS field

Inline style="…" attributes do not work. The sanitizer keeps a few harmless properties, but the survey page runs under a strict Content Security Policy that the sandboxed frame inherits, and the browser refuses every style attribute regardless of its content. Put all CSS, structural or cosmetic, in the CSS Styles field and address elements by class or id.

4.3 Allowed HTML Tags

The following tags pass through sanitization and will render in the survey:

Layout

div, span, p, br, hr, small, section, article, header, footer, main, aside

Text Formatting

strong, em, b, i, u, h1, h2, h3, h4, h5, h6

Lists

ul, ol, li

Tables

table, thead, tbody, tfoot, tr, th, td

Form Elements

label, input, textarea, select, option, optgroup, button, fieldset, legend

Interactive

details, summary

Media

img (images uploaded to Choicery, referenced by their relative /images/... path), a (relative or mailto: links pass sanitization, but the sandboxed frame blocks new tabs and top-level navigation, so links are not usable for respondents; treat the question as a closed page)

4.4 Allowed Attributes

Each HTML tag may only carry the global attributes plus the attributes listed for that tag. Any unlisted attribute is silently stripped. Tags that do not appear in the table (div, span, table, tr, fieldset, legend, details, summary and the rest) accept the global attributes only.

ScopeAllowed Attributes
Global (all tags) class, id, style (kept by the sanitizer but ignored by the browser in the survey, see Section 4.2), title, role, aria-label, aria-labelledby, aria-describedby, data-*
Input type, name, id, value, placeholder, min, max, step, inputmode, maxlength, minlength, checked, disabled, readonly, required, pattern, autocomplete, size
Textarea name, id, rows, cols, placeholder, maxlength, minlength, disabled, readonly, required
Select name, id, multiple, size, disabled, required
Option value, selected, disabled
Optgroup label, disabled
Label for
Button type, disabled
Link (<a>) href (relative or mailto only; links do not open inside the sandboxed frame), target, rel
Image (<img>) src (relative path only; data: and external URLs are removed), alt, width, height
Table cells (<th>, <td>) colspan, rowspan
About data-* attributes

All data-* attributes are allowed on any tag. These are commonly used for storing custom metadata (e.g., data-question-id="q1") and are safe because they do not affect rendering or trigger code execution.

5. Examples

Below are three small examples showing the basic Custom HTML pattern. Each includes the HTML you would paste into the editor and the corresponding variable definition. For complete, ready-to-use research instruments (risk elicitation, time preference, star rating, NPS, slider grids), see Section 6.

5.1 Simple Text Input

A single text field asking for open-ended feedback.

<label for="feedback">Your feedback:</label>
<input type="text" name="feedback" id="feedback" placeholder="Type here...">

Variable Definition

Name
feedback
Type
text
Required
On or Off (your choice)

The name="feedback" attribute on the input must exactly match the variable name. When the respondent types “Great product!” and clicks Next, the value Great product! is stored under the column feedback.

5.2 Custom Rating Table

A table-based rating matrix with radio buttons, the kind of question that goes beyond what the built-in Grid / Likert item offers (e.g., custom column labels or mixed scales per row).

<table>
  <tr>
    <th>Aspect</th>
    <th>1</th><th>2</th><th>3</th><th>4</th><th>5</th>
  </tr>
  <tr>
    <td>Quality</td>
    <td><input type="radio" name="quality" value="1"></td>
    <td><input type="radio" name="quality" value="2"></td>
    <td><input type="radio" name="quality" value="3"></td>
    <td><input type="radio" name="quality" value="4"></td>
    <td><input type="radio" name="quality" value="5"></td>
  </tr>
  <tr>
    <td>Design</td>
    <td><input type="radio" name="design" value="1"></td>
    <td><input type="radio" name="design" value="2"></td>
    <td><input type="radio" name="design" value="3"></td>
    <td><input type="radio" name="design" value="4"></td>
    <td><input type="radio" name="design" value="5"></td>
  </tr>
</table>

Variable Definitions

Name
quality, Type: radio
Name
design, Type: radio

Each row uses a different name, so each row produces a separate variable. The value attribute (1-5) determines what gets stored.

5.3 Range Slider

An HTML5 range slider for capturing a numeric value on a continuous scale.

<label>How likely? <span id="val">50</span>%</label>
<input type="range" name="likelihood" min="0" max="100" value="50">

Variable Definition

Name
likelihood
Type
numeric (a range input is captured as a number)
Required
On (a range input always has a value)
JavaScript for dynamic labels is stripped

The <span id="val">50</span> above would normally update dynamically via JavaScript as the slider moves. Since JavaScript is blocked, the span will always show “50” regardless of the slider position. The captured value will still be correct; only the visual label will not update. Consider using plain text labels like “0 = Not at all likely” and “100 = Extremely likely” instead.

6. Research Instrument Recipes (Copy & Paste)

The recipes below are complete, ready-to-paste instruments that are common in academic and commercial research. Each one shows a live preview first, then the full HTML and CSS (click to expand) and the Variable Definitions to create. All of them work entirely without JavaScript.

Star ratings, NPS scales and slider ratings also exist as built-in response types inside the Evaluation item, but there they are tied to a product list and collect one score per product. Use the Custom HTML recipes when you need the same widget as a standalone question, or with your own labels and layout.

One rule to remember for every recipe: no > child combinator in CSS

The CSS sanitizer strips every < and > character, which includes the CSS child combinator (.parent > .child). Use a descendant selector (.parent .child) or a sibling combinator (~, +) instead. Every recipe below already follows this rule.

Where the two blocks go

Paste the HTML block into the HTML Content field and the CSS block into the CSS Styles field. Inside the survey your content sits alone in a sandboxed iframe, so plain element selectors such as table or label are safe; they cannot affect anything else. (The live previews on this page use scoped class selectors only, so they do not restyle the tutorial itself.)

6.1 Holt-Laury Risk Elicitation (Multiple Price List)

A multiple price list (MPL) measures risk attitude. Each row pits a fixed 50/50 lottery (column 1) against a sure payment (column 2) that grows row by row. The row where the respondent switches from the lottery to the sure payment reveals their certainty equivalent, and therefore their degree of risk aversion. This is the certainty-equivalent variant of the Holt and Laury (2002) design, shown here with the full 14 rows.

Every row is its own radio group (HL_No0 through HL_No13), so every row becomes one variable. Value 1 means the respondent chose the lottery on that row; value 2 means the sure payment.

Live preview
No.(1) Risky payoff(2) Sure payoff
1
€0 with 50%;€12 with 50%
€0
2
€0 with 50%;€12 with 50%
€1
3
€0 with 50%;€12 with 50%
€2
4
€0 with 50%;€12 with 50%
€3
5
€0 with 50%;€12 with 50%
€4
6
€0 with 50%;€12 with 50%
€5
7
€0 with 50%;€12 with 50%
€5.50
8
€0 with 50%;€12 with 50%
€6
9
€0 with 50%;€12 with 50%
€6.50
10
€0 with 50%;€12 with 50%
€7
11
€0 with 50%;€12 with 50%
€8
12
€0 with 50%;€12 with 50%
€9
13
€0 with 50%;€12 with 50%
€10
14
€0 with 50%;€12 with 50%
€11
HTML Content (full 14-row table)
<table>
  <tr>
    <th>No.</th>
    <th>(1) Risky payoff</th>
    <th>(2) Sure payoff</th>
  </tr>
  <tr>
    <td>1</td>
    <td><div class="option-container"><span class="keep-together">€0 with 50%;</span><span class="keep-together">€12 with 50%</span><span class="no-wrap"><input type="radio" name="HL_No0" value="1"></span></div></td>
    <td><div class="option-container"><span class="no-wrap"><input type="radio" name="HL_No0" value="2"></span><span class="fixed-width">€0</span></div></td>
  </tr>
  <tr>
    <td>2</td>
    <td><div class="option-container"><span class="keep-together">€0 with 50%;</span><span class="keep-together">€12 with 50%</span><span class="no-wrap"><input type="radio" name="HL_No1" value="1"></span></div></td>
    <td><div class="option-container"><span class="no-wrap"><input type="radio" name="HL_No1" value="2"></span><span class="fixed-width">€1</span></div></td>
  </tr>
  <tr>
    <td>3</td>
    <td><div class="option-container"><span class="keep-together">€0 with 50%;</span><span class="keep-together">€12 with 50%</span><span class="no-wrap"><input type="radio" name="HL_No2" value="1"></span></div></td>
    <td><div class="option-container"><span class="no-wrap"><input type="radio" name="HL_No2" value="2"></span><span class="fixed-width">€2</span></div></td>
  </tr>
  <!-- Rows 4 to 14 follow the same pattern: increment the name
       (HL_No3 ... HL_No13) and raise the sure payoff to
       €3, €4, €5, €5.50, €6, €6.50, €7, €8, €9, €10, €11. -->
</table>
CSS Styles (paste into the CSS field)
th, td { font-family: 'Calibri', sans-serif; padding: 8px; text-align: center; }
table { border-collapse: collapse; margin-left: auto; margin-right: auto; }
th { border-bottom: 1px solid black; text-align: left; padding-left: 15px; }
th:nth-child(2), th:nth-child(3), td:nth-child(2), td:nth-child(3) { width: 180px; }
tr:nth-child(even) { background-color: #fbfbfb; }
.option-container { display: flex; justify-content: flex-start; align-items: center; }
.option-container span { margin-left: 5px; }
.no-wrap, .keep-together { white-space: nowrap; }
.fixed-width { min-width: 50px; display: inline-block; text-align: left; }

Variable Definitions

HL_No0 through HL_No13
14 variables, one per row. Type: radio. Required: On. Each stores 1 (lottery) or 2 (sure payment).
The auto-fill script does not run

Spreadsheet-style MPL templates (for example from conjoint exchange format) often ship a <script> that, once the respondent picks a switching row, auto-completes the consistent rows above and below. Choicery strips all <script> tags, so respondents answer each row individually. The captured data is identical (one value per row); enforce a single switching point during analysis, or simply count the number of safe choices per respondent.

6.2 Time-Preference (Intertemporal Choice)

The same MPL structure measures time discounting. Column 1 pays a fixed amount today; column 2 pays a growing amount in 6 months. The switching row reveals the respondent’s discount rate. This recipe reuses the exact CSS from Recipe 6.1, only the table contents change.

Live preview
No.(1) Today(2) In 6 months
1
2
3
4
5
6
7
8
9
10
HTML Content (full 10-row table)
<table>
  <tr>
    <th>No.</th>
    <th>(1) Today</th>
    <th>(2) In 6 months</th>
  </tr>
  <tr><td>1</td><td><label><input type="radio" name="TD_1" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_1" value="2"> €50 in 6 months</label></td></tr>
  <tr><td>2</td><td><label><input type="radio" name="TD_2" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_2" value="2"> €52 in 6 months</label></td></tr>
  <tr><td>3</td><td><label><input type="radio" name="TD_3" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_3" value="2"> €54 in 6 months</label></td></tr>
  <tr><td>4</td><td><label><input type="radio" name="TD_4" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_4" value="2"> €56 in 6 months</label></td></tr>
  <tr><td>5</td><td><label><input type="radio" name="TD_5" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_5" value="2"> €58 in 6 months</label></td></tr>
  <tr><td>6</td><td><label><input type="radio" name="TD_6" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_6" value="2"> €60 in 6 months</label></td></tr>
  <tr><td>7</td><td><label><input type="radio" name="TD_7" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_7" value="2"> €62 in 6 months</label></td></tr>
  <tr><td>8</td><td><label><input type="radio" name="TD_8" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_8" value="2"> €64 in 6 months</label></td></tr>
  <tr><td>9</td><td><label><input type="radio" name="TD_9" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_9" value="2"> €66 in 6 months</label></td></tr>
  <tr><td>10</td><td><label><input type="radio" name="TD_10" value="1"> €50 today</label></td><td><label><input type="radio" name="TD_10" value="2"> €68 in 6 months</label></td></tr>
</table>

CSS: paste the same block as Recipe 6.1 into this item’s CSS Styles field.

Variable Definitions

TD_1 through TD_10
10 variables, one per row. Type: radio. Required: On. Each stores 1 (today) or 2 (in 6 months).

6.3 Star Rating (Pure CSS, No JavaScript)

A five-star rating widget built only from radio buttons and CSS. The classic trick: list the stars in reverse order (5 down to 1) and use the general sibling combinator (~) to light up the chosen star and every star before it. No JavaScript is needed for the hover and selection highlight, try it in the preview below.

Live preview (hover and click)
How satisfied are you?
HTML Content
<fieldset class="stars">
  <legend>How satisfied are you?</legend>
  <input type="radio" name="satisfaction" value="5" id="st5"><label for="st5">★</label>
  <input type="radio" name="satisfaction" value="4" id="st4"><label for="st4">★</label>
  <input type="radio" name="satisfaction" value="3" id="st3"><label for="st3">★</label>
  <input type="radio" name="satisfaction" value="2" id="st2"><label for="st2">★</label>
  <input type="radio" name="satisfaction" value="1" id="st1"><label for="st1">★</label>
</fieldset>
CSS Styles
.stars { border: none; display: inline-flex; flex-direction: row-reverse; }
.stars input { position: absolute; opacity: 0; width: 0; height: 0; }
.stars label { font-size: 34px; color: #d1d5db; cursor: pointer; padding: 0 3px; }
.stars label:hover,
.stars label:hover ~ label { color: #f5b301; }
.stars input:checked ~ label { color: #f5b301; }

Variable Definition

satisfaction
Type: radio. Required: On. Stores 1 to 5.
Why this needs the CSS field, not inline styles

The radios are hidden with position: absolute; opacity: 0 and the layout uses flex-direction: row-reverse. Inline style="…" attributes are ignored by the browser inside the survey (Section 4.2), so the CSS field is the only place where CSS takes effect.

6.4 Net Promoter Score (0 to 10)

The standard NPS scale: eleven options from 0 (“not at all likely”) to 10 (“extremely likely”), laid out as a single horizontal row with end anchors. The built-in NPS response type lives inside the Evaluation item and asks for one score per product; this recipe gives you the same strip as a standalone question.

Live preview (click a number)
How likely are you to recommend us to a friend or colleague?
Not at all likely Extremely likely
HTML Content (all 11 options)
<fieldset class="nps">
  <legend>How likely are you to recommend us to a friend or colleague?</legend>
  <div class="nps-row">
    <label><input type="radio" name="nps" value="0"><span>0</span></label>
    <label><input type="radio" name="nps" value="1"><span>1</span></label>
    <label><input type="radio" name="nps" value="2"><span>2</span></label>
    <label><input type="radio" name="nps" value="3"><span>3</span></label>
    <label><input type="radio" name="nps" value="4"><span>4</span></label>
    <label><input type="radio" name="nps" value="5"><span>5</span></label>
    <label><input type="radio" name="nps" value="6"><span>6</span></label>
    <label><input type="radio" name="nps" value="7"><span>7</span></label>
    <label><input type="radio" name="nps" value="8"><span>8</span></label>
    <label><input type="radio" name="nps" value="9"><span>9</span></label>
    <label><input type="radio" name="nps" value="10"><span>10</span></label>
  </div>
  <div class="nps-anchors">
    <span>Not at all likely</span>
    <span>Extremely likely</span>
  </div>
</fieldset>
CSS Styles
.nps { border: none; padding: 0; }
.nps-row { display: flex; gap: 6px; }
.nps-row label { flex: 1; }
.nps-row input { position: absolute; opacity: 0; }
.nps-row span {
  display: block; text-align: center; padding: 12px 0;
  border: 1.5px solid #94a3b8; border-radius: 6px; cursor: pointer;
}
.nps-row input:checked + span { background: #0f766e; color: #fff; border-color: #0f766e; }
.nps-anchors { display: flex; justify-content: space-between; margin-top: 6px; font-size: 13px; color: #64748b; }

Variable Definition

nps
Type: radio. Required: On. Stores 0 to 10.

6.5 Visual Analog Scale Grid (Sliders)

Several continuous 0-to-100 sliders for rating multiple items on one screen, a visual analog scale (VAS) grid. The built-in Numeric item is a single text box, and the Evaluation item’s Slider Rating is bound to a product list, so a free-standing multi-slider grid is a Custom HTML job.

Live preview (drag the sliders)
0 = Poor100 = Excellent
HTML Content
<div class="vas">
  <div class="vas-row">
    <label for="v_comfort">Comfort</label>
    <input type="range" name="vas_comfort" id="v_comfort" min="0" max="100" value="50">
  </div>
  <div class="vas-row">
    <label for="v_style">Style</label>
    <input type="range" name="vas_style" id="v_style" min="0" max="100" value="50">
  </div>
  <div class="vas-row">
    <label for="v_value">Value for money</label>
    <input type="range" name="vas_value" id="v_value" min="0" max="100" value="50">
  </div>
  <div class="vas-anchors"><span>0 = Poor</span><span>100 = Excellent</span></div>
</div>
CSS Styles
.vas-row { display: flex; align-items: center; gap: 14px; margin: 10px 0; }
.vas-row label { flex: 0 0 130px; }
.vas-row input[type="range"] { flex: 1; }
.vas-anchors { display: flex; justify-content: space-between; font-size: 13px; color: #64748b; }

Variable Definitions

vas_comfort, vas_style, vas_value
Type: numeric. Required: On. Each stores 0 to 100.
No live number readout

A slider that shows its current number as you drag needs JavaScript, which is stripped. Use fixed end anchors (“0 = Poor”, “100 = Excellent”) instead of a live value. The captured number is exact regardless of the missing readout.

7. Best Practices

Do: Test with “Preview” before publishing

Always preview your Custom HTML question in the editor before sending the survey to respondents. The preview renders your HTML through the same sanitization pipeline, so you will see exactly what respondents see.

Do: Use “Scan HTML” to auto-detect variables

After writing your HTML, click “Scan HTML” to automatically generate variable definitions. This ensures the variable names match exactly and prevents data loss from typos.

Do: Put structural CSS in the CSS field

Inline style="…" attributes have no effect in the survey, and <style> blocks in the HTML are blocked as well. Put every rule in the CSS Styles field and keep it under 12,000 characters.

Do: Use semantic HTML and labels

Wrap inputs with <label> elements and use for attributes that reference input id values. This improves accessibility and makes radio buttons / checkboxes clickable by their label text.

Don’t: Use external or data URI images

The sanitizer drops any src or href that starts with http://, https:// or //, and it also drops data: URIs. Upload the image to Choicery first (Survey editor, image picker) and reference its /images/... path. Check it in the preview before you rely on it.

Don’t: Rely on JavaScript

All <script> tags and on* event handlers are removed. If your question design requires dynamic behavior (show/hide logic, calculated fields), consider using built-in question types with Display Logic instead. For interactive widgets that do not strictly need JavaScript (star ratings, highlighted selections), pure CSS often suffices, see Section 6.

Don’t: Use <form> tags

Choicery wraps your content in its own data-collection mechanism. Any <form> tags you include will be unwrapped (removed but inner content preserved). Do not rely on form submission, action attributes, or form-level validation.

Don’t: Use duplicate name attributes (except for radio and checkbox groups)

Each name attribute should be unique across your HTML, with two exceptions: radio buttons in one group share a name (the checked value is stored), and checkboxes in one group may share a name (all checked values are stored, joined by commas). For any other input type, sharing a name means only the first field is read.

8. Rendering & Data Collection

Understanding how Custom HTML is rendered and how data flows from the respondent’s browser to Choicery’s database helps you debug issues and design reliable questions.

Rendering

Your Custom HTML content is rendered inside a sandboxed iframe. The iframe uses the sandbox="allow-scripts" attribute, which is required for the postMessage data collection mechanism. The sandbox prevents the iframe from accessing the parent page’s DOM, cookies, or storage, but the allow-scripts permission lets the built-in Choicery data-collection script run inside the iframe.

Why a sandbox?

The sandbox isolates your custom HTML from the survey framework. Even if malicious content somehow passed sanitization, the sandbox prevents it from accessing survey data, cookies, or the parent page. This is a defense-in-depth measure.

Data Collection Flow

1

Respondent fills in your form fields (text inputs, radio buttons, sliders, etc.) within the iframe.

2

Respondent clicks “Next” on the survey page. The parent frame sends a postMessage to the iframe requesting form data.

3

The iframe collects all named input values by iterating over the variable definitions and reading the current value of each matching name. Radio variables yield the value of the checked button, checkbox variables yield every checked value joined by commas, all other variables yield the first matching field’s value. A variable whose name does not exist in the HTML yields an empty string.

4

The iframe sends the data back to the parent frame via postMessage as a key-value object.

5

Choicery validates and stores the data using the Variable Definitions. Required variables must be non-empty, Numeric variables must parse as a number and respect Allow Decimals and the Min/Max bounds. If a check fails, the page stays where it is, the survey’s error message appears, and the offending fields are outlined in red inside the frame; the same checks run again on the server when the page is submitted. Values are stored keyed by the variable name alongside all other survey response data, so each variable becomes its own column in the CSV export.

Back Navigation and Auto-Resize

Two further behaviours are handled by the same message channel. When a respondent returns to a page they already answered, the parent sends the stored values into the iframe and the fields are refilled (radios and checkboxes are re-checked, text and numeric fields get their value back). And the iframe continuously reports its own content height to the parent, which resizes the frame to fit, up to a cap of 2000 pixels. Content taller than that scrolls inside the frame.

Screenshot needed

Shows: a Custom HTML question as a respondent sees it during the survey, with the frame sized to the content and the Next button below it.

Where in Choicery: open the survey itself and go to the page that contains the Custom HTML item.

Save as: images/custom_html/participant_view.png

Part II

Technical Reference

Under the hood: sanitization pipeline, character limits, and implementation details.

9. Sanitization Pipeline

All Custom HTML content passes through a multi-stage server-side sanitization pipeline before it is stored in the database. This section describes each stage in order.

9.1 Pipeline Overview

1

User submits HTML/CSS in editor. The HTML Content and CSS Styles fields are sent to the server when the item is saved.

2

_sanitize_item_config() detects that the item type is custom_html and calls sanitize_input(html, InputType.CUSTOM_HTML).

3

_handle_custom_html() runs the full sanitization chain on the HTML content:

  1. Strip dangerous tags, <script>, <iframe>, <object>, <embed> blocks are removed entirely (including their content).
  2. Unwrap <form> tags, The <form> wrapper is removed but all inner content (inputs, labels, etc.) is kept.
  3. bleach.clean(), Runs the bleach library with an explicit tag whitelist and attribute whitelist. A custom attr_filter function enforces per-tag attribute rules and blocks external URLs.
  4. Remove on* handlers, A regex pass strips any remaining event handler attributes (onclick, onerror, etc.).
  5. Sanitize embedded <style> blocks, Each <style> block found in the HTML is passed through sanitize_custom_css(), but such blocks are ignored by the browser at survey time (see Section 4.2).
4

CSS field sanitized separately. The dedicated CSS Styles field is passed through sanitize_custom_css(), which first strips any stray <style> wrapper, truncates to 12,000 characters, then removes @import, external url() references, javascript: URIs, expression() calls, and all </> characters.

5

Sanitized content is stored. The cleaned HTML and CSS are saved to the item configuration in survey_flow.json under the keys html_content and css_content. These two key names are canonical. A legacy js_content key (and a bare js key) is dropped from the configuration on every save.

9.2 Rendering in the Survey

When a respondent reaches the Custom HTML page, the sanitized HTML is rendered inside a sandboxed iframe with sandbox="allow-scripts". The allow-scripts permission is necessary for the Choicery-injected data-collection script (which uses postMessage) to function. User-submitted JavaScript has already been stripped in the sanitization pipeline, so only the Choicery collection script executes.

10. Character Limits

ContentMax LengthNotes
HTML Content 100,000 characters Total length of the HTML field, measured before sanitization. Exceeding it makes the save fail with an error rather than truncating.
Variables per item 100 Saving fails with an error above this.
Custom HTML variables per project 300 Counted across all Custom HTML items.
Answer length per variable 5,000 characters Longer input is rejected with an error message naming the variable.
CSS Styles field 12,000 characters Silently truncated above the limit.
Variable Name 50 characters Must match a name attribute in your HTML. Must start with a letter and contain only letters, digits and underscores. Must not collide with another variable in the survey, with a page-timing name, or with a reserved system variable.
Why 100,000 characters for HTML?

This generous limit leaves room for long tables such as multiple price lists with many rows. Inline SVG and data URI images are stripped, so they do not count toward it. Most Custom HTML questions will use far less. If you are approaching this limit, consider whether your content could be simplified.

11. References

Choicery Documentation (2026). Custom HTML item type, feature specification and sanitization rules. Internal reference.

Holt, C. A., & Laury, S. K. (2002). Risk aversion and incentive effects. American Economic Review, 92(5), 1644-1655. doi:10.1257/000282802762024700

OWASP Foundation. (2024). XSS Prevention Cheat Sheet. Open Worldwide Application Security Project. Retrieved from https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Scripting_Prevention_Cheat_Sheet.html

Mozilla Developer Network. (2025). Window: postMessage() method. MDN Web Docs. Retrieved from https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage

bleach (Python library). (2024). bleach, An easy safelist-based HTML-sanitizing tool. Retrieved from https://github.com/mozilla/bleach

Note: Mozilla deprecated the bleach library on 23 January 2023 (release 6.0.0) and it no longer receives feature work. It remains widely deployed and reliable for the kind of static, server-side HTML/CSS sanitization Choicery performs on Custom HTML content, where the threat model is well understood and the allowed tag/attribute set is small and explicit.