Skip to main content

POS list

  • Object display: Display and link to records such as cart items, customers, or products, with images and descriptions.
  • Menu items: Display menu items and settings with switches, or a chevron icon to indicate navigation.
  • Selectable options: Display selectable options, filters, and actions.
  • Mixed layouts: Combine section headers, dividers, and content rows in one list by giving each kind of row its own template.
  • Paginated data: Show paginated data with a loading indicator and an end-reached callback.

Displays a virtualized list of rows rendered from plain data and item templates compiled with posListTemplate.

string

A unique identifier for the element.

Anchor to itemTemplates
itemTemplates

Reusable templates that define the layout of list rows. Use posListTemplate to define one or more <s-pos-list-item> templates.

Anchor to loadingMore
loadingMore
boolean
Default: false

Whether more data is being loaded. Set to true when paginating and fetching additional data for the list. POS shows a progress indicator after the last row.

[]

An array of POSListRow objects that define the content of each row in the list.

The POS list component provides event callbacks for handling user interactions. Learn more about handling events.

Anchor to loadmore
loadmore
(event: <"s-pos-list">) => void

Callback when the list has scrolled near its end and more rows should be loaded.

Anchor to rowclick
rowclick
(event: ) => void

Callback when a button row is activated. event.detail.item is the row and event.detail.index its position in rows. Rows rendered by a text template never fire it.

Content slots for the POS list.

Anchor to header
header
HTMLElement

Content displayed before the rows as part of the list's scrollable content.


Each kind of row is laid out by a template. Write each template as an <s-pos-list-item> element, compile the templates with posListTemplate, and pass the result to itemTemplates.

Anchor to Import posListTemplateImport posListTemplate

import {posListTemplate} from '@shopify/ui-extensions/point-of-sale';

Call posListTemplate as a tagged template, with one <s-pos-list-item> for each kind of row. Inside it, use the same components as the rest of your extension. Define templates outside your component, at the top level of the file, so they're compiled once instead of on every render.

const itemTemplates = posListTemplate`
<s-pos-list-item templateId="section-header" type="text">
<s-heading>{{title}}</s-heading>
</s-pos-list-item>

<s-pos-list-item templateId="product">
<s-stack direction="inline" gap="base" alignItems="center">
<s-box inlineSize="48px" blockSize="48px">
<s-image bind:src="image.src" alt="Photo of {{name}}" objectFit="cover" />
</s-box>
<s-stack gap="small-200">
<s-text>{{name}}</s-text>
<s-text color="subdued">{{variant}} · {{price}}</s-text>
{{#if onSale}}<s-badge tone="success">Sale</s-badge>{{/if}}
</s-stack>
<s-switch bind:checked="active" label="Available" />
</s-stack>
</s-pos-list-item>
`;

Anchor to List item attributesList item attributes

AttributeRequiredDescription
templateIdYesA unique name for the template. Rows select a template by this name.
typeNobutton (the default): the row is one button and fires rowclick.

text: the row is static content with no button role.

<s-pos-list-item> takes no other attributes, and it must be at the top level of the template, not inside another element.

Anchor to Connect rows to templatesConnect rows to templates

Each row's templateFor is the templateId of the template that renders it, and the row's fields fill the template's placeholders: {{name}} shows the row's name. Every row also needs a unique string id.

const rows = [
{id: 'summer', templateFor: 'section-header', title: 'Summer collection'},
{
id: 'graphic-tee',
templateFor: 'product',
name: 'Graphic tee',
variant: 'Black / Medium',
price: '$29.00',
image: {src: 'placeholder.png'},
onSale: true,
active: true,
},
{
id: 'canvas-tote',
templateFor: 'product',
name: 'Canvas tote',
variant: 'Natural',
price: '$18.00',
image: {src: 'placeholder.png'},
onSale: false,
active: false,
},
];

<s-pos-list rows={rows} itemTemplates={itemTemplates} />
Row idtemplateForRendered by
summersection-header<s-pos-list-item templateId="section-header">
graphic-teeproduct<s-pos-list-item templateId="product">
canvas-toteproduct<s-pos-list-item templateId="product">

Several rows can share a template. A row whose templateFor matches no templateId renders nothing.

A path names a field on the row. Use dots to reach nested fields. Take the graphic-tee row:

{
id: 'graphic-tee',
templateFor: 'product',
name: 'Graphic tee',
variant: 'Black / Medium',
price: '$29.00',
image: {src: 'placeholder.png'},
onSale: true,
active: true,
}
PathValue in this row
name'Graphic tee'
image.src'placeholder.png'
onSaletrue
activetrue

A path that doesn't exist on the row resolves to undefined: text and attributes show it as empty, and {{#if}} hides its content.

Note: Paths can use letters (A–Z and a–z), digits, _, and dots, which is narrower than JavaScript property names. A field such as sale-price, $id, or café can't be read, so name row fields like salePrice.

SyntaxExampleWhat it does
{{path}} in text<s-text>{{name}}</s-text>Shows the field as text: Graphic tee.
{{path}} in an attributealt="Photo of {{name}}"Builds a string from text and fields: "Photo of Graphic tee".

Note: The result is always a string. On a boolean prop, any non-empty string counts as true, so checked="{{active}}" turns the switch on even when active is false. Use bind:checked="active" instead.
bind:prop="path"<s-switch bind:checked="active" />Passes the field's value with its own type: the switch gets true. Use it for booleans, numbers, and arrays.
{{#if path}}…{{/if}}{{#if onSale}}<s-badge tone="success">Sale</s-badge>{{/if}}Shows the content only when the field is truthy: graphic-tee shows the badge and canvas-tote doesn't. Blocks can't nest, and there's no else.

  • An invalid template stops your extension from loading. posListTemplate checks templates when your extension runs, not when you build it, so the build succeeds. When a template is invalid, POS shows a generic "Error loading extension" message, not the problem posListTemplate found. Templates are static, so an invalid template fails every time: after you change a template, open your extension with shopify app dev and fix any error before you release.
  • A row with a non-string id or no templateFor replaces your extension with an error. POS APIs return numeric IDs, so convert them, for example id: product.id.toString(). A row whose id repeats another row's may not render, and POS doesn't report it.
  • Rows without itemTemplates render nothing. If itemTemplates isn't set, the list shows no rows and POS doesn't report an error. The header and loading indicator still render.

Anchor to Display rows with item templatesDisplay rows with item templates

Define a section-header template for static headings and a product template for tappable rows, and keep the rows in state. Tapping a product row fires onRowClick; the handler finds the row from event.detail.item and flips its active field, which updates the switch bound to it. A banner in the header slot tells the POS user what tapping does.

Display rows with item templates

A POS list with an info banner that reads Tap a product to change its availability, a Summer collection heading, and two product rows: Graphic tee, on sale with its availability switch on, and Canvas tote with its switch off.

Display rows with item templates

import {posListTemplate} from '@shopify/ui-extensions/point-of-sale';
import {render} from 'preact';
import {useState} from 'preact/hooks';

const itemTemplates = posListTemplate`
<s-pos-list-item templateId="section-header" type="text">
<s-heading>{{title}}</s-heading>
</s-pos-list-item>

<s-pos-list-item templateId="product">
<s-stack direction="inline" gap="base" alignItems="center">
<s-box inlineSize="48px" blockSize="48px">
<s-image bind:src="image.src" alt="Photo of {{name}}" objectFit="cover" />
</s-box>
<s-stack gap="small-200">
<s-text>{{name}}</s-text>
<s-text color="subdued">{{variant}} · {{price}}</s-text>
{{#if onSale}}<s-badge tone="success">Sale</s-badge>{{/if}}
</s-stack>
<s-switch bind:checked="active" label="Available" />
</s-stack>
</s-pos-list-item>
`;

const initialRows = [
{id: 'summer', templateFor: 'section-header', title: 'Summer collection'},
{
id: 'graphic-tee',
templateFor: 'product',
name: 'Graphic tee',
variant: 'Black / Medium',
price: '$29.00',
image: {src: 'placeholder.png'},
onSale: true,
active: true,
},
{
id: 'canvas-tote',
templateFor: 'product',
name: 'Canvas tote',
variant: 'Natural',
price: '$18.00',
image: {src: 'placeholder.png'},
onSale: false,
active: false,
},
];

function Extension() {
const [rows, setRows] = useState(initialRows);

// The switch can't be tapped on its own: tapping anywhere on the row fires
// onRowClick, so update the row's `active` field to change the switch.
const toggleAvailability = (id: string) =>
setRows((currentRows) =>
currentRows.map((row) =>
row.id === id && row.active !== undefined
? {...row, active: !row.active}
: row,
),
);

return (
<s-pos-list
rows={rows}
itemTemplates={itemTemplates}
onRowClick={(event) => toggleAvailability(event.detail.item.id)}
>
<s-banner
slot="header"
heading="Tap a product to change its availability"
tone="info"
/>
</s-pos-list>
);
}

export default async () => {
render(<Extension />, document.body);
};

Anchor to Load rows incrementallyLoad rows incrementally

POS calls onLoadMore when the POS user scrolls near the end of the list. Append the next page of rows and set loadingMore while it loads, so the list shows a loading indicator. Ignore calls while a page is loading, and stop once every row is loaded. Here a one-second timer stands in for a real request.

Load rows incrementally

A POS list of orders from Order #1007 to Order #1015. Each row has an image, the order number, a blue Odd or green Even badge, and a right chevron. A loading indicator is below the last row.

Load rows incrementally

import {posListTemplate} from '@shopify/ui-extensions/point-of-sale';
import {render} from 'preact';
import {useState} from 'preact/hooks';

const PAGE_SIZE = 16;
const TOTAL_ORDERS = 80;

const itemTemplates = posListTemplate`
<s-pos-list-item templateId="order">
<s-stack direction="inline" justifyContent="space-between" alignItems="center">
<s-stack direction="inline" gap="base" alignItems="center">
<s-box inlineSize="48px" blockSize="48px">
<s-image src="placeholder.png" alt="" objectFit="cover" />
</s-box>
<s-text>{{name}}</s-text>
<s-badge tone="{{badgeTone}}">{{parity}}</s-badge>
</s-stack>
<s-icon type="chevron-right" />
</s-stack>
</s-pos-list-item>
`;

function createOrders(start: number) {
return Array.from({length: PAGE_SIZE}, (_, offset) => {
const index = start + offset;
const orderNumber = 1000 + index;
const even = index % 2 === 0;
return {
id: String(orderNumber),
templateFor: 'order',
name: `Order #${orderNumber}`,
parity: even ? 'Even' : 'Odd',
badgeTone: even ? 'success' : 'info',
};
});
}

function Extension() {
const [rows, setRows] = useState(() => createOrders(0));
const [loadingMore, setLoadingMore] = useState(false);

const loadMore = async () => {
if (loadingMore || rows.length >= TOTAL_ORDERS) return;

setLoadingMore(true);
// Stands in for a real request.
await new Promise((resolve) => setTimeout(resolve, 1000));
setRows([...rows, ...createOrders(rows.length)]);
setLoadingMore(false);
};

return (
<s-pos-list
rows={rows}
itemTemplates={itemTemplates}
loadingMore={loadingMore}
onLoadMore={loadMore}
onRowClick={(event) => {
// Navigate to the tapped order here, using event.detail.item.
}}
/>
);
}

export default async () => {
render(<Extension />, document.body);
};

  • Compile templates once: Define templates outside your component, at the top level of the file, so they're compiled once instead of on every render.
  • Check new templates in POS: TypeScript doesn't check the markup inside a template. Unknown tags are skipped, but their children still render; props a component doesn't declare are ignored; and for props with a fixed set of allowed values, an unsupported value falls back to the prop's default.
  • Use unique string row IDs: Give each row a unique string id that stays the same when you append or update rows. Convert numeric IDs from POS APIs, for example product.id.toString().
  • Put display-ready values in rows: Format dates, prices, and other values as strings before you add them to a row. A Date, Map, Set, or class instance doesn't render as readable text.
  • Keep rows flat: Put the fields a template reads directly on the row, or one level deep, and give them the type the component expects. bind:prop passes values through unchanged, so a string "false" stays truthy.
  • Prefer bind: for non-string props: Use {{path}} for text and string attributes, and bind:prop whenever the component expects a boolean, number, or array.
  • Prevent duplicate loads: Ignore onLoadMore events while a request is already in progress or after you reach the final page.
  • Keep interactions consistent: Every row rendered by a button template is one button. Use type="text" for headings, dividers, totals, and other informational rows so they don't announce as buttons.
  • Place headings based on scroll behavior: Put a page-level heading outside the list. Use the header slot when the heading, a search field, or a banner should scroll with the rows.
  • Don't nest long lists in a scroll box: s-pos-list scrolls on its own. A short list inside s-scroll-box works, but place long or incrementally loaded lists directly on the screen rather than inside another scrolling container.

  • Nothing inside a row is individually interactive: a row is either one button or static content. rowclick fires on the list, with event.currentTarget set to the s-pos-list element and the activated row and its index in event.detail. To change a switch in a row, update the row's data.
  • rowclick and loadmore are element events. An error thrown or a promise rejected in the listener stays in your extension and isn't reported by POS as an extension error, so handle failures in the listener.
  • Templates are static and compiled by posListTemplate. You can't interpolate values into a template or change a template per row; use {{#if}} and separate templates for variations.
  • {{#if}} blocks can't nest and have no else. {{else}} isn't a branch: like any other {{path}}, it reads a row field, here one named else. Precompute derived flags on the row instead.
  • A row's layout uses the same components as the rest of your extension, so a template can't layer or position elements, for example to place a badge over an image.

Was this page helpful?