Schema to no-code customization
Schema defines editable values; Customize renders controls for clients automatically.
The Schema dropdown includes built-in helpers you can use, like generating schema from code, inserting sample schema, inserting section-with-blocks, and inserting all input types.
Accepted schema shapes
- Bare array of settings
- Shopify-style section object with settings, blocks, presets, and max_blocks
Supported setting types
text, textarea, richtext, html, color, range, checkbox/boolean, select, radio, number, url, image_picker, video/video_picker, icon_picker, font_picker.
For text, pick by how much control the client needs:
textarea for plain,
multi-line text;
richtext for a
formatted editor (bold, lists, links) with no code; and
html for raw
markup in a monospace code editor. All three render as HTML — see below.
Icon picker
Use icon_picker to let
clients choose an SVG icon. Combine it with
icon_size and
icon_color settings for
full control.
{
"type": "icon_picker",
"id": "btn_icon",
"label": "Button Icon",
"default": "heart"
}
Template usage:
{% render 'icon', icon: section.settings.btn_icon %}
The icon renders as an SVG. You can style it with CSS or inline styles as usual.
Rich text
Use richtext to give
clients a no-code WYSIWYG editor (bold, italic, underline, lists, links). The value is
stored as HTML and rendered as formatted markup — no code editor, unlike
html.
{
"type": "richtext",
"id": "body",
"label": "Body",
"default": "<p>Edit <strong>me</strong></p>"
}
Template usage:
<div class="prose">{{ section.settings.body }}</div>
The default is HTML.
Like html, the value
runs inside the widget's sandboxed iframe — treat it with the same trust as your own
code.
HTML
Use html to let clients
add a block of raw HTML — an embed snippet, a custom layout, an SVG. The Customize
panel shows a monospace code editor, and the value is output
unescaped, so it renders as real markup.
{
"type": "html",
"id": "custom_html",
"label": "Custom HTML",
"default": "<p>Edit <strong>me</strong></p>"
}
Template usage:
<div class="custom">{{ section.settings.custom_html }}</div>
html vs textarea: use
html when the value is
markup, and textarea for
plain prose. Liquid prints both raw, so to render a value as
literal text (escaping any
< /
&) add the
escape filter:
{{ section.settings.bio | escape }}.
Note: the value is inserted into the already-rendered page, so Liquid tags typed into the field are not re-processed — it's HTML/CSS/JS, not a template. It runs inside the widget's sandboxed iframe (no access to the host page), but it is still arbitrary client-supplied markup — treat it with the same trust as your own code.
Image
Use image_picker to let
clients add an image — upload a file or paste a URL. The value is the image URL, plus
an alt text and an optional display name.
{
"type": "image_picker",
"id": "image",
"label": "Image",
"default": ""
}
The value is an object with these properties:
-
{{ section.settings.image }}— the image URL -
{{ section.settings.image.alt }}— alt text (accessibility) -
{{ section.settings.image.name }}— the optional display name
Template usage (caption from the display name):
{% if section.settings.image != blank %}
<figure>
<img src="{{ section.settings.image }}" alt="{{ section.settings.image.alt }}" loading="lazy">
{% if section.settings.image.name != blank %}
<figcaption>{{ section.settings.image.name }}</figcaption>
{% endif %}
</figure>
{% endif %}
Inside a repeatable block, use
block.settings instead —
e.g.
{{ block.settings.image_1.name }}.
Video
Use video (or
video_picker) to let
clients add a video — a YouTube, Vimeo, Loom, Dailymotion, or Wistia link, a direct
.mp4/.webm
URL, or an uploaded file. WebDYI detects the source and turns the value into an
object, so your template can drop in a ready-made player or build its own.
Schema example:
{
"type": "video",
"id": "video",
"label": "Video",
"default": ""
}
Easiest — drop in the ready-made responsive player (a 16:9
<iframe> for embed
providers). It is empty for direct video files, so pair it with a
<video> fallback:
{% if section.settings.video.embed %}
{{ section.settings.video.embed_html }}
{% elsif section.settings.video %}
<video src="{{ section.settings.video }}" controls playsinline style="width:100%"></video>
{% endif %}
The video value is an
object with these properties:
-
{{ section.settings.video }}— the original URL the client entered -
.embed_html— a ready-to-drop, lazy-loaded responsive<iframe>player (embed providers only) -
.embed— the canonical embed URL, e.g.https://www.youtube.com/embed/<id> -
.type— the provider (youtube,vimeo,loom,dailymotion,wistia) for embeds, orvideofor a direct file -
.embedId— the platform's video id -
.controls,.autoplay,.loop,.muted— the playback options the client toggled (present when set)
The Controls, Autoplay, Loop, and
Muted toggles appear automatically in the Customize panel for any
video field — you don't declare them in the schema (Controls is on by
default). They don't change the ready-made
.embed_html player, but
you can apply them to a direct
<video> so the
client's choices take effect:
<video src="{{ section.settings.video }}"
{% if section.settings.video.controls %}controls{% endif %}
{% if section.settings.video.autoplay %}autoplay{% endif %}
{% if section.settings.video.loop %}loop{% endif %}
{% if section.settings.video.muted %}muted{% endif %}
playsinline style="width:100%"></video>
Browsers only allow
autoplay when the video
is also muted, so enable
both together.
Poster image
A direct
<video> can show a
thumbnail before it plays via the
poster attribute. Add a
companion image_picker
setting so the client can choose one:
{
"type": "image_picker",
"id": "video_poster",
"label": "Video poster"
}
<video src="{{ section.settings.video }}"
poster="{{ section.settings.video_poster }}"
controls playsinline style="width:100%"></video>
Embedded players (YouTube, Vimeo…) show the provider's own thumbnail. To use a custom
poster there, display the image with a play button and swap in
{{ section.settings.video.embed_html }}
on click.
Or build your own embed instead of using
.embed_html:
<iframe src="{{ section.settings.video.embed }}" allowfullscreen
style="border:0;width:100%;aspect-ratio:16/9"></iframe>
Note: third-party players (YouTube, Vimeo…) only play where the
provider allows embedding from the hosting page, so some show a "configuration error"
in the editor's preview pane but work on the published widget URL. A direct
.mp4/.webm
file played through
<video> is the
most reliable everywhere.
Schema variables in CSS and JavaScript
You can safely pass schema values like
{{ section.settings.heading }}
inside CSS and JS. The editor may show lint errors because it sees the Liquid-like
syntax as invalid code.
If you want to avoid lint warnings, use these patterns:
-
CSS: use a CSS custom property injected from HTML:
<style> .my-component { color: var(--heading-color); } </style> <div class="my-component" style="--heading-color: {{ section.settings.heading_color }}"> ... </div> -
JavaScript: read the value from a
data-*attribute:<div id="widget" data-heading="{{ section.settings.heading }}"></div> <script> const heading = document.getElementById('widget').dataset.heading; </script>
Custom fonts
Use a
font_picker setting to
let clients choose a font. The picker includes a curated list of Google Fonts and
system stacks, and clients can also add their own custom fonts by entering a Google
font name, a stylesheet URL, or uploading a font file. The chosen font loads
automatically in the rendered widget.
Schema example:
{
"type": "font_picker",
"id": "font",
"label": "Font"
}
Template usage:
body {
font-family: {{ section.settings.font.family }};
}
Or use the full stack:
body {
font-family: {{ section.settings.font }};
}
The font_picker value is
a font object with these properties:
-
{{ section.settings.font }}— the full CSS font-family stack (e.g."Inter", sans-serif) -
{{ section.settings.font.family }}— the primary family name (e.g."Inter") -
{{ section.settings.font.fallback_families }}— the fallback list (e.g.sans-serif)
Set a default font
Add a default with a CSS
font stack. Match one of the built-in options so the picker shows it pre-selected (an
unmatched stack still works, but appears as "Custom"):
{
"type": "font_picker",
"id": "heading_font",
"label": "Heading font",
"default": "'Poppins', sans-serif"
}
Common stacks:
'Inter', sans-serif,
'Poppins', sans-serif,
Georgia, 'Times New Roman', serif,
ui-monospace, SFMono-Regular, Menlo, monospace.
Add a fallback font
Define the fallback chain once in the schema with
fallback_families. It
overrides the picked font's generic fallback, so every template that uses the value
gets it automatically — no per-template wiring:
{
"type": "font_picker",
"id": "heading_font",
"label": "Heading font",
"default": "'Poppins', sans-serif",
"fallback_families": "Georgia, 'Times New Roman', serif"
}
Now
{{ section.settings.heading_font }}
renders
'Poppins', Georgia, 'Times New Roman', serif, and
.fallback_families
becomes
Georgia, 'Times New Roman', serif.
Or handle it per-template instead. The picked value already ends in a generic fallback
(e.g.
'Poppins', sans-serif),
so the simplest version is to use the full stack — it carries the fallback through:
h2 {
font-family: {{ section.settings.heading_font }};
}
To set your own fallback chain, use
.family (the chosen
font) followed by your fallbacks:
h2 {
font-family: {{ section.settings.heading_font.family }}, Georgia, 'Times New Roman', serif;
}
Since
.family is the chosen
font name and
.fallback_families is
its built-in fallback, this is equivalent to the full stack — handy when you build the
declaration by hand:
h2 {
font-family: {{ section.settings.heading_font.family }}, {{ section.settings.heading_font.fallback_families }};
}
Google Fonts load automatically. System font stacks require no external request.
Forms with storage schema
WebDYI supports form submissions through a
storage schema type.
When a user submits a form, the data is stored and can be viewed in the dashboard.
Important
You must claim the form in the dashboard for submissions to be collected. An unclaimed form will not store responses.
Schema example:
"storage": [
{
"id": "contacts",
"fields": [
{
"id": "name",
"type": "text",
"maxLength": 120
},
{
"id": "email",
"type": "email"
},
{
"id": "phone",
"type": "tel"
},
{
"id": "subject",
"type": "text",
"maxLength": 120
},
{
"id": "message",
"type": "textarea",
"maxLength": 2000
},
{
"id": "consent",
"type": "checkbox"
}
]
}
]
Template usage with
{% form %}:
{% form "contact_form", "contact-form", class: "contact-form",
success-message: "Thanks! We'll be in touch.", error-message: "Couldn't send.
Please try again." %}
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message"></textarea>
<button type="submit">Send</button>
{{ form.success }}
{{ form.error }}
{% endform %}
Form tag options
-
First argument (required): the collection id — must match a
storageschema id -
success-message/success_message: message shown after a successful submission -
error-message/error_message: preset error text (overridden with the actual reason on failure) -
redirect: URL to navigate to on success instead of showing an inline message -
class: extra CSS classes (.webdyi-formis always added) -
id: form element id
{{ form.success }} and {{ form.error }}
Inside a
{% form %} block you can
place
{{ form.success }} and
{{ form.error }} where
you want the messages to appear. They start hidden and are revealed in place after
submission. If you omit them, a message is appended next to the form automatically.
After publishing the widget, go to Dashboard → Widgets → [your widget] → Claim Form to start collecting submissions.
Block behavior
- Instances reconcile against current block definitions.
- Unknown removed block types are dropped.
- Compatible saved values are kept; new fields get defaults.
- Preset blocks seed first render.
Client customize-only mode
Tokenized edit URLs can open with customize-only view that hides code tabs and surfaces Save/Share actions.