Bound Settings: Menus, Pickers, Dynamic Sources

Settings whose stored value is a reference the host resolves at render — menu handles, resource ids, ordered id lists, and @dynamic ctx paths.

Bound Settings#

Most settings store a literal value. A BOUND setting stores a reference — a menu handle, an entity id, a list of ids, or a ctx path — and the host swaps it for live data before your template runs. Your markup always reads resolved objects, never raw ids. One law covers every binding: it works IDENTICALLY on section settings and on block settings, at any nesting depth.

A "menu"-typed setting stores the picked menu's HANDLE. At render the host loads that one menu and injects the resolved tree beside it as settings._resolved — your template iterates the tree and never touches ctx.menus. A handle whose menu was deleted resolves to an empty tree (items.size == 0): render your empty state, never an error.

vein
{% if settings.menu_resolved.items.size > 0 %}
  <nav aria-label="{{ settings.menu_resolved.name }}">
    {% for item in settings.menu_resolved.items %}
      <a href="{{ item.url }}"{% if item.open_in_new_tab %} target="_blank" rel="noopener"{% endif %}>{{ item.title }}</a>
      {% for child in item.children %} … {% endfor %}
    {% endfor %}
  </nav>
{% endif %}

Resolved tree field

Meaning

name

The menu's display name.

items

Ordered top-level items.

items[].title / url / open_in_new_tab

Link fields, host-resolved (a deleted target page drops the item's stale URL for its live one — links never 404 from staleness).

items[].children

Nested items, recursively (three levels).

Menu settings on BLOCKS resolve the same way: a footer's menu block can carry its own "menu" setting, read b.settings.menu_resolved, and fall back to the section-level pick — multiple menu blocks, each showing a different menu.

Resource pickers#

A picker setting stores the picked entity's STABLE id; at render the host resolves it IN PLACE — {{ settings.product }} IS the product card object. A deleted or unresolvable entity becomes nil: guard with {% if settings.product %} and render your placeholder. Picker types: vendor, category, brand, product, collection (integer-id family), blog_post, blog_category, page (uuid family), and policy (picked by fixed policy type).

vein
{% if settings.product %}
  {% assign p = settings.product %}
  <a href="{{ p.url }}">
    {% if p.featured_image %}<img src="{{ p.featured_image.url | image_url: width: 480 }}" alt="{{ p.featured_image.alt_text | default: p.title }}">{% endif %}
    <span>{{ p.title }}</span>
    <span>{% if p.price_range.has_range %}{{ 'products.from' | t }} {{ p.price_range.min | money }}{% elsif p.price %}{{ p.price.current | money }}{% endif %}</span>
  </a>
{% else %}
  {{ 'product-1' | placeholder_svg }}
{% endif %}

List pickers#

product_list and collection_list store an ORDERED list of ids — the merchant picks up to "max" (default 12), reorders freely, and the editor renders chips with add/remove/reorder. At render each id resolves to the same card object the single picker yields; deleted entries DROP, so the list you iterate is always dense and ordered. Saving deduplicates and caps at max.

vein
{% for p in settings.products %}
  <article class="card"> … </article>
{% endfor %}
{% if settings.products.size == 0 %} …placeholder cards… {% endif %}
{% schema %}
{ "settings": [
  { "id": "products", "type": "product_list", "max": 8,
    "editor": {"label": "products", "group": "content"} }
] }
{% endschema %}

Dynamic sources#

A text or textarea setting may store the sentinel "@dynamic:" instead of literal text — the render resolves the path against live ctx. The path's ROOT must be one of the owning schema's declared ctx_needs: a section's settings validate against the section's allowance, a THEME block's against the block's own, and a LOCAL block type's against its section's (local blocks render inside the section's template). Save rejects a root outside the allowance; at render an absent path resolves to "" — the same graceful-empty law as a deleted picker entity.

vein
// stored setting value (set via the editor's connect-dynamic-source flow)
"title": "@dynamic:product.title"

// renders as the live product title — the template just writes
{{ settings.title }}