Skip to content

    Pre-launch API. The concepts described here are settled, but the API shape is not: request and response fields, parameters and defaults can still change. Build against it, and talk to your Luigi's Box contact before you put an integration into production.

    Templates

    View source

    Every widget renders through named templates. Each has a built-in version; a file named template-<name>.ts in your integration replaces it. Listings use templates in your own markup and CSS classes, so results look like the rest of your site.

    For each template name, the last one found is used:

    1. The widget’s built-in template
    2. integration/_global/template-<name>.ts
    3. The widget’s own folder — integration/search/, integration/autocomplete/, and so on

    Category pages use search/ templates, overridden by any in plp/. A _global/ template named after a widget (template-recommender-…) applies to that widget only.

    WidgetTemplate names
    Search results, category pagessearch-box, search-header, search-facets-default, search-range-default, search-results-default, search-result-default, search-pagination
    Autocompleteautocomplete-box, autocomplete-result-default, autocomplete-sheet, autocomplete-empty
    Recommendersrecommender-box-default, recommender-result-default, recommender-popup

    Any other name is your own template, rendered from another with include().

    A template is lit-html: a default-exported function of its scope that returns html`…`.

    integration/search/template-search-result-default.ts
    import { html, nothing } from '@/lit';
    import type { SearchResultScope } from '@/types';
    export default ({ r, i, img, price }: SearchResultScope) => html`
    <a class="product-tile" href=${r.c.url}>
    ${img(r, i)}
    <span class="product-tile__name">${r.c.title}</span>
    ${r.c.price ? html`<b class="product-tile__price">${price(r.c.price)}</b>` : nothing}
    </a>`;

    Import lit from @/lit and scope types from @/types. A re-render changes only the bindings whose value changed, so images, focus and open <details> survive it. Templates need no eval and work under a strict Content Security Policy.

    Syntax
    ${expr}Text, escaped
    ${cond ? html`…` : nothing}A condition
    ${repeat(list, (x) => x.id, (x) => html`…`)}A keyed list; list.map(…) for an unkeyed one
    attr=${v}, ?disabled=${b}, .checked=${b}Attribute, boolean attribute, property
    @click=${s.showMore}Event handler
    ${include('product-tile', { badge })}Another template, with this scope plus the given properties

    @/lit also exports svg, classMap, styleMap, ifDefined and unsafeHTML.

    A template must depend on its scope by value. Luma reuses a result tile’s output while the values it read are unchanged, so an object changed in place, rather than replaced, can keep showing the old value.

    TemplateScope typeHolds
    search-*SearchScopequery, title, total, results, facets, ranges, flags, chips, sorts, more, showMore, and the actions on them
    search-result-defaultSearchResultScopeThe above, plus r (the result) and i (its position, from 0)
    search-range-defaultSearchRangeScopeThe above, plus range (one numeric facet)
    autocomplete-*AutocompleteScopequery, groups, byType(), active, choose(), allHref
    autocomplete-result-defaultAutocompleteResultScopeThe above, plus r and i
    recommender-*RecommenderScoperesults, and close() in the popup
    recommender-result-defaultRecommenderResultScopeThe above, plus r and i

    Every field is documented on its type in @/types. The ones a listing template binds most:

    Field
    facetsText facets. Each value carries label, checked, href and rank (its place in the API’s order, most frequent first). There are no counts per value.
    rangesNumeric facets: min, max, from, to, step, and set(from, to) to apply one. Call set when the shopper lets go, not on every move.
    chipsActive filters, each with the link that removes it
    clearA link that removes every filter, or null
    sortsEach with label, selected and href
    more, showMoreWhether another page exists, and the handler that appends it

    A control that changes the listing is a link: put its href on an <a>, or as data-lbx-href on any other element, and bind go(link) as the click handler. Luma then prefetches the state the control leads to when the shopper hovers or focuses it.

    Listings use “show more” only. results holds every page loaded so far; showMore appends the next one. Bind it on <button data-lbx-more @click=${s.showMore}>: from the keyboard, focus moves to the first new result. There are no numbered pages, and “show more” does not change the URL.

    A result r has:

    r.idIts @id, for example product/sku-1001
    r.typeIts @type
    r.aThe fields the API returned, as returned — see Fields
    r.cComputed attributes

    The built-in computed attributes:

    Read from
    r.c.title@title
    r.c.urlurl, else web_url
    r.c.imagelbx:image, else image_url, else image_link
    r.c.pricelbx:price, else price

    Add or replace them in _global/_widget.ts (every widget) or a widget’s own _widget.ts:

    integration/_global/_widget.ts
    import type { LumaConfigType } from '@/types';
    export default (c: LumaConfigType) => {
    c._global.computedAttributes = {
    onSale: (r) => Number(r.a.list_price) > Number(r.a.price),
    };
    };

    In every scope:

    Helper
    t(key, params)Translated text. The widget’s own keys first (search.sort), then the bare key.
    price(n)A number as a price in the site’s locale and currency; empty for a missing price
    plain(v)A field’s text — its first value when it is a list — for attributes and alt text
    first(v)The first value of a list, or the value itself
    include(name, props)Another template, with this scope plus props
    img(r, i, opts)A complete <img> in a placeholder box
    image(r, i, opts)The same image’s attributes, for your own markup — a <picture>, your own classes

    img() and image() build every attribute of a product image from images in _global/_config.ts:

    images: {
    srcset: { match: '^(https://images\\.example\\.com/.+\\.jpg)$', pattern: '$1?w={w}', widths: [240, 480, 960] },
    sizes: '(min-width: 1200px) 240px, (min-width: 768px) 30vw, 50vw',
    width: 400,
    height: 400,
    firstRow: { '0': 2, '768': 3, '1200': 4 },
    },
    KeyDefault
    srcset—How to build srcset from the image URL: match, a regular expression on it; then either pattern with {w} for each of widths (an image resizer), or candidates, a map of descriptor to replacement ({ "240w": "/thumb_$1", "480w": "/$1" }). A URL that does not match gets no srcset.
    sizes—The sizes attribute: the tile image’s rendered width per viewport
    width, height400 × 400The image box’s size. It reserves the space before the image loads, so nothing shifts.
    firstRow{ "0": 2, "768": 3, "1200": 4 }Images in the first row load eagerly at high priority and are preloaded; the rest load lazily. A number, or columns per viewport min-width.
    placeholderNeutral skeletonCatalog fields holding a dominant colour (color) or a tiny image URL (image) to show in the box while the image loads. false for none.
    preloadtruePreload the first row’s images
    fadetrueLazy images fade in, except under reduced motion
    warm0For results fetched ahead of a click, load the first N images at low priority

    opts on img() and image() overrides one image: src, sizes, eager, alt, class, title.

    A widget’s styles go in style-<name>.css in its folder and load with the widget. Luma’s built-in CSS sits in @layer lbx, so a rule of yours without a layer always wins over it.

    style-host.css is different: it hides your own UI that the widget replaces, and applies only while Luma’s is shown. Target only elements of the page it replaces — on a category page, the listing, not the breadcrumbs.

    Luma’s built-in templates meet WCAG 2.2 AA: combobox semantics on the autocomplete input, labelled controls, managed focus in dialogs, aria-busy while a listing renders. A template override takes on that responsibility for its own markup:

    • Use real <a> and <button> elements for controls.
    • Label inputs — the facet search field with t('facetSearch', { facet }).
    • Bind facet group toggles as <button aria-expanded=${open} aria-controls=${id}>.
    • Give images an alt — plain(r.c.title) if the catalog has nothing better.

    yarn luma a11y checks an integration’s widgets, by keyboard too — see Tools and debugging.