Widget templates
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.
| Available | Not 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_attributesget_content_items, get_content_item_by_id, get_content_type_by_developer_name, get_main_menu, get_menu, raytha_functionEvery filter, including organization_time and attachment_public_url
|
Target, ContentType, CurrentOrganization, CurrentUser, PathBase, QueryParams, ViewData, RequestVerificationTokenrender_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_timeworks. - The widget's own values are also at the top level (
settings.headline,id,type) but writewidget.settings.headline. It is the documented spelling.
Reading settings
Settings arrive as plain values, with the JSON type kept:
| Field type | Stored and read as | Accepted when saving |
|---|---|---|
single_line_text | string | Any string |
long_text | string | Any string |
wysiwyg | string of HTML | Any string |
number | number: an integer when whole, else a decimal | A JSON number. The text "3" is refused. |
checkbox | true or false | A JSON boolean |
date | string | Any text that parses as a date, such as 2026-03-01 |
dropdown, radio | string: the choice's developerName | One of the enabled choices |
color | string like #1e293b | Exactly six hex digits after # |
image | string, normally a URL | Any string |
repeater | list of rows; each row reads like settings | A list of objects, each row checked against the sub-fields. No repeaters inside repeaters. |
content_type | string: a content type developer name | Any string |
view | string: a view id | Any 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
| escapeon single-line text, long text, image and any value in an attribute. Printwysiwygunescaped, 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_idandwidget.custom_attributesare 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 writesid="". 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:
| Property | Required | Meaning |
|---|---|---|
developerName | yes | The key in widget.settings. Starts with a letter. Letters, digits and underscores only. Unique in the form, ignoring case. |
label | yes | What the editor sees. |
fieldType | yes | One of the 13 types. |
description | no | Help text under the input. |
isRequired | no | Refuse empty values on save. |
defaultValue | no | Pre-fills a new widget. It is validated as if it were an entered value. |
choices | dropdown, radio | At least one { "developerName", "label" }. Choice names are unique. "disabled": true hides a choice and refuses it on save. |
subFields | repeater | At least one field. Not a repeater. |
contentTypeField | view | The 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_itemshas no view argument. Aview-type setting is stored and validated, but a widget cannot pass it toget_content_items, and the view's own filter and sort are not applied. The built-in Content List widget passesViewId, and it is ignored too. UseFilterandOrderBysettings, 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 name | Label | Settings |
|---|---|---|
hero | Hero | headline, subheadline, backgroundImage, backgroundColor, textColor, buttonText, buttonUrl, buttonStyle, alignment, minHeight |
wysiwyg | WYSIWYG | content, backgroundColor, padding |
imagetext | Image + Text | imageUrl, imageAlt, headline, content, imagePosition, buttonText, buttonUrl, buttonStyle, backgroundColor |
card | Card | title, description, imageUrl, imageAlt, buttonText, buttonUrl, buttonStyle, backgroundColor |
faq | FAQ | headline, subheadline, expandFirst, backgroundColor, items (question, answer) |
cta | Call to Action | headline, content, buttonText, buttonUrl, buttonStyle, backgroundColor, textColor, alignment |
embed | Embed | embedType, iframeUrl, htmlContent, aspectRatio, maxWidth, caption, backgroundColor |
contentlist | Content List | headline, 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.