Skip to main content

Custom Carousel

You can create a custom carousels either a list or slider, with Handlebars

What is Handlebars

Handlebars is a popular templating language used in web development. It helps you generate HTML dynamically by combining templates (HTML with special tags) and JSON data, that comes from an endpoint that you have specified. Introduction Handlebars

Endpoint data requirements

Your endpoint, ideally return an object that contains items array at the root. Within an item you can have other arrays. You can not have other arrays at the root.

items array

When your endpoint returns array that is not named items

When interacting with an endpoint, like shopify MCP, when using the search_shop_catalog. It returns an array of products, you have to transform this JSON to be an array of items

Using JSONata Response for the transformation

{
"items": products,
"pagination": pagination,
"available_filters": available_filters,
"instructions": instructions
}

Which will generate the JSON that now has an array of items

-- Absolutely, here’s a cleaned-up, concise, and structured version that improves clarity and flow. I’m also correcting typos and tightening up the decision tree.


Endpoint Data Handling

1. No Carousel Items:

  • If the Carousel exists but items.length === 0, the LLM processes the returned data (which contains no items) and typically responds with "no results found" along with possible recommendations.

2. Carousel Items Exist — Data Routing Rules: Data routing is controlled by two parameters:

  • The endpoint JSON object property: process
  • The LLM response parameter: llmCarouselBothProcess

Routing Logic:

  • If the process parameter is set:

    • process = "llm":

      • Data is not sent to the Carousel.
      • The LLM processes the JSON and generates the response.
    • process = "both":

      • Data is sent to both the LLM and the Carousel.
      • The LLM processes the JSON and provides additional commentary or context around the Carousel.
  • If the LLM response includes llmCarouselBothProcess:

    • llmCarouselBothProcess = "llm":

      • Data is not sent to the Carousel.
      • The LLM processes the JSON exclusively.
    • llmCarouselBothProcess = "both":

      • Data is sent to both the LLM and the Carousel.
      • The LLM processes the data and enriches the Carousel with additional context.
  • If neither process nor llmCarouselBothProcess is set to "llm" or "both":

    • Data bypasses the LLM and is sent directly to the Carousel for rendering.

Summary Table

ConditionSent to LLMSent to CarouselNotes
Carousel exists, items.length === 0✔️LLM handles empty results
process = "llm"✔️LLM only
process = "both"✔️✔️Both LLM and Carousel
llmCarouselBothProcess = "llm"✔️LLM only
llmCarouselBothProcess = "both"✔️✔️Both LLM and Carousel
(Default: neither flag set to "llm"/"both")✔️Carousel only, bypassing LLM

Real Estate Example for appartment rentals

Real Estate Example


In this example, there is a list of buildings, which is the items array. Each item has an array of units.

custom


The Carousel Controls accepts structured data to display a series of interactive items, each potentially with multiple panels, forms, images, and detailed content. Each item can include titles, images, descriptions, forms, and buttons with various functions. Below is a comprehensive guide on how to pass data to the Carousel Control and define the behavior of the interactive elements.

Tabs

The first tab represents the items, it is the Handlebars template for creating the cards. One tab is required, other tabs are optional. Additional tabs are panel views of that item information. The name of the tab is what you use to add buttons to navigage between the different panel.

Tab "List"

{{#each items as |item index|}}
<div class="carousel-card flex flex-wrap items-start mb-4 bg-white border h-auto border-gray-200 rounded-lg shadow md:flex-row dark:border-gray-700" data-index="{{index}}">

<div class="relative md:w-1/3 carousel-container card-image {{#if (gte item.images.length 1)}}w-full{{else}}w-full md:w-1/2{{/if}}" data-carousel-id="carousel-{{index}}" id="carousel-{{index}}" data-carousel="slide">
<div class="relative h-64 overflow-hidden">
{{#each item.images as |image imageIndex|}}
<img src="{{image}}" alt="Image for {{../name}}" class="card-image object-cover w-full h-96 md:h-auto md:mt-6 md:ml-2 hidden duration-700 ease-in-out" data-carousel-item id="carousel-{{index}}-item-{{imageIndex}}"/>
{{/each}}
</div>

{{#if (lte item.images.length 3)}}
{{#if (gte item.images.length 1)}}
<div class="absolute z-30 flex -translate-x-1/2 bottom-5 left-1/2 space-x-3 rtl:space-x-reverse">
{{#each item.images as |image imageIndex|}}
<button type="button" class="w-3 h-3 rounded-full" aria-current="true" aria-label="Slide {{imageIndex}}" data-carousel-slide-to="{{imageIndex}}" id="carousel-{{index}}-indicator-{{imageIndex}}"></button>
{{/each}}
</div>
{{/if}}
{{else}}
<div class="absolute z-30 flex -translate-x-1/2 bottom-5 left-1/2 space-x-3 rtl:space-x-reverse">
<button type="button" class="w-3 h-3 rounded-full bg-blue-600" aria-current="true" aria-label="Previous Slide" data-carousel-prev id="carousel-{{index}}-prev"></button>
<button type="button" class="w-3 h-3 rounded-full bg-blue-600" aria-current="true" aria-label="Next Slide" data-carousel-next id="carousel-{{index}}-next"></button>
</div>
{{/if}}
</div>

<div class="flex flex-col justify-between p-4 leading-normal md:w-2/3 card-right">
<h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">{{item.name}}</h5>
<p short-description mb-3 font-normal text-gray-700 dark:text-gray-400">
Distance: {{item.distance}} Km
</p>
<p class="font-title text-md md:text-xl">
{{item.location.Address}}, {{item.location.City}}, {{item.location.ProvinceCode}} {{item.location.PostalCode}}
</p>

{{#if item.suites.length}}
<p class="font-title text-md md:text-xl">
{{item.suites.[0].bedrooms}} Bed / {{item.suites.[0].bathrooms}} Bath – {{item.suites.[0].squareFeet}} sq ft – ${{item.suites.[0].rate}} CAD/month
</p>
{{/if}}

<p class="short-description mb-3 font-normal text-gray-700 dark:text-gray-400">
{{{item.buildingDescription}}}
</p>

<div class="flex items-center my-4">

<button class="card-button list-btn uppercase flex-grow hover:bg-gradient-to-t md:flex-none" data-type="panel" onclick='carousel.selectByName("Units")'>Units</button>
<button class="card-button list-btn uppercase flex-grow hover:bg-gradient-to-t md:flex-none" data-type="panel" onclick='carousel.selectByName("Info")'>Info</button>
{{#if ../isBot }}
<button class="card-button list-btn uppercase flex-grow hover:bg-gradient-to-t md:flex-none" onclick='ai12zBot.sendMessage("Request a Tour form","Request a Tour")'>Book Tour</button>
{{else}}
<button class="card-button list-btn uppercase flex-grow hover:bg-gradient-to-t md:flex-none" onclick='ai12zCta.sendMessage("Request a Tour form","Request a Tour")'>Book Tour</button>
{{/if}}
</div>
</div>
</div>
{{/each}}

<div class="pagination-controls">
<button class="prev-btn list-btn" {{#unless hasPrev}}disabled{{/unless}}>Prev</button>
<span> Page {{getcurrentPage}} of {{getPageCount}} </span>
<button class="next-btn list-btn" {{#unless hasNext}}disabled{{/unless}}>Next</button>
</div>


Tab "Units"

{{#each item.suites as |suite index|}}
<div class="card">
<div class="card-content">
<h3 class="card-title">
<b
>Suite {{inc index}}: {{suite.bedrooms}} Bed / {{suite.bathrooms}}
Bath</b
>
</h3>
<p class="card-subtitle">
{{suite.squareFeet}} sq ft - ${{suite.rate}} CAD/Month
</p>
<p class="card-subtitle">Available: {{suite.availabilityDate}}</p>
</div>
<img
class="card-img"
src="{{suite.floorplanImage}}"
alt="Floor Image of {{title}}"
/>
</div>
{{/each}}
<button
class="panel-button list-btn fav-btn"
data-type="panel"
onclick='carousel.selectByName("List")'
data-id="0"
>
Add to favourites
</button>
<button
class="panel-button list-btn text-white"
data-type="panel"
onclick='carousel.selectByName("List")'
>
Back
</button>
<style>
.card {
border: 1px solid #ddd;
/*border-radius: 8px;*/
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-family: sans-serif;
margin: 16px;
}

.card-content {
padding: 16px;
}

.card-title {
margin: 0;
font-size: 1.2em;
font-weight: bold;
}

.card-subtitle {
margin-top: 8px;
font-size: 0.95em;
color: black;
}

.card-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.fav-btn {
margin: 10px;
}
</style>

Tab "Info"

<div class="amenities-section p-4 bg-gray-50 rounded-md shadow-md w-full">
<h4 class="text-lg font-semibold text-gray-800 mb-2">Amenities</h4>
<ul class="list-disc list-inside text-gray-700 space-y-1">
{{#each item.amenities}}
<li>{{this}}</li>
{{/each}}
</ul>
<h4 class="text-lg font-semibold text-gray-800 mb-2">Utilities</h4>
<ul class="list-disc list-inside text-gray-700 space-y-1">
{{#each item.utilities}}
<li>{{this}}</li>
{{/each}}
</ul>
<p>
<b>Contact Info: </b> {{item.location.Address}}, {{item.location.City}},
{{item.location.Country}}
</p>
<button
class="panel-button list-btn text-white"
data-type="panel"
onclick='carousel.selectByName("List")'
>
Back
</button>
</div>

Buttons

<button class="panel-button list-btn text-white" onclick="window.open('https://www.ai12z.com', '_blank')">ai12z</button>

Panel button, to list note class is panel-button

<button class="panel-button list-btn text-white" data-type="panel" onclick='carousel.selectByName("List")'>Back</button>

Panel button, to any other tab, class is card-button

<button class="card-button list-btn uppercase flex-grow hover:bg-gradient-to-t md:flex-none" data-type="panel" onclick='carousel.selectByName("Units")'>Units</button>
{{#if ../isBot }}
<button class="card-button list-btn uppercase flex-grow hover:bg-gradient-to-t md:flex-none" onclick='ai12zBot.sendMessage("Request a Tour form","Request a Tour")'>Book Tour</button>
{{else}}
<button class="card-button list-btn uppercase flex-grow hover:bg-gradient-to-t md:flex-none" onclick='ai12zCta.sendMessage("Request a Tour form","Request a Tour")'>Book Tour</button>
{{/if}}

JavaScript functions

Shadow Root

Shadow Root allows ai12z bot widget to encapsulate its HTML, CSS, and JavaScript, preventing the host website’s styles or scripts from interfering with your bot’s appearance or behavior. This isolation ensures your chatbot always looks and functions as intended, no matter where it’s embedded. For bot providers, this means fewer conflicts, easier maintenance, and a more reliable user experience across all customer sites.

Where to add your Script

Add your javascript to the bot, config under the tab Script, do not add to the handlebars template. Note: You cannot add it outside of the script tag, because the code is modified to work with a shadow root. To if you put it in an external js file it will not work.

Javascript and to the bot or search config under the tab javascript

function openLink() {
window.open("https://www.google.com", "_blank")
}
<button class="panel-button list-btn text-white" onclick="openLink()">
ai12z
</button>

Example using JavaScript within Shadow Root

Menue items

Handelbars for Menu Example

In this example, we show how to select the QNTY, when clicking Add to Cart

{{#each items}}
<div
class="carousel-card flex flex-wrap items-start mb-4 bg-white border h-auto border-gray-200 rounded-lg shadow"
>
<!-- Image Panel -->
<div class="relative w-full md:w-1/3 carousel-container card-image">
{{#if images.[0]}}
<img
src="{{images.[0]}}"
alt="Image for {{title}}"
class="card-image object-cover w-full h-64 md:h-80 duration-700 ease-in-out"
/>
{{/if}}
</div>
<!-- Details Panel -->
<div
class="flex flex-col justify-between p-4 leading-normal md:w-2/3 card-right"
>
<h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900">
{{title}}
</h5>
<ul class="subtitles mb-1 text-sm text-gray-600">
{{#each subTitle}}
<li>{{this}}</li>
{{/each}}
</ul>
<p class="short-description mb-3 font-normal text-gray-700">
{{shortDescription}}
</p>
<!-- Quantity and Button -->
<div class="flex items-center my-4">
<label for="qnty-{{@index}}" class="mr-2">Qnty:</label>
<select id="qnty-{{@index}}" name="qnty">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
</select>
<button
class="card-button list-btn uppercase flex-grow hover:bg-gradient-to-t md:flex-none ml-2"
onclick="addToCart(this, '{{title}}')"
>
Add to Cart
</button>
</div>
</div>
</div>
{{/each}}

Javascript Function

Only add to the Control, within the tab Script, not to handlebars template and not in a seperate js file. ai12z runs in the Shadow DOM.

function addToCart(button, title) {
// Find the nearest select[name="qnty"] within the same container as the button
var select = button.parentNode.querySelector('select[name="qnty"]')
var qty = select ? select.value : 0
alert("Added to cart: " + title + ", Quantity: " + qty)
}

Note is the function above uses the button.parentNode refers to the button's immediate parent, which contains the select.

Handlebars helper functions

HelperWhat it DoesExample Use
lteChecks if a is ≤ b{{#if (lte value 10)}} ... {{/if}}
gteChecks if a is ≥ b{{#if (gte value 5)}} ... {{/if}}
andBlock helper: both arguments must be truthy{{#and a b}} ... {{/and}}
getPageCountReturns total page count from context{{getPageCount}}
getcurrentPageReturns current page from context{{getcurrentPage}}
processArrayOutputs array as a safe JSON string{{processArray items}}
jsonOutputs context as a JSON string, escapes for HTML{{json data}}

MCP Example Shopify Store

Ecommerce

Integration

Integraion

Using JSONata Response for the transformation

{
"items": products,
"pagination": pagination,
"available_filters": available_filters,
"instructions": instructions
}

Handlebars

{{#each items}}
<div
class="carousel-card bg-white rounded-lg shadow border p-4 mb-6"
data-index="{{@index}}"
data-item="{{{json this}}}"
>
<!-- Product image and color selector -->
<div class="flex flex-col items-center">
<img
id="main-image-{{@index}}"
src="{{variants.[0].image_url}}"
alt="{{title}}"
class="object-cover w-44 h-44 mb-3 rounded"
style="background:#eee;"
/>
<div class="flex flex-row space-x-2 mt-1 mb-2">
{{#each variants}}
<button
type="button"
class="color-btn w-7 h-7 rounded-full border-2 border-gray-300"
style="background:#ddd;"
aria-label="{{title}}"
onclick="selectColor(this, {{@index}})"
data-variant-index="{{@index}}"
title="{{title}}"
></button>
{{/each}}
</div>
</div>
<!-- Product details -->
<div class="flex flex-col px-2 mt-2">
<h2 class="text-lg font-bold mb-1">{{title}}</h2>
<p class="font-semibold text-gray-600 mb-1">USD ${{price_range.min}}</p>
<p class="mb-2 text-gray-800 text-sm">{{description}}</p>
<label class="font-semibold text-xs">Size/Variant:</label>
<select
class="size-select border rounded px-2 py-1 text-sm"
id="size-select-{{@index}}"
onchange="onVariantChange(this)"
>
{{#each variants}}
<option value="{{@index}}">{{title}}</option>
{{/each}}
</select>
<button
class="card-button list-btn uppercase flex-grow hover:bg-gradient-to-t md:flex-none ml-2 mt-2 bg-black text-white px-3 py-1 rounded text-sm"
onclick="addToCart(this)"
>
Add to Cart
</button>
</div>
</div>
{{/each}}

JavaScript

JavaScript ecom

function selectColor(button, variantIndex) {
// Find the parent card div
var card = button.closest(".carousel-card")
if (!card) return
var data = JSON.parse(card.getAttribute("data-item"))
var image = card.querySelector('img[id^="main-image"]')
if (image && data.variants[variantIndex]) {
image.src = data.variants[variantIndex].image_url
}
// Set dropdown to match the selected color
var select = card.querySelector(".size-select")
if (select) select.selectedIndex = variantIndex
// Store selection as data-attribute for addToCart
card.setAttribute("data-selected-variant", variantIndex)
}

function addToCart(button) {
var card = button.closest(".carousel-card")
if (!card) return
var data = JSON.parse(card.getAttribute("data-item"))
var select = card.querySelector(".size-select")
var variantIndex = select ? select.value : 0
var variant = data.variants[variantIndex]
alert("Added to cart: " + data.title + "\nVariant: " + variant.title)
}

function onVariantChange(select) {
var card = select.closest(".carousel-card")
if (!card) return
var data = JSON.parse(card.getAttribute("data-item"))
var variantIndex = select.value
var image = card.querySelector('img[id^="main-image"]')
if (image && data.variants[variantIndex]) {
image.src = data.variants[variantIndex].image_url
}
// Optional: update the round indicator if you want, e.g. highlight the correct color dot
card.setAttribute("data-selected-variant", variantIndex)
}

function applyColorSwatches() {
const colorMap = {
Black: "#222",
Slate: "#a9b3ba",
Sunrise: "#faa61a",
Ironwood: "#ae5249",
Chalk: "#eaeaea",
Red: "#b30000",
Purple: "#b185db",
Gold: "#ecc94b",
// Add more as needed
}
let root
if (typeof carousel !== "undefined") {
// Try to get the shadow root where the cards are rendered.
root = carousel?.shadowRoot
}
// Now look for the color buttons in the right scope
;(root?.querySelectorAll ? root.querySelectorAll(".color-btn") : []).forEach(
(btn) => {
const colorText = btn.title || btn.getAttribute("aria-label") || ""
let colorHex = "#ddd"
Object.keys(colorMap).forEach((key) => {
if (colorText.includes(key)) colorHex = colorMap[key]
})
btn.style.background = colorHex
}
)
}

applyColorSwatches()

Note:: root = carousel?.shadowRoot; This is how we reference the shadowRoot, if carousel

JSON from endpoint, simplified...

{
"available_filters": [
{
"label": "Availability",
"values": {
"input_options": [
{
"input": {
"available": true
},
"label": "In stock"
},
{
"input": {
"available": false
},
"label": "Out of stock"
}
],
"label": ["In stock", "Out of stock"]
}
},
{
"label": "Price",
"values": {
"input_options": [
{
"input": {
"price": {
"max": 319.95,
"min": 0
}
},
"label": "Price"
}
],
"label": ["Price"]
}
}
],
"instructions": "Use markdown to render product titles as links to their respective product pages using the URL property.\n\nFor product data:\n- If variants are included in the response, those are the only variants available\n- If variants are not included, use the options and availability matrix with get_product_details tool\n- Check product_type, tags, and other properties to help the user find what they need\n\nFor filters:\n- Mention available filters to the user if they might be helpful\n- Use filters ONLY from available_filters in follow-up searches\n- If a search term matches a filter, do a follow-up search with the filter applied\n\nFor pagination:\n- Show only the first set of results initially\n- If pagination.hasNextPage is true, ask if the user wants to see more\n- Only fetch additional pages when explicitly requested\n",
"items": [
{
"description": "Don't leave home without the Smith Level Mips snow helmet. It's built with a hybrid shell construction and Mips technology, which helps reduce rotational forces if the helmet gets hit at an angle.",
"image_url": "https://cdn.shopify.com/s/files/1/0920/9112/1967/files/1.png?v=1749094711",
"price_range": {
"currency": "USD",
"max": "225.0",
"min": "225.0"
},
"product_id": "gid://shopify/Product/10607997387055",
"product_type": "",
"tags": ["Men"],
"title": "Men's Level Mips Snow Helmet",
"variants": [
{
"available": true,
"currency": "USD",
"image_url": "https://cdn.shopify.com/s/files/1/0920/9112/1967/files/1.png?v=1749094711",
"price": "225.0",
"title": "Black / S",
"variant_id": "gid://shopify/ProductVariant/51390165909807"
},
{
"available": true,
"currency": "USD",
"image_url": "https://cdn.shopify.com/s/files/1/0920/9112/1967/files/1.png?v=1749094711",
"price": "225.0",
"title": "Black / M",
"variant_id": "gid://shopify/ProductVariant/51390165975343"
},
{
"available": true,
"currency": "USD",
"image_url": "https://cdn.shopify.com/s/files/1/0920/9112/1967/files/E0062800TB00_P00_ec62e1a4-e582-443f-80d2-8abe8f76add4.webp?v=1750297479",
"price": "225.0",
"title": "Matte Slate / S",
"variant_id": "gid://shopify/ProductVariant/51390165942575"
}
]
},
{
"description": "Like a good day on the mountain, the Smith Level ski and snowboard helmet is at your service for ranging far and wide. It brings the added energy absorption of Zonal KOROYD® and the advanced angled impact protection of Mips® to let you focus on ripping high-speed arcs in the alpine and chasing untracked lines in the trees. The hybrid shell design adds durability without weighing you down for a helmet that you will actually take with you when the backcountry beckons. And because you love to ski all day, the fit and vents are adjustable on the fly, so you never need to slow down.",
"image_url": "https://cdn.shopify.com/s/files/1/0920/9112/1967/files/level-mips-helmet_matteIronwood_3Q.png?v=1749177564",
"price_range": {
"currency": "USD",
"max": "225.0",
"min": "195.0"
},
"product_id": "gid://shopify/Product/10615507124527",
"product_type": "",
"tags": ["Men"],
"title": "Men's Smith Level Snow Helmet",
"variants": [
{
"available": true,
"currency": "USD",
"image_url": "https://cdn.shopify.com/s/files/1/0920/9112/1967/files/level-mips-helmet_matteIronwood_3Q.png?v=1749177564",
"price": "225.0",
"title": "Matte Ironwood / MEDIUM-MIPS",
"variant_id": "gid://shopify/ProductVariant/51399728595247"
},
{
"available": true,
"currency": "USD",
"image_url": "https://cdn.shopify.com/s/files/1/0920/9112/1967/files/level-mips-helmet_matteSunrise_3Q.png?v=1750298981",
"price": "195.0",
"title": "Matte Sunrise / L",
"variant_id": "gid://shopify/ProductVariant/51399728922927"
}
]
}
],
"pagination": {
"currentPage": 1,
"endCursor": null,
"hasNextPage": false,
"hasPreviousPage": false,
"limitReached": false,
"maxPages": 500,
"nextPage": null,
"startCursor": "eyJwYWdlIjoxLCJsYXN0X2lkIjoxMDYwNzk5NzM4NzA1NSwicmV2ZXJzZSI6dHJ1ZX0="
}
}