Loomi AI for Shopify: HTML and EJS reference

Loomi AI for Shopify widgets lets you replace their default HTML using Experience customization. This article lists mandatory data attributes, supported EJS variables, and a working example per component.

🛑

Customer responsibility

Custom HTML overrides are the customer's responsibility. Test all customization on your storefront before publishing and verify that they preserve the requirements in this article.

Examples show the structural markup and data hooks — add your own CSS to match your design system.

Access Experience customization

Prerequisites

  • An active Loomi AI for Shopify application installed on your storefront.
  • At least one of Search, Autosuggest, or Collections active on the storefront.
  • Working knowledge of HTML and EJS templating for hands-on editing.

Navigate to the editor

  1. Open the Loomi AI for Shopify app from your Shopify admin.

  2. Click Experience customization in the app navigation.

  3. Select the template you want to edit: Search, Collections, or Autosuggest.

  4. Choose a component to expand the editor — Layout, Product card, Text facet, Slider facet, or Selected facet.

    Choose a component to expand the editor.
  5. Edit the HTML and EJS, then save. Use Revert to Bloomreach template to clear any custom configuration.

Page layout anchors

Every widget renders inside a layout root that depends on four anchor elements. Define each anchor as a single element using the exact class name shown. You can add other classes to the same element (for example, class="br-facets-container custom-sidebar-style").

⚠️

Mandatory anchors

The four class names below must exist in your markup. If an anchor is missing, Loomi AI generates an empty div with the corresponding class and attaches it to the layout root in this sequence: facets, sorting, grid, pagination.

ClassHostsUI role
br-facets-containerFacet toolbar when includeFacets is true. Loomi AI adds br-toolbar and renders facet groups, the mobile filters drawer, and related controls.Filter sidebar or top toolbar.
br-sortingThe br-sorting web component when sort options are configured. If there are no facets but sorting is enabled, br-toolbar attaches here instead.Sort by control.
br-products-gridThe product grid. Loomi AI appends a child div.br-products__grid containing br-product-card elements.Product listing.
br-paginationThe br-pagination web component when pagination is enabled and totalPages > 1.Page controls.

Example

<div>
  <aside class="br-facets-container"></aside>
  <div>
    <header>
      <div class="br-sorting"></div>
    </header>
    <div class="br-products-grid"></div>
    <footer>
      <div class="br-pagination"></div>
    </footer>
  </div>
</div>

Product card component

Product card component.

Data attributes

AttributeElementValueBehavior
data-br-actionProduct link, image linkproduct-card-product-linkhref navigation still works. Click additionally fires br-product-link-clicked for tracking in search or autosuggest. Doesn't change card content.
data-br-actionVariant swatchproduct-card-select-swatchClick selects that variant on the card. No navigation. Updates swatch styling, product image, title, prices, product link URL, and add-to-cart link to match the variant.
data-br-swatch-indexVariant swatch0..n-1 (number)Index in the visible swatch list. Must be the loop index over visibleSwatchItems.
data-br-actionOverflow +N or buttonproduct-card-toggle-variants-expandWhen showOverflowButton is true, click toggles between collapsed and expanded swatch rows. Collapsed shows only swatches that fit plus a +N button. Expanded shows all swatches and a control.

EJS variables

The productCard object exposes these fields:

FieldDescriptionData type
productRaw full product payload from Discovery. See ProductData below.ProductData
pidProduct ID.string
brandBrand name.string or undefined
titleEffective title. The selected variant overrides this when variants are enabled.string
thumbImageEffective image URL.string or undefined
priceEffective list price.number or undefined
salePriceEffective sale price.number or undefined
formattedPricePre-formatted list price.string or undefined
formattedSalePricePre-formatted sale price.string or undefined
hasDiscounttrue when both prices exist and salePrice < price.boolean
productUrlProduct detail page URL. Includes a variant query when applicable.string
addToCartHref/cart/add?id=… for the selected or first variant.string or undefined
displayVariantsWhether variant UI is enabled.boolean
swatchItemsAll swatches. See VariantSwatchItem below.VariantSwatchItem[]
visibleSwatchItemsSubset shown in the current collapsed or expanded state.VariantSwatchItem[]
selectedSwatchIndexIndex of the selected swatch.number
variantsExpandedWhether the overflow list is expanded.boolean
hiddenCountNumber of swatches hidden behind +N.number
showOverflowButtonWhether to render the overflow toggle.boolean

ProductData

ProductData is the raw product payload from Discovery, passed through as productCard.product. Discovery returns only the attributes you request in the widget's fl_fields parameter, so a field appears only when you include it (pid is always returned).

FieldDescriptionData type
pidProduct ID.string
titleProduct title.string
brandBrand name.string
descriptionProduct description.string
priceList price.number
price_rangePrice range for products with variants.number[]
promotionsActive promotions.string[]
sale_priceSale price.number
sale_price_rangeSale price range for products with variants.number[]
scoreDiscovery relevance score.number
thumb_imageThumbnail image URL.string or undefined
urlProduct detail page URL.string
variantsProduct variants.Variant[]

ProductData also accepts custom Discovery attributes. Define them in the widget's fl_fields parameter to make them available to your template.

VariantSwatchItem

VariantSwatchItem is a union type. Color swatches and image swatches use different shapes.

Color swatch (type: "color"):

FieldDescriptionData type
typeAlways "color".string
hexHex color value.string
labelSwatch label for accessibility and tooltips.string

Image swatch (type: "image"):

FieldDescriptionData type
typeAlways "image".string
urlSwatch image URL.string
labelSwatch label for accessibility and tooltips.string
fallbackHexBackground color shown while the image loads.string or undefined

Autosuggest product card limitations

Autosuggest renders product cards from its own Discovery API endpoint, which returns a reduced field set. When you template a product card for the Autosuggest dropdown, only these fields are available:

FieldDescriptionData type
pidProduct ID.string
titleProduct title.string
sale_priceSale price.number or undefined
thumb_imageThumbnail image URL.string or undefined
urlProduct detail page URL.string

References to any other productCard.* field render as empty in this context.

Example

<a
  href="<%= productCard.productUrl %>"
  data-br-action="product-card-product-link"
>
  <div>
    <% if (productCard.thumbImage) { %>
      <img src="<%= productCard.thumbImage %>" alt="<%= productCard.title %>" loading="lazy" />
    <% } else { %>
      <span>No image</span>
    <% } %>
  </div>
  <div>
    <% if (productCard.brand) { %>
      <div><%= productCard.brand %></div>
    <% } %>
    <h3><%= productCard.title %></h3>
    <% if (productCard.hasDiscount) { %>
      <div>
        <span><%= productCard.formattedSalePrice %></span>
        <span><%= productCard.formattedPrice %></span>
      </div>
    <% } else if (productCard.formattedSalePrice || productCard.formattedPrice) { %>
      <div>
        <span><%= productCard.formattedSalePrice || productCard.formattedPrice %></span>
      </div>
    <% } %>
  </div>
</a>
<% if (productCard.displayVariants && productCard.visibleSwatchItems.length) { %>
<div>
  <% productCard.visibleSwatchItems.forEach(function(item, idx) {
    let selected = idx === productCard.selectedSwatchIndex;
    let label = item.label || '';
  %>
    <% if (item.type === 'image') { %>
      <span
        title="<%= label %>"
        data-tooltip="<%= label %>"
        data-br-action="product-card-select-swatch"
        data-br-swatch-index="<%= idx %>"
        <% if (item.fallbackHex) { %>style="background-color: <%= item.fallbackHex %>"<% } %>
      >
        <img src="<%= item.url %>" alt="" loading="lazy" />
      </span>
    <% } else { %>
      <span
        style="background-color: <%= item.hex %>"
        title="<%= label %>"
        data-tooltip="<%= label %>"
        data-br-action="product-card-select-swatch"
        data-br-swatch-index="<%= idx %>"
      ></span>
    <% } %>
  <% }); %>
  <% if (productCard.showOverflowButton) { %>
    <button
      type="button"
      data-br-action="product-card-toggle-variants-expand"
      aria-label="<%= productCard.variantsExpanded ? 'Show fewer variants' : 'Show ' + productCard.hiddenCount + ' more' %>"
    >
      <%= productCard.variantsExpanded ? '−' : '+' + productCard.hiddenCount %>
    </button>
  <% } %>
</div>
<% } %>
<% if (productCard.addToCartHref) { %>
  <a href="<%= productCard.addToCartHref %>">
    Add to cart
  </a>
<% } %>

Facet component

Facet component.

Data attributes

AttributeElementValueBehavior
data-br-actionHeader or toggle controlfacet-toggle-dropdownOpens or closes the option list. When another facet is open, it closes first — only one facet dropdown stays open at a time.
data-br-actionEach selectable optionfacet-select-optionToggles the filter on or off. Fires br-facet-change. The parent widget refetches or filters results, then updates the product grid, pagination, and selected facet chips.
data-filter-valueEach selectable optionoption.filterValueIdentifies which filter value to add or remove.
data-br-actionShow more buttonfacet-show-moreReveals the next batch of facet options in the dropdown.
data-br-actionShow less buttonfacet-show-lessCollapses the list back to the initial visible limit. Selected options stay selected and remain visible.

EJS variables

The facet object exposes these fields:

FieldDescriptionData type
facetRaw facet data: { name: string, options: array of {name: string, filterValue: string, count: number}, initialOptionsLimit: number or undefined }object
nameFacet display name. Same as facet.name.string
isOpenWhether the dropdown is open.boolean
selectedCountNumber of selected options.number
selectedOptionsSelected filterValue strings.string[]
visibleOptionsOptions currently shown. Respects the limit and expansion state, and always includes selected options. Each item has an isSelected boolean.array
showMoreState for the show-more control: { visible: boolean, expanded: boolean, nextBatchSize: number }object
actionsData attribute constants for event delegation: { toggleDropdown: "facet-toggle-dropdown", selectOption: "facet-select-option", showMore: "facet-show-more", showLess: "facet-show-less" }.object

Example

<div data-br-action="<%= facet.actions.toggleDropdown %>">
  <span>
    <%= facet.name %><% if (facet.selectedCount > 0) { %> (<%= facet.selectedCount %>)<% } %>
  </span>
  <span aria-hidden="true"></span>
</div>
<% if (facet.isOpen) { %>
<div>
  <% facet.visibleOptions.forEach(function(option) {%>
  <div
    data-br-action="<%= facet.actions.selectOption %>"
    data-filter-value="<%= option.filterValue %>"
    data-test-option-filter-value="<%= option.filterValue %>"
  >
    <input
      type="checkbox"
      <%= option.isSelected ? 'checked' : '' %>
    />
    <div>
      <span><%= option.name %></span>
      <span>(<%= option.count %>)</span>
    </div>
  </div>
  <% }); %>
  <% if (facet.showMore.visible) { %>
    <% if (facet.showMore.expanded) { %>
      <button
        type="button"
        data-br-action="<%= facet.actions.showLess %>"
      >
        Show less
      </button>
    <% } else { %>
      <button
        type="button"
        data-br-action="<%= facet.actions.showMore %>"
      >
        + Show <%= facet.showMore.nextBatchSize %> more
      </button>
    <% } %>
  <% } %>
</div>
<% } %>

Slider facet component

Slider facet component.

Data attributes

AttributeElementValueBehavior
data-br-actionLower range input (type="range")slider-facet-min-inputDragging the lower thumb updates formattedMinValue and, after a short debounce, fires br-facet-change with a range filter. The product grid and counts refresh. If the user drags above the upper thumb, the input snaps back.
data-br-actionUpper range input (type="range")slider-facet-max-inputDragging the upper thumb updates formattedMaxValue and, after debounce, fires br-facet-change. Same grid refresh as the min input. Dragging below the lower thumb snaps back.

Both inputs must be real HTMLInputElement instances (not divs). The component listens to the input event and reads input.value.

Set min, max, and step from sliderFacet.start and sliderFacet.end. Bind current values from sliderFacet.minValue and sliderFacet.maxValue.

EJS variables

The sliderFacet object exposes these fields:

FieldDescriptionData type
nameFacet title.string
minValueCurrent lower handle value.number
maxValueCurrent upper handle value.number
startFacet minimum bound from the API.number
endFacet maximum bound from the API.number
countResult count in range, from the API.number
formattedMinValueDisplay string for minValue.string
formattedMaxValueDisplay string for maxValue.string
formattedStartDisplay string for start.string
formattedEndDisplay string for end.string
selectedRangePre-selected range from the parent, if any: { start: number, end: number }object or undefined
actionsData attribute constants for event delegation: { minInput: “slider-facet-min-input”, maxInput: “slider-facet-max-input” }object

Example

<div><%= sliderFacet.name %></div>
<div>
  <input
    type="range"
    value="<%= sliderFacet.minValue %>"
    min="<%= sliderFacet.start %>"
    max="<%= sliderFacet.end %>"
    step="1"
    data-br-action="<%= sliderFacet.actions.minInput %>"
  />
  <span aria-hidden="true"></span>
  <input
    type="range"
    value="<%= sliderFacet.maxValue %>"
    min="<%= sliderFacet.start %>"
    max="<%= sliderFacet.end %>"
    step="1"
    data-br-action="<%= sliderFacet.actions.maxInput %>"
  />
</div>
<div>
  <span><%= sliderFacet.formattedMinValue %></span>
  <span><%= sliderFacet.formattedMaxValue %></span>
</div>
<p aria-hidden="true">
  <%= sliderFacet.formattedStart %> – <%= sliderFacet.formattedEnd %>
  <% if (sliderFacet.count != null) { %>
    (<%= sliderFacet.count %>)
  <% } %>
</p>

Selected facet pill component

The active filter pill that appears in the selected-filters row above the product grid.

Selected facet pill component.

Data attributes

AttributeElementValueBehavior
data-br-actionRemove controlfacet-chip-removeClick removes the chip from the active-filters row and clears the filter value from the facet selection. Fires br-facet-change. The parent unchecks the matching facet option, updates other chips, and refreshes the product grid. The chip disappears on the next render.

EJS variables

The facetChip object exposes these fields:

FieldDescriptionData type
facetNameFacet group name.string
optionNameHuman-readable option label.string
filterValueValue removed when the chip is cleared.string
displayTextDefault label: facetName: optionName.string
allSelectedOptionsFull selection for this facet after removal.string[]
actionsData attribute constants for event delegation: { remove }.object

Example

<div>
  <span><%= facetChip.displayText %></span>
  <button
    type="button"
    data-br-action="<%= facetChip.actions.remove %>"
    aria-label="Remove <%= facetChip.displayText %>"
  >
    X
  </button>
</div>

Sorting component

Sorting component.

Data attributes

AttributeElementValueBehavior
data-br-actionTrigger buttonsorting-triggerClick toggles the sort dropdown open and closed.
data-br-actionEach sorting optionsorting-optionClick selects the sort option, closes the dropdown, updates the displayed label, and fires br-sort-change. The parent widget reloads results in the new sort order — the product grid and pagination update.
data-valueEach optionencodeURIComponent(option.value)Tells the component which sort key was chosen so the correct option becomes selected and the parent receives the right value in br-sort-change. Wrong or missing values prevent selection.

EJS variables

The sorting object exposes these fields:

FieldDescriptionData type
optionsAll sort options.{ label, value }[]
selectedValueCurrently selected value from the parent.string
selectedLabelLabel of the effective selection.string
effectiveSelectedValueResolved value. Falls back to the first option if selectedValue is invalid.string
labelDesktop or static label. Default "Sort by".string
mobileLabelShort label for mobile. Default "Sort".string
displayLabelmobileLabel when viewport width is <= 768px, otherwise label.string
isOpenWhether the dropdown is open.boolean
isMobiletrue when matchMedia('(max-width: 768px)') matches.boolean

Example

<button type="button" data-br-action="sorting-trigger">
  <%= sorting.displayLabel %>: <%= sorting.selectedLabel %>
</button>
<ul>
  <% sorting.options.forEach(function(option) { %>
    <li
      data-br-action="sorting-option"
      data-value="<%= encodeURIComponent(option.value) %>"
      aria-selected="<%= option.value === sorting.effectiveSelectedValue %>"
    ><%= option.label %></li>
  <% }); %>
</ul>

Pagination component

Pagination component.

Data attributes

AttributeElementValueBehavior
data-br-actionPrevious buttonpagination-prevClick goes to the previous page when not on page 1. Fires br-page-change. The parent loads the prior result set, updates the active page styling, and refreshes the product grid.
data-br-actionNext buttonpagination-nextClick goes to the next page when not on the last page. Same refresh as prev.
data-br-actionPage number buttonpagination-pageClick jumps to that page number. Fires br-page-change and reloads results for the page.
data-br-pagePage number buttonPage number (for example, 1)Supplies the target page index for pagination-page clicks. Wrong values navigate to the wrong page or no-op.

EJS variables

The pagination object exposes these fields:

FieldDescriptionData type
currentPageActive page.number
totalPagesTotal page count.number
pageNumbersSequence to render. Uses "ellipsis" for gaps.`(number"ellipsis")[]`
isPrevDisabledtrue on the first page.boolean
isNextDisabledtrue on the last page.boolean
actionsData attribute constants for event delegation: { prev: “pagination-prev”, next: “pagination-next”, page: “pagination-page” }object

Example

<button data-br-action="<%= pagination.actions.prev %>" <%= pagination.isPrevDisabled ? 'disabled' : '' %>>Prev</button>
<% pagination.pageNumbers.forEach(function(page) { %>
  <% if (page === 'ellipsis') { %>
    <span>…</span>
  <% } else { %>
    <button
      data-br-action="<%= pagination.actions.page %>"
      data-br-page="<%= page %>"
    ><%= page %></button>
  <% } %>
<% }); %>
<button data-br-action="<%= pagination.actions.next %>" <%= pagination.isNextDisabled ? 'disabled' : '' %>>Next</button>

Related articles



© Bloomreach, Inc. All rights reserved.