Platform
Learn
Developer docs User guide Quickstart Blog
Company
Services About Contact Links Get started

Widget templates

Updated

A widget template is a piece of Liquid plus a settings form. Editors fill the form on a site page, and Raytha renders your Liquid with their values available as widget.settings. This page shows how to write one, define its form, and read the values back safely.

A first widget

A feature grid: a headline, a number of columns, and a list of features. The Liquid:

{% assign cols = widget.settings.columns | default: 3 | floor %}
{% if cols < 1 or cols > 12 %}{% assign cols = 3 %}{% endif %}
{% assign span = 12 | divided_by: cols %}
<section class="feature-grid py-5{% if widget.css_class != blank %} {{ widget.css_class | escape }}{% endif %}"{% if widget.html_id != blank %} id="{{ widget.html_id | escape }}"{% endif %}>
  <div class="container">
    {% if widget.settings.headline %}
      <h2 class="text-center mb-5">{{ widget.settings.headline | escape }}</h2>
    {% endif %}
    <div class="row g-4">
      {% for feature in widget.settings.features %}
        <div class="col-md-{{ span }}">
          <h3 class="h5">{{ feature.title | escape }}</h3>
          <div>{{ feature.description }}</div>
        </div>
      {% endfor %}
    </div>
  </div>
</section>

The settings form, as field definitions:

{
  "label": "Feature grid",
  "fields": [
    { "developerName": "headline", "label": "Headline", "fieldType": "single_line_text" },
    { "developerName": "columns", "label": "Columns", "fieldType": "number", "defaultValue": 3,
      "description": "1 to 12. Twelve must divide evenly: 2, 3, 4 or 6." },
    { "developerName": "features", "label": "Features", "fieldType": "repeater", "subFields": [
      { "developerName": "title", "label": "Title", "fieldType": "single_line_text", "isRequired": true },
      { "developerName": "description", "label": "Description", "fieldType": "wysiwyg" }
    ] }
  ]
}

To create it in the admin, open the active theme, choose Widget templates, then New widget template. Enter a Label. Raytha fills Developer name from it (Feature grid becomes feature_grid). The developer name is what pages store, and it cannot change later. The Liquid tab holds the markup and the Fields tab holds the form, with a Form preview next to it that shows what editors will see. From the command line, raytha theme push reads the two files above (see Themes and the command line tool).

Raytha checks the Liquid syntax when you save and refuses a template that does not parse. A runtime error only shows up when a page renders.

What a widget can see

A widget renders in a small context, not the page's. It has less than a detail or list template.

AvailableNot available (evaluates to nothing)
widget.settings.*, widget.id, widget.type, widget.row, widget.column, widget.column_span (also widget.columnSpan), widget.css_class, widget.html_id, widget.custom_attributes
get_content_items, get_content_item_by_id, get_content_type_by_developer_name, get_main_menu, get_menu, raytha_function
Every filter, including organization_time and attachment_public_url
Target, ContentType, CurrentOrganization, CurrentUser, PathBase, QueryParams, ViewData, RequestVerificationToken
render_section, get_section
{% renderbody %} and layout inheritance

That has consequences:

  • A widget does not know which page it is on, who is logged in, or the organization name. If you need those, make them settings, or fetch them by function. A widget cannot show members-only content on its own.
  • Links inside a widget cannot be prefixed with PathBase. If the site is served under a sub-path, store full paths in the settings.
  • The widget's time zone is the organization's, so organization_time works.
  • The widget's own values are also at the top level (settings.headline, id, type) but write widget.settings.headline. It is the documented spelling.

Reading settings

Settings arrive as plain values, with the JSON type kept:

Field typeStored and read asAccepted when saving
single_line_textstringAny string
long_textstringAny string
wysiwygstring of HTMLAny string
numbernumber: an integer when whole, else a decimalA JSON number. The text "3" is refused.
checkboxtrue or falseA JSON boolean
datestringAny text that parses as a date, such as 2026-03-01
dropdown, radiostring: the choice's developerNameOne of the enabled choices
colorstring like #1e293bExactly six hex digits after #
imagestring, normally a URLAny string
repeaterlist of rows; each row reads like settingsA list of objects, each row checked against the sub-fields. No repeaters inside repeaters.
content_typestring: a content type developer nameAny string
viewstring: a view idAny string

Validation runs when the editor saves widgets. Empty means missing, null, "" or an empty list. An empty value passes unless the field is isRequired. Errors read like Columns must be a number., Alignment must be one of: left, center., Accent must be a color like #1e293b. and Features row 2: Title is required.

Rules for writing the Liquid:

  • Guard every optional value. A missing setting is nothing, and a default in the field definition is not applied when the page renders. It only pre-fills the form for a new widget. Write widget.settings.columns | default: 3.
  • Escape text. Output is not escaped by default. Use | escape on single-line text, long text, image and any value in an attribute. Print wysiwyg unescaped, because it is HTML you want. Only editors with the right permission can write to it.
  • Test for blank, not for truthy. widget.css_class, widget.html_id and widget.custom_attributes are empty strings, not nothing, when the editor set no value, and an empty string is truthy in Liquid. {% if widget.html_id %} is always true and writes id="". Use {% if widget.html_id != blank %} as the example does.
  • Keys are the field's developer name, with its case. Use camelCase or snake_case, but stay consistent with the definition.
  • Date values are strings. Format them with {{ widget.settings.startsOn | date: "%B %e, %Y" }}.

Field definitions

The settings form is a JSON array. The order of the array is the order of the form. Every field accepts:

PropertyRequiredMeaning
developerNameyesThe key in widget.settings. Starts with a letter. Letters, digits and underscores only. Unique in the form, ignoring case.
labelyesWhat the editor sees.
fieldTypeyesOne of the 13 types.
descriptionnoHelp text under the input.
isRequirednoRefuse empty values on save.
defaultValuenoPre-fills a new widget. It is validated as if it were an entered value.
choicesdropdown, radioAt least one { "developerName", "label" }. Choice names are unique. "disabled": true hides a choice and refuses it on save.
subFieldsrepeaterAt least one field. Not a repeater.
contentTypeFieldviewThe developerName of a content_type field in the same form.

One definition of every type:

[
  { "developerName": "headline", "label": "Headline", "fieldType": "single_line_text", "isRequired": true },
  { "developerName": "intro", "label": "Intro", "fieldType": "long_text" },
  { "developerName": "body", "label": "Body", "fieldType": "wysiwyg" },
  { "developerName": "columns", "label": "Columns", "fieldType": "number", "defaultValue": 3 },
  { "developerName": "showDividers", "label": "Show dividers", "fieldType": "checkbox", "defaultValue": true },
  { "developerName": "startsOn", "label": "Starts on", "fieldType": "date" },
  { "developerName": "align", "label": "Alignment", "fieldType": "dropdown", "defaultValue": "left",
    "choices": [ { "developerName": "left", "label": "Left" }, { "developerName": "center", "label": "Center" } ] },
  { "developerName": "size", "label": "Size", "fieldType": "radio",
    "choices": [ { "developerName": "small", "label": "Small" }, { "developerName": "large", "label": "Large" } ] },
  { "developerName": "accent", "label": "Accent color", "fieldType": "color", "defaultValue": "#1e293b" },
  { "developerName": "photo", "label": "Photo", "fieldType": "image" },
  { "developerName": "source", "label": "Content type", "fieldType": "content_type" },
  { "developerName": "sourceView", "label": "View", "fieldType": "view", "contentTypeField": "source" },
  { "developerName": "items", "label": "Items", "fieldType": "repeater", "subFields": [
    { "developerName": "name", "label": "Name", "fieldType": "single_line_text", "isRequired": true },
    { "developerName": "link", "label": "Link", "fieldType": "single_line_text" }
  ] }
]

Changing a field's developerName orphans the values editors already saved under the old name. They are kept in the page but nothing reads them. Add the new field, copy the values across, then retire the old one.

A widget that lists content

A content_type field lets an editor pick which content to show. Because a widget has no page context, it loads its own data:

{% assign count = widget.settings.count | default: 3 %}
{% if widget.settings.source %}
  {% assign result = get_content_items(ContentType=widget.settings.source, OrderBy="CreationTime desc", PageSize=count) %}
  {% if result.Items.size > 0 %}
    <section class="py-4">
      {% if widget.settings.headline %}<h2>{{ widget.settings.headline | escape }}</h2>{% endif %}
      <ul>
        {% for item in result.Items %}
          <li><a href="{{ item.RoutePath | prepend: "/" }}">{{ item.PrimaryField | escape }}</a></li>
        {% endfor %}
      </ul>
    </section>
  {% endif %}
{% endif %}

Fields: headline (single_line_text), source (content_type, required) and count (number, default 3). If the content type does not exist, get_content_items returns nothing and the widget renders empty.

Note get_content_items has no view argument. A view-type setting is stored and validated, but a widget cannot pass it to get_content_items, and the view's own filter and sort are not applied. The built-in Content List widget passes ViewId, and it is ignored too. Use Filter and OrderBy settings, as the built-in does.

Built-in widgets

Every theme starts with eight widgets. Their developer names are reserved, so a custom widget needs a different name. Copy one under a new name to start from it.

Developer nameLabelSettings
heroHeroheadline, subheadline, backgroundImage, backgroundColor, textColor, buttonText, buttonUrl, buttonStyle, alignment, minHeight
wysiwygWYSIWYGcontent, backgroundColor, padding
imagetextImage + TextimageUrl, imageAlt, headline, content, imagePosition, buttonText, buttonUrl, buttonStyle, backgroundColor
cardCardtitle, description, imageUrl, imageAlt, buttonText, buttonUrl, buttonStyle, backgroundColor
faqFAQheadline, subheadline, expandFirst, backgroundColor, items (question, answer)
ctaCall to Actionheadline, content, buttonText, buttonUrl, buttonStyle, backgroundColor, textColor, alignment
embedEmbedembedType, iframeUrl, htmlContent, aspectRatio, maxWidth, caption, backgroundColor
contentlistContent Listheadline, subheadline, contentType, viewId, filter, orderBy, pageSize, displayStyle, showImage, showDate, showExcerpt, linkText, linkUrl, backgroundColor

You can edit the Liquid of a built-in in any theme you own. Reset to defaults on the widget template list restores the Liquid and fields of all eight, keeps a revision of each, and does not touch custom widgets or settings already saved on pages. A built-in cannot be deleted.

Revisions and deleting

Every save of a widget template keeps a revision you can revert to. A custom widget template cannot be deleted while a site page, draft or published, still holds a widget of that type. The error names the pages.

Gotchas

  • A widget template is looked up by developer name in the active theme. A page can only use widget types that exist there.
  • A widget that throws at render time does not break the page. The page shows <!-- Widget rendering error: name - message --> where the widget should be.
  • {% include %} and {% render %} do not work. Write each widget as one self-contained file.
  • Keep the stylesheet out of the widget. Put classes in the layout's CSS so that several widgets on a page do not each carry a copy of it.
  • The built-in widgets print many settings unescaped, so what an editor types into a plain text field is written as HTML. Escape in your own widgets.

Next steps