Building page and ballot templates
Movement allows you to set up page and ballot templates. These can be used within the page builder to quickly create custom, branded page designs.
This guide explains how page and ballot templates work and how to build your own.
A note on naming. Slugs and field property names are always written in snake_case (e.g.
petition_ask,question_type,show_in_wizard). This is how they are stored, how you reference them in your HTML ({{ template.petition_ask }}), and what the API expects
Page and Ballot Template Components
A page or ballot template is made up of four components:
| Component | What it is | Where it ends up | Liquid context available |
|---|---|---|---|
| Body HTML | The page body — HTML + Liquid + custom elements | Inside <body> | The full context (template.*, settings.*, flow.*, client.brand.*) — rendered client-side |
| Head HTML | Extra <head> content — font links, analytics (e.g. GTM), meta tags | Inside <head> | Only client.brand.* — rendered server-side |
| Styles CSS | The template's CSS | Wrapped in a <style> in <head> | Only client.brand.* — rendered server-side |
| Fields | A JSON array describing the configurable inputs an admin fills in | — | See Fields |
Why the scope differs: Head HTML and Styles CSS are rendered through Liquid on the server, before the page is delivered, so only
client.brand.*is in scope there —template.*,settings.*andflow.*are not available. Body HTML is rendered on the client with the full context, which is why field values and per-step branching only work there.
Body HTML
Body HTML defines the layout of the page content. It is written in HTML and pre-processed using Liquid. To show the contents of the form, include the <surveyform></surveyform> tags (see Custom HTML elements).
Injecting template field values
Inject the value of a field using the syntax {{ template.slug_name }}. The slug is always snake_case and must match a slug defined in your Fields.
<h1>{{ template.petition_ask }}</h1>Conditional logic
You can show or hide content based on field values using Liquid's if, elsif, and else statements:
{% if template.show_contact_info != blank %}
Contact us at 0123456789
{% endif %}One Body HTML, every step
The same Body HTML renders for every step of a flow (the survey step, the share step, an email-to-target step, and so on). You switch which content is shown by branching on flow.current_action_kind — see Branching on the action kind.
Head HTML
Head HTML defines metadata injected into the page <head> — custom fonts, analytics scripts, meta tags, and similar. It is rendered server-side, so only client.brand.* Liquid variables are available here (not template.*).
Styles CSS
Styles CSS defines the styling of the page and is written in CSS. Like Head HTML it is rendered server-side, so only client.brand.* Liquid variables are in scope.
The .template-body selector references the template's parent <div>.
A base stylesheet is auto-prepended. Movement injects a shared base stylesheet (covering common form, layout, and widget styling) before your Styles CSS. Reuse these existing classes instead of reinventing them, and keep your own CSS to what is genuinely template-specific. See Styling: classes you can rely on.
Fields
Fields defines the configurable values a template exposes — for instance a page heading, an intro paragraph, or an optional image. It is written as a JSON array, where each entry is one field. A field's value becomes {{ template.<slug> }} in your Body HTML.
A field is defined by:
slug— snake_case identifier used to reference the field as{{ template.<slug> }}. (required)label— human-readable name shown to the admin editing the field. (required)question_type— the field type. (required) — see Field typesoptions— a set ofvalue/label(and optionaldefault) options, forselect/checkboxtypes.default_value— the field's default value. May contain Liquid, e.g."{{ client.brand.privacy_policy }}".wysiwyg— whether the field is editable as rich text directly in the page builder preview. (default:false)setup— whether the field is shown and edited in the page builder setup tab (rather than inline in the preview). Use this for config-like toggles, e.g. "show signature count". (default:false)show_in_wizard— whether the field is shown in the initial page-creation wizard. (default:false)description— additional help text shown when editing the field.required— whether the field must be filled in. (default:true)image— whether the value is rendered as an<img>automatically. (default:false)translatable— whether the value can differ across languages. (default:false). Anything which is supporter-readable text should have this set to true, so that it can be translated. NB: This must be set to true for WYSIWYG fields, even if you only have one languagenumber_only— whether a text field only accepts numeric input. (default:false)
Field types
The available question_type values are:
| Type | Description | Example use |
|---|---|---|
text | Single-line text input | Headings, button labels |
paragraph | Multi-line text input | Intro copy, descriptions |
url | Single-line URL input | Links, redirect URLs |
select | Dropdown selector (options required) | Theme picker, layout variants |
checkbox | Boolean checkbox | Show/hide optional sections |
image | Image uploader (usually paired with image: true) | Hero images, logos |
Advanced field properties
These properties are used by Movement's built-in templates to wire a field into wider flow behaviour. They are not part of the standard field editor — most templates will not need them.
action_text_content— an array of action kinds whose content this field supplies, e.g.["survey"]or["donate", "email_to_target"]. Controls which step(s) the field's content is used for.apply_to_settings— an array of flow setting keys that this field's value also populates, e.g.["share_title"]for a field that doubles as the share title.apply_to_share_content_test— links the field to a share-content A/B test parameter, e.g."share_title".
How a template renders
A useful mental model for what happens when a page is viewed:
- The page is rendered once per step of the flow. The same Body HTML is used for every step — you switch content per step with Liquid
{% if %}onflow.current_action_kind. - Head HTML and Styles CSS are rendered through Liquid on the server first (only
client.brand.*is in scope), then delivered. - Body HTML is rendered on the client with the full Liquid context.
- React then "hydrates" the page by injecting live widgets (the form, the signature count, etc.) into the custom elements you place in your Body HTML.
Liquid context
The following top-level variables are available when rendering Body HTML:
template.<slug>— the value of each configurable field you declared in Fields. E.g.{{ template.petition_ask }}.settings.<key>— flow-level settings (snake_case).flow.current_action_kind— the key branching variable for the current step (see below).flow.is_embedded—truewhen the page is being shown in embed mode.client.brand.*— the organisation's brand settings (also the only context available in Head HTML and Styles CSS).
Remember: in Head HTML and Styles CSS, only
client.brand.*is available.
flow.current_action_kind
flow.current_action_kindThe action kind of the step currently being rendered. The values you will commonly branch on are:
survey— the main form / sign-up / petition-signing step.share— the post-action share step.email_to_target— the "email your MP / decision-maker" step (uses postcode lookup).donate— a donation step.custom— a custom content step.
Other specialised kinds exist (html, redirect, and fundraising-integration steps such as fundraise_up and lunda), but most templates only need to handle the ones above.
client.brand.*
client.brand.*Brand settings for the organisation. Prefer these over hard-coded colours and fonts unless a fixed value is specifically required. Commonly used keys:
primary_color,secondary_colorheader_font,body_fontlogo_light_bg,logo_dark_bg,logo_square,faviconcustom_homepage_urlprivacy_policy
Custom HTML elements
Movement provides a set of custom elements that React replaces with live widgets at runtime. Author them as empty elements wherever the functionality is needed:
<surveyform></surveyform>— the form (name / email / questions / submit button). Include this in every action-kind branch<signaturecount></signaturecount>— live signature count (petitions).<progressbar></progressbar>— a fundraising / sign-up progress bar.<questionnumber></questionnumber>— current / total question indicator.
Do not author
<editfield>yourself — it is injected automatically in the page builder preview only.
Branching on the action kind
Because the same Body HTML renders for every step, wrap each step's markup in an {% if %} on flow.current_action_kind. Note how the example reuses shared classes (.box, .full-width, .center, .ett-box) and includes a <surveyform> in each input step:
<section class="main">
{% if flow.current_action_kind != 'share'
and flow.current_action_kind != 'custom'
and flow.current_action_kind != 'email_to_target' %}
<article>
<p class="full-width">{{ template.main_image }}</p>
<div class="main-content">
<div>{{ template.petition_ask }}</div>
<div>{{ template.petition_reason }}</div>
</div>
</article>
{% endif %}
{% if flow.current_action_kind == 'survey' %}
<section class="cta petition">
<div class="box">
{% if template.show_signature_count == true %}
<signaturecount></signaturecount>
{% endif %}
<surveyform></surveyform>
<div class="small-text">{{ template.privacy_text }}</div>
</div>
</section>
{% elsif flow.current_action_kind == 'share' %}
<section class="center">
<div class="box">
<p class="share-text">{{ template.share_text }}</p>
<surveyform></surveyform>
</div>
</section>
{% elsif flow.current_action_kind == 'email_to_target' %}
<section>
<div class="ett-box">
<div class="image-side">
<p class="ett-image">{{ template.ett_image }}</p>
<div class="ett-content">{{ template.ett_content }}</div>
</div>
<div class="postcode-side">
<div class="ett-container">
<surveyform></surveyform>
<div class="small-text">{{ template.privacy_text }}</div>
</div>
</div>
</div>
</section>
{% else %}
<section>
<div><surveyform></surveyform></div>
</section>
{% endif %}
</section>Styling: classes you can rely on
Movement auto-prepends a base stylesheet before your Styles CSS. Reuse these class names rather than reinventing the controls — keep your own CSS to what is genuinely template-specific.
Phone & postcode widgets (fully styled already — never restate or restyle these):
.react-tel-input …— the international phone-number input (flag dropdown, country list, formatted input)..postcode-fields/.postcode-input/.postcode-input.international-postcodes— the postcode lookup / country selector used by the email-to-target flow.
Form fields:
.field(wraps one question),.field label,.field .answers label(radio/checkbox answer rows).- Per-type variants:
.field.question-type-checkbox,.field.question-type-multiple_select,.field.question-type-responses_confirmation. - Standard
input,textarea,select, checkbox and radio controls are styled.
Layout / containers:
.box(white rounded card — the usual content/form container),.center,.align-center,.full-width(full-bleed, e.g. a hero image),.add-padding,.back-caret(back nav).
Text / feedback:
.error,.error.small(validation messages),.custom-content(wraps rich-text/WYSIWYG field output),.small-text.
Donation flows (.donate-page namespace):
.amount-container .amount-btn(preset amount buttons),.custom-amount(custom amount input + currency select),.donation-type-toggle .toggle-btn(one-off vs recurring),.cover-fee(cover-the-fee checkbox),.donate-btn.
Email-to-target (write to decision-maker) flows:
.ett-box,.ett-container,.ett-content,.ett-image,.image-side,.postcode-side, and the editable composer.email-editor(with.header,.body,.footer,.user-info,.decision-makers-list).
Templates also define their own layout classes in Styles CSS (e.g.
.main,.main-content,.cta). When editing an existing template, reuse the classes already present in its CSS and markup rather than introducing parallel ones.
Best practices
- Always include a
<surveyform>in any branch - Reuse the shared classes above wherever they apply; don't reinvent inputs or buttons.
- Use brand variables (
client.brand.*) over hard-coded colours and fonts where possible. - Preserve required hooks. Don't strip wrapper elements (
.field,.box), custom elements, or step-specific markup that styling and React injection depend on (e.g. the postcode field required by the email-to-target flow). - Design for small screens. Many users and campaign staff are on ~1080px-wide laptops and phones — check the layout works there.
- Be careful changing field slugs on templates already in use. If a template is used by published pages, removing or renaming a
slugmeans any page that referenced it loses that value. Avoid renaming/removing slugs on in-use templates unless you understand the impact.
Example
Body HTML
<div class="header">
{% if template.member_type == 'supporter' %}
<p>A Movement Supporter Survey</p>
{% elsif template.member_type == 'member' %}
<p>A Movement Member Survey</p>
{% else %}
<p>A Movement Staff Survey</p>
{% endif %}
<h1>{{ template.salutation }}</h1>
</div>
<div class="survey-box">
<div class="hero">
{{ template.hero }}
</div>
<div class="questions">
<p class="intro">{{ template.introduction_message }}</p>
<surveyform></surveyform>
</div>
</div>
<div class="footer">
{% if template.show_disclaimer_notice != blank %}
<div class="disclaimer">
A disclaimer notice about collecting and processing data
</div>
{% endif %}
</div>Head HTML
<link href="https://fonts.googleapis.com/css2?family=League+Gothic&display=swap" rel="stylesheet">Styles CSS
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
@font-face {
font-family: 'Gellix';
font-weight: 400;
src: url('https://d5r7ykgo9eugh.cloudfront.net/Gellix-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Gellix';
font-weight: 700;
src: url('https://d5r7ykgo9eugh.cloudfront.net/Gellix-Bold.ttf') format('truetype');
}
* {
font-family: 'Gellix', sans-serif;
}
.template-body {
display: flex;
flex-direction: column;
min-height: 100vh;
align-items: center;
}
.header {
text-align: center;
padding: 20px 0;
}
.header p {
color: #bbb;
font-style: italic;
font-size: 12px
}
h1 {
font-family: 'Albert Sans';
margin-top: 10px;
}
body {
font-size: 16px;
background: white;
}
.survey-box {
padding: 0 10px;
display: flex;
gap: 48px;
max-width: 876px;
margin: 0 16px;
}
.questions {
box-shadow: 4px 16px 48px 0px #19193629;
padding: 32px;
border-radius: 8px;
min-width: 304px;
}
.hero {
flex-grow: 1;
}
.hero img {
border-radius: 8px;
width: 100%;
}
.survey-box p.intro {
font-size: 12px;
color: #E4003C;
padding-bottom: 10px;
}
.footer {
background: #454545;
color: #9F9F9D;
margin-top: 60px;
padding: 60px 0;
width: 100%;
position: absolute;
bottom: 0;
}
.disclaimer {
text-align: center;
padding: 20px 0;
font-size: 10px;
color: #aaa;
}
button,
a.button {
padding: 8px 24px 12px 24px;
font-weight: 700;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
button[type="submit"] {
background: #E4003C;
margin-top: 8px;
border: none;
color: white;
width: 100%;
}
.field {
margin-bottom: 12px;
}
.field label {
display: flex;
gap: 5px;
color: #1E1E44CC;
font-weight: 700;
line-height: 32px;
font-size: 14px;
}
input[type="text"],
input[type="email"],
input[type="date"],
textarea {
font-size: 16px;
width: 100%;
border-radius: 4px;
border: 2px solid #20204D33;
padding: 8px;
background-color: white;
}Fields
[
{
"slug": "salutation",
"wysiwyg": true,
"label": "Header Salutation",
"question_type": "text",
"show_in_wizard": true,
"translatable": true
},
{
"slug": "more_info_url",
"wysiwyg": true,
"label": "More Info URL",
"question_type": "url",
"default_value": "https://movement.industries"
},
{
"slug": "introduction_message",
"wysiwyg": true,
"label": "Introductory Message",
"question_type": "paragraph",
"default_value": "Thanks for taking the time to fill out our survey. It should only take a few minutes!",
"translatable": true
},
{
"slug": "member_type",
"label": "Member Type",
"question_type": "select",
"show_in_wizard": true,
"options": [
{
"value": "supporter",
"label": "Supporter"
},
{
"value": "member",
"label": "Member"
},
{
"value": "staff",
"label": "Staff"
}
]
},
{
"slug": "show_disclaimer_notice",
"label": "Show disclaimer notice",
"question_type": "checkbox",
"setup": true
},
{
"slug": "hero",
"image": true,
"label": "Hero Image",
"question_type": "image",
"required": false,
"show_in_wizard": true
}
]Updated 2 months ago
