# UI components

> Applies to `api_version: "2026-01"`.

Worker UI extensions (blocks, `modal`/`fullscreen` actions, `render: "worker"` pages) build their UI only from the `pos-*` elements below. POS renders each element with its own components, so the UI always matches POS.

Iframe pages do not use this list; they use free-form HTML/React and can add `@pancake-pos/ui` to look like POS.

## General rules

- **Only use elements from this list.** Plain HTML tags (`div`, `span`, `img`, `table`, `script`…) and unknown elements are not rendered; the extension logs an error in the app's error log.
- **Text** is only displayed inside `pos-text`, `pos-heading`, `pos-badge`, `pos-link`, `pos-button`, `pos-banner`.
- **No** `style`, `className` or raw HTML. Colors, spacing and fonts follow POS.
- **URLs** (`href`, `src`) must be `https://`.
- **Events** pass values through `event.detail`. In JSX use `onClick`, `onChange`… as with regular components. In plain JS, the event name is the part after `on` in lowercase: `onPageChange` → `addEventListener('pagechange', …)`.
- **Limits**: 2,000 elements per extension; `pos-table` renders at most 500 rows at a time (use pagination).
- Array/object properties (`options`, `columns`, `rows`, `pagination`) are passed directly in JSX; no JSON.stringify needed.

```tsx
<pos-stack gap="small">
  <pos-text-field label="Ticket code" value={code} onChange={(e) => setCode(e.detail)} />
  <pos-button variant="primary" loading={saving} onClick={save}>Save</pos-button>
</pos-stack>
```

Without a framework, build the tree with the runtime's DOM API (see [Worker environment](/developers/apps/en/runtime-api#worker-environment)):

```js
let code = '';

const field = document.createElement('pos-text-field');
field.label = 'Ticket code';
field.addEventListener('change', (e) => { code = e.detail; });

const button = document.createElement('pos-button');
button.variant = 'primary';
button.textContent = 'Save';
button.addEventListener('click', () => save(code));

const stack = document.createElement('pos-stack');
stack.gap = 'small';
stack.append(field, button);
document.body.append(stack);
```

## Layout

### pos-stack

Stacks children vertically or horizontally.

| Property | Type | Default | Description |
|---|---|---|---|
| `direction` | `'vertical' \| 'horizontal'` | `'vertical'` | Stacking direction |
| `gap` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Space between children |
| `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | `'stretch'` | Cross-axis alignment |
| `justify` | `'start' \| 'center' \| 'end' \| 'space-between'` | `'start'` | Main-axis alignment |
| `wrap` | `boolean` | `false` | Allow wrapping (with `horizontal`) |

### pos-grid

A grid of equal columns; collapses to one column on narrow screens.

| Property | Type | Default | Description |
|---|---|---|---|
| `columns` | `1–6` | `2` | Number of columns |
| `gap` | `'none' \| 'small' \| 'medium' \| 'large'` | `'medium'` | Spacing |

### pos-section

A titled container, typically the outer shell of a block.

| Property | Type | Description |
|---|---|---|
| `heading` | `string` | Title |
| `description` | `string` | Short text below the title |

Slot `actions`: buttons shown at the right of the title.

```tsx
<pos-section heading="Customer debt">
  <pos-button slot="actions" variant="tertiary" onClick={reload}>Reload</pos-button>
  <pos-text>...</pos-text>
</pos-section>
```

### pos-divider

A horizontal rule. No properties.

## Display

### pos-text

| Property | Type | Default |
|---|---|---|
| `tone` | `'default' \| 'subdued' \| 'success' \| 'warning' \| 'critical'` | `'default'` |
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` |
| `weight` | `'regular' \| 'semibold' \| 'bold'` | `'regular'` |

### pos-heading

| Property | Type | Default |
|---|---|---|
| `level` | `1–4` | `3` |

### pos-badge

A short status label.

| Property | Type | Default |
|---|---|---|
| `tone` | `'default' \| 'info' \| 'success' \| 'warning' \| 'critical'` | `'default'` |

### pos-money

Displays an amount using the shop's currency and number format.

| Property | Type | Description |
|---|---|---|
| `value` | `number` | Amount |
| `currency` | `string` | ISO 4217 code; defaults to the shop currency |
| `tone` | same as `pos-text` | Text color |

### pos-link

| Property | Type | Description |
|---|---|---|
| `href` | `string` | `https://` URL, always opens in a new tab |

`onClick` event: use it when the link performs an action in the app instead of opening a URL (omit `href`).

### pos-image

| Property | Type | Default | Description |
|---|---|---|---|
| `src` | `string` | | An `https://` URL on a domain listed in `network.allowed_origins`, or `public/<file name>` for a file in the project's `public/` folder |
| `alt` | `string` | | Alternative text |
| `width`, `height` | `number` | | Size (px) |
| `fit` | `'cover' \| 'contain'` | `'cover'` | How the image fits |

POS loads images through a proxy on the sandbox: image types only, up to 5 MB. Images from undeclared domains are not shown (development mode logs a warning).

## Inputs

Properties shared by every input:

| Property | Type | Description |
|---|---|---|
| `label` | `string` | Label |
| `value` | depends on the component | Current value (controlled component) |
| `disabled` | `boolean` | Disable the input |
| `required` | `boolean` | Show the required marker (does not validate) |
| `error` | `string` | Error message below the input |
| `help` | `string` | Hint below the input |

Shared events: `onChange` (`event.detail` is the new value), `onBlur`.

POS shows what the user types immediately and then sends `onChange` to the app, so inputs never lag while the app is busy. If the app sets a `value` different from what the user just entered (for example uppercasing it or clearing the field), POS shows the app's value. Set `value` directly in `onChange`; changing it after an `await` may overwrite what the user is typing.

### pos-text-field

| Property | Type | Description |
|---|---|---|
| `value` | `string` | |
| `placeholder` | `string` | |
| `multiline` | `boolean \| number` | Multi-line input; a number sets the visible rows |
| `maxLength` | `number` | Character limit |

### pos-number-field

| Property | Type | Description |
|---|---|---|
| `value` | `number \| null` | |
| `min`, `max`, `step` | `number` | |
| `suffix` | `string` | Unit shown after the number, for example `"%"` |

### pos-select

| Property | Type | Description |
|---|---|---|
| `options` | `{ label: string; value: string; disabled?: boolean }[]` | Choices |
| `value` | `string \| string[]` | An array with `multiple` |
| `multiple` | `boolean` | Multiple selection |
| `placeholder` | `string` | |
| `searchable` | `boolean` | Allow typing to filter |

### pos-checkbox

| Property | Type | Description |
|---|---|---|
| `checked` | `boolean` | |
| `label` | `string` | |

`onChange`: `event.detail` is a `boolean`.

### pos-date-picker

| Property | Type | Description |
|---|---|---|
| `value` | `string \| null` | Date as `YYYY-MM-DD` |
| `min`, `max` | `string` | Selectable range, as `YYYY-MM-DD` |

## Feedback

### pos-banner

A prominent message inside the extension.

| Property | Type | Default | Description |
|---|---|---|---|
| `tone` | `'info' \| 'success' \| 'warning' \| 'critical'` | `'info'` | |
| `heading` | `string` | | Title |
| `dismissible` | `boolean` | `false` | Show a close button |

`onDismiss` event.

### pos-spinner

| Property | Type | Default |
|---|---|---|
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` |

## Data and actions

### pos-button

| Property | Type | Default | Description |
|---|---|---|---|
| `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'danger'` | `'secondary'` | Button style |
| `size` | `'small' \| 'medium'` | `'medium'` | |
| `loading` | `boolean` | `false` | Shows a busy state and blocks clicks |
| `disabled` | `boolean` | `false` | |

`onClick` event.

### pos-table

A data table. Cells only hold values (strings, numbers); POS formats them according to the column `format`.

| Property | Type | Default | Description |
|---|---|---|---|
| `columns` | `Column[]` | | Column definitions |
| `rows` | `object[]` | `[]` | Data, up to 500 rows |
| `rowKey` | `string` | `'id'` | Field used as the row key |
| `loading` | `boolean` | `false` | |
| `pagination` | `{ page: number; pageSize: number; total: number } \| null` | `null` | App-side pagination |
| `emptyText` | `string` | | Content when there are no rows |

```ts
type Column = {
  key: string;                       // field name in the row
  title: string;
  align?: 'left' | 'center' | 'right';
  width?: number;                    // px
  format?: 'text' | 'number' | 'money' | 'date' | 'datetime';
};
```

Events:

- `onPageChange`: `event.detail` is `{ page, pageSize }`.
- `onRowClick`: `event.detail` is the clicked row.

```tsx
<pos-table
  columns={[
    { key: 'system_id', title: 'Order' },
    { key: 'total_price', title: 'Total', format: 'money', align: 'right' },
  ]}
  rows={orders}
  rowKey="system_id"
  pagination={{ page, pageSize: 20, total }}
  onPageChange={(e) => setPage(e.detail.page)}
/>
```

### pos-modal

A dialog opened from inside an extension (for example from a button in a block).

| Property | Type | Description |
|---|---|---|
| `open` | `boolean` | Open state |
| `heading` | `string` | Title |

Slot `footer` for the dialog buttons. The `onClose` event fires when the user closes it or clicks outside.

```tsx
<pos-modal open={open} heading="Confirm" onClose={() => setOpen(false)}>
  <pos-text>Push this order to the ERP?</pos-text>
  <pos-button slot="footer" onClick={() => setOpen(false)}>Cancel</pos-button>
  <pos-button slot="footer" variant="primary" onClick={push}>Push</pos-button>
</pos-modal>
```

Actions with `presentation: "modal"` already get a POS modal; do not wrap them in `pos-modal`.

## Planned additions

`pos-page`, `pos-tabs`, `pos-filter-bar`, `pos-chart` (POS draws the chart, the app only passes data), for full-screen Worker UI pages.
