Apps
App UI kit
App UI kit
The Eekaam UI kit is a set of web components that look and behave like the merchant admin. Build your embedded app with them and it reads as part of Eekaam, not as a website pasted into a frame.
The kit is one script, ui.js, served from the Eekaam CDN. It registers every <e-*> element, puts the admin's design tokens on the page, follows the admin's light or dark scheme, and exposes the App Bridge functions on window.eekaam.
Why a kit
An app runs in an iframe on its own origin. Anything it draws is its own, so without the kit every app picks its own colours, spacing and buttons, and each one drifts further from the admin around it.
The kit fixes that in two ways:
- It looks native by default. The components read the same tokens the dashboard reads — colour, radius, spacing, type, shadow. A page built from them looks like an admin page.
- Eekaam owns how it looks. Your app loads
ui.jsfrom the CDN rather than bundling it. When the admin's design changes, the kit changes with it, and your app picks the change up on the next page load. You do not redeploy.
Using the kit is also the first rule apps are reviewed against — see ui.kit in the App review guidelines.
Load ui.js
Add one script tag to the <head> of every page your app serves inside the admin:
Then use the elements anywhere in the page:
Loading the script twice is harmless: each element is registered only once, and window.eekaam is only set if it is not there already.
<script type="module" src="https://themes-cdn.eekaam.com/ui/v1/ui.js"></script><e-page heading="Payments">
<e-button slot="primary-action">Save</e-button>
<e-card heading="Provider">
<e-text-field name="name" label="Display name" required></e-text-field>
</e-card>
</e-page>Versions
| URL | What it serves | Cache |
|---|---|---|
https://themes-cdn.eekaam.com/ui/v1/ui.js | The latest 1.x build. Apps load this. | 5 minutes |
https://themes-cdn.eekaam.com/ui/<x.y.z>/ui.js | One exact build, for example /ui/1.0.0/ui.js. It never changes. | Immutable |
Inside v1, properties, attributes, events, slots and parts are only ever added. Nothing is renamed or removed. A change that would break an app ships as /ui/v2/, and /ui/v1/ keeps working.
Pin an exact version only to debug or to roll back for a short while. A pinned app stops receiving design updates, and it will slowly stop looking like the admin.
window.eekaam.version tells you which build the page is running.
Working on the kit locally
Apps scaffolded with eekaam app init read the script URL from EEKAAM_UI_URL, falling back to the CDN. Point it at a kit you are building locally:
You only need this if you are changing the kit itself. Leave it unset in production. If you write your own <head>, do the same: read the URL from configuration, and default to the CDN.
# in the kit repo: build dist/ui.js and serve it with CORS
pnpm --filter @eekaam/ui build
npx serve --cors -l 5050 frontend/packages/ui/dist
# in your app
EEKAAM_UI_URL=http://localhost:5050/ui.js npm run devReact
@eekaam/ui-react gives you typed React components over the same elements.
Keep the script tag. The wrappers contain no element code and no styles. They are types and glue: every prop is set on the element as a property, and every on* handler is attached with addEventListener. The elements themselves still come from ui.js. Updating the npm package improves your types; it never changes how your app looks.
A few things differ from plain HTML:
- Props use the property name, in camelCase:
helpText,backHref,fullWidth,accessibilityLabel. Not the kebab-case attribute. - Array and object props work.
columns,rows,options,itemsandtabsare passed as real values, not strings. - Events are
onprops. Each component lists its handlers in the reference below. Custom events arrive as aCustomEvent, with the data inevent.detail. className,style,id,slot,hidden,tabIndex,aria-*anddata-*go straight to the element as attributes.refis the element. Use it to call methods such asrequestClose()orreportValidity().
It does not matter whether ui.js finishes loading before or after React's first render. Properties set before the element upgrades are kept.
If you cannot add a tag to <head>, call loadEekaamUI() once on the client. It injects the script, and resolves when it has loaded:
The package also re-exports the bridge functions (toast, navigate, setTitle, sessionToken, isEmbedded, onSchemeChange, setAppLocation, openAdmin) and the kit's data types (AdminIntent, ComboboxOption, DataTableColumn, DataTableRow, MenuItem, SelectOption, TabItem, Tone).
npm install @eekaam/ui-reactimport { Page, Card, Button, toast } from "@eekaam/ui-react";
export default function Settings() {
return (
<Page heading="Settings" width="narrow">
<Card heading="Status">
<Button onClick={() => toast("Connected", "success")}>Connect</Button>
</Card>
</Page>
);
}import { loadEekaamUI, UI_SCRIPT_URL } from "@eekaam/ui-react";
useEffect(() => {
loadEekaamUI(); // defaults to UI_SCRIPT_URL
}, []);Plain HTML and Liquid
The kit needs no bundler. Server-rendered pages — plain HTML, Liquid, PHP, Rails views — use the elements as tags and set the rest from a <script>:
window.eekaam is set as soon as ui.js runs. It holds version, toast, navigate, setTitle, sessionToken, isEmbedded, onSchemeChange, setAppLocation and openAdmin — the same functions described in App Bridge.
<e-page heading="Orders">
<e-card padding="none">
<e-data-table id="orders" accessibility-label="Orders"></e-data-table>
</e-card>
</e-page>
<script type="module">
const table = document.getElementById("orders");
table.columns = [
{ key: "name", header: "Order" },
{ key: "total", header: "Total", numeric: true },
];
table.rows = await (await fetch("/api/orders")).json();
table.addEventListener("e-page", (event) => loadPage(event.detail.page));
window.eekaam.toast("Orders loaded");
</script>Conventions
Every component follows the same rules, so once you know one you know how the rest behave.
Attributes and properties
Tags start with e-. Attributes are kebab-case, properties are camelCase: the help-text attribute is the helpText property.
Strings, numbers and booleans work as either. A boolean attribute is on when present (<e-button disabled>) and off when absent — disabled="false" still means disabled.
Arrays and objects — columns, rows, options, items, tabs, files, selectedKeys — are properties only. Set them from JavaScript, or pass them as React props.
Attributes marked reflects in the tables below are written back to the element when the property changes, so you can style on them: e-button[loading].
Events
- Form controls dispatch
inputandchange, like native inputs. Both bubble and cross shadow roots. - Richer components dispatch
e-*custom events with a typeddetail:e-sort,e-page,e-select,e-files,e-dismiss,e-actionand others listed per component. - All kit events bubble and are composed, so a listener on any ancestor hears them.
- Events marked cancelable can be refused with
event.preventDefault(). The component then keeps its current state — an<e-tabs>stays on its tab, an<e-modal>stays open.
Forms and FormData
Form controls are real form controls. They work inside a plain <form>:
- They appear in
new FormData(form)under theirname. requiredblocks submit while empty, and the browser shows its own message.errormarks the control invalid with your message, and shows it under the field.form.reset()puts each control back to the value it started with.- A disabled
<fieldset>disables them. <e-button type="submit">submits the form it sits in;type="reset"resets it.
Every form control shares these attributes:
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
name | name | string | "" | Key in FormData |
value | value | string | "" | The current value |
label | label | string | "" | Visible label |
label-hidden | labelHidden | boolean | false | Hides the label visually, keeps it for screen readers |
help-text | helpText | string | "" | Hint under the control |
error | error | string | "" | Error message; makes the control invalid |
placeholder | placeholder | string | "" | |
required | required | boolean | false | Reflects |
disabled | disabled | boolean | false | Reflects |
Each also has form (the owning form), checkValidity(), reportValidity() and focus(), and exports the parts label, help-text and error.
A form with unsaved changes should use <e-save-bar>, so the admin stops the merchant leaving by accident. See e-save-bar.
<form id="settings" method="post">
<e-text-field name="title" label="Title" required></e-text-field>
<e-switch name="enabled" label="Enabled" checked></e-switch>
<e-button type="submit">Save</e-button>
</form>Tones, variants and sizes
The same words mean the same thing on every component that takes them:
| Attribute | Values |
|---|---|
variant | primary · secondary · tertiary · plain · critical |
tone | neutral · info · success · warning · critical · attention |
size | sm · md · lg |
Not every component takes every value. The reference lists what each one accepts.
Customising with ::part()
Component styles live in shadow DOM, and they only read the kit's token variables. To adjust a component, style the parts it exports with ::part():
Use the kit's variables (--e-space-4, --e-muted, --e-radius-md and so on) rather than your own colours, or your change will look wrong in dark mode and after the next design update. Never reach into a shadow root: its structure is not API and can change in any release.
e-card.highlight::part(header) {
background: var(--e-muted);
}
e-data-table::part(cell) {
vertical-align: top;
}Dark mode
Inside the admin, ui.js asks which scheme the merchant is using and follows it, including when the merchant switches while your app is open. Until the admin answers — or if an older admin never does — the kit stays light, like the admin around it. Outside the admin, it follows the operating system's prefers-color-scheme.
The scheme is set as data-e-scheme="light" or "dark" on <html>. Your own CSS can key off it, or you can listen with onSchemeChange():
The kit also sets a background, text colour and font on <body>. They sit under :where(), so any rule you write wins.
:root[data-e-scheme="dark"] .chart {
filter: invert(1);
}Layout
e-page
The frame for one screen. width="narrow" suits settings and forms; full gives tables room. Inside the admin, heading is also sent up as the admin's title bar, so the two never disagree.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
heading | heading | string | "" | Page title; also sets the admin title bar |
subtitle | subtitle | string | "" | |
back-href | backHref | string | "" | Shows a back arrow linking here |
width | width | default | narrow | full | default | Reflects |
keep-title | keepTitle | boolean | false | Leave the admin's title bar alone |
Events: e-back — the back arrow was clicked. Cancelable: prevent it to route client-side instead of following back-href. React: onBack.
Slots: default (content), primary-action, secondary-actions, title-metadata (beside the heading, e.g. a badge), aside (a side column).
Parts: page, header.
<e-page heading="Payments" subtitle="How customers pay you" back-href="/settings">
<e-badge slot="title-metadata" tone="success">Active</e-badge>
<e-button slot="secondary-actions" variant="secondary">Export</e-button>
<e-button slot="primary-action">Save</e-button>
<e-card heading="Provider">…</e-card>
<div slot="aside">…</div>
</e-page>e-card
A bounded group of related things. padding="none" lets a table run to the card's edges. subdued is for secondary information.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
heading | heading | string | "" | |
padding | padding | default | none | default | Reflects |
subdued | subdued | boolean | false | Reflects |
Slots: default, actions (in the header), footer.
Parts: header, body, footer.
<e-card heading="Shipping">
<e-button slot="actions" variant="plain">Edit</e-button>
Ships from Lahore.
<div slot="footer">Last changed yesterday</div>
</e-card>e-stack
Puts children in a column or a row with the kit's spacing between them, so you never need your own margins. Children stay in the light DOM and keep your styles.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
direction | direction | vertical | horizontal | vertical | Reflects |
gap | gap | 0 1 2 3 4 5 6 8 | 4 | Steps on the spacing scale. Reflects |
align-items | alignItems | start | center | end | stretch | baseline | unset (stretch) | Reflects |
justify-content | justifyContent | start | center | end | space-between | unset | Reflects |
wrap | wrap | boolean | false | Reflects |
Slots: default. Parts: none — style the element itself.
<e-stack direction="horizontal" align-items="center" justify-content="space-between" wrap>
<e-badge>Draft</e-badge>
<e-button>Publish</e-button>
</e-stack>e-grid
Equal columns. columns is a fixed count that drops to one column when the grid itself is narrower than 40rem — measured on the grid, not the window. min-column-width instead fits as many columns as the width allows.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
columns | columns | number | 2 | |
min-column-width | minColumnWidth | CSS length | "" | e.g. 16rem. Wins over columns |
gap | gap | 0 1 2 3 4 5 6 8 | 4 | Reflects |
Slots: default. Parts: grid.
<e-grid min-column-width="16rem" gap="6">
<e-card heading="Orders">…</e-card>
<e-card heading="Revenue">…</e-card>
<e-card heading="Visitors">…</e-card>
</e-grid>e-divider
A hairline between groups. It is announced as a separator; a line that is only decoration should be a border instead.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
orientation | orientation | horizontal | vertical | horizontal | Reflects |
Slots: none. Parts: none.
<e-divider></e-divider>
<e-stack direction="horizontal"><span>A</span><e-divider orientation="vertical"></e-divider><span>B</span></e-stack>Actions
e-link
A link inside running text. admin-path moves the admin rather than your frame: the click is handed to the bridge as navigate(). external opens a new tab and says so, to sighted and screen-reader users alike.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
href | href | string | "" | |
target | target | string | "" | |
admin-path | adminPath | string | "" | An admin path such as /orders/123. Used instead of href |
external | external | boolean | false | New tab, with an icon. Reflects |
monochrome | monochrome | boolean | false | Inherits the text colour. Reflects |
remove-underline | removeUnderline | boolean | false | Reflects |
Events: click (native). React: onClick.
Slots: default. Parts: link.
<p>Stock comes from <e-link admin-path="/products">your products</e-link>.</p>
<e-link href="https://help.example.com" external>Help centre</e-link>Form controls
All of these share the form-control attributes, events and parts listed under Forms and FormData. Each fires input and change; in React, onInput and onChange.
e-text-field
A single-line input. input fires on every keystroke, change when the merchant is done.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
type | type | text | email | number | password | search | tel | url | text | |
prefix | prefix | string | "" | Text inside the field, before the value, e.g. Rs. |
suffix | suffix | string | "" | Text after the value, e.g. kg |
autocomplete | autocomplete | string | "" | |
minlength | minlength | number | — | |
maxlength | maxlength | number | — | |
min · max · step | same | string | "" | For type="number" |
pattern | pattern | string | "" | |
readonly | readonly | boolean | false |
Parts: input, prefix, suffix, plus the shared ones.
<e-text-field name="price" type="number" label="Price" prefix="Rs." min="0" step="1" required></e-text-field>e-text-area
Multi-line text. With max-rows it grows with its content from rows up to that many lines, then scrolls. With maxlength it shows a character count.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
rows | rows | number | 3 | |
max-rows | maxRows | number | — | Grow up to this many lines. Reflects |
minlength | minlength | number | — | |
maxlength | maxlength | number | — | Also shows used/max |
autocomplete | autocomplete | string | "" | |
readonly | readonly | boolean | false |
Parts: textarea, count, plus the shared ones.
<e-text-area name="note" label="Note" max-rows="8" maxlength="280"></e-text-area>e-select
A dropdown built on a native <select>, so the picker is the platform's own. Options come from the options property when it is set, otherwise from <option> children. Without a placeholder, the value is the first enabled option — what the merchant sees is what submits.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
| — | options | SelectOption[] | — | { label, value, disabled? }. Wins over <option> children |
Slots: <option> children are read as options; they are not displayed as-is.
Parts: select, plus the shared ones.
<e-select name="status" label="Status" placeholder="Choose a status">
<option value="active">Active</option>
<option value="draft">Draft</option>
</e-select>e-checkbox
A checkbox that submits value (default "on") only while checked. required means it must be ticked. Pass label, or put the label in the element when it needs markup.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
checked | checked | boolean | false | Reflects |
indeterminate | indeterminate | boolean | false | Neither on nor off, e.g. "select all". Cleared by a click. Reflects |
value | value | string | "on" | Submitted while checked |
Slots: default — the label, when label is not set.
Parts: checkbox, plus the shared ones.
<e-checkbox name="gift" value="yes" checked>Wrap as a gift</e-checkbox>
<e-checkbox name="terms" label="I accept the terms" required></e-checkbox>e-switch
An on/off setting. It submits like a checkbox, but screen readers call it a switch and it draws as a track. Use a switch for a setting that takes effect as it is flipped; use <e-checkbox> for a choice applied when a form is saved.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
checked | checked | boolean | false | Reflects |
value | value | string | "on" | Submitted while on |
Slots: default — the label, when label is not set.
Parts: switch, thumb, plus the shared ones.
<e-switch name="published" label="Visible on the storefront" checked></e-switch>e-date-picker
A date field whose value is always YYYY-MM-DD, whatever the merchant's locale. The merchant can type the date or open the calendar. Days outside min/max cannot be picked, and a typed date outside them makes the field invalid.
Calendar keys: arrows move a day or a week, Home/End go to the start or end of the week, PageUp/PageDown change month (with Shift, year), Enter picks, Escape closes. Alt+ArrowDown in the field opens the calendar.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
min | min | YYYY-MM-DD | "" | Earliest pickable date |
max | max | YYYY-MM-DD | "" | Latest pickable date |
readonly | readonly | boolean | false | |
locale | locale | string | browser's | For month and day names |
Methods: show() opens the calendar; close(refocus?) closes it.
Parts: input, toggle, calendar, day, plus the shared ones.
<e-date-picker name="starts_on" label="Start date" min="2026-01-01"></e-date-picker>e-combobox
A text input that narrows a list as the merchant types. The value is the chosen option's value. With allow-custom, typed text that matches no option stands as the value.
For options that live on your server, set remote: typing fires e-search (debounced by 200ms), you set loading while you fetch, then set options. With remote the list is shown exactly as given. Without remote, the kit filters options by label — and still fires e-search while options is empty.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
| — | options | ComboboxOption[] | [] | { label, value, disabled? } |
allow-custom | allowCustom | boolean | false | Typed text can be the value |
remote | remote | boolean | false | Always fire e-search; never filter locally |
loading | loading | boolean | false | Shows a loading row. Reflects |
empty-text | emptyText | string | "No results" | |
autocomplete | autocomplete | string | "off" |
Events:
| Event | Detail | React |
|---|---|---|
e-select | { value, label } — an option was chosen | onSelect |
e-search | { query } — fetch matching options | onSearch |
change | — the value changed | onChange |
input | — typed text became the value (with allow-custom, or when cleared) | onInput |
Methods: selectOption(option).
Parts: input, toggle, listbox, option, loading, empty, plus the shared ones.
<e-combobox id="customer" name="customer" label="Customer" remote></e-combobox>
<script type="module">
const box = document.getElementById("customer");
box.addEventListener("e-search", async (event) => {
box.loading = true;
box.options = await searchCustomers(event.detail.query);
box.loading = false;
});
</script>e-drop-zone
Drag files onto it or click to browse. Chosen files are listed with a remove button each, and submit with the form: each file is appended to FormData under name. Files that fail accept or max-size are turned away with e-reject, so you can say why.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
accept | accept | string | "" | As for <input type="file">: image/*, .csv, application/pdf |
multiple | multiple | boolean | false | Without it, a new file replaces the current one |
max-size | maxSize | number | — | Largest file, in bytes |
action-label | actionLabel | string | "Add files" | Browse button text |
| — | files | File[] | [] | The accepted files. Replace the array to change it |
Events:
| Event | Detail | React |
|---|---|---|
e-files | { files } — the full list after a change | onFiles |
e-reject | { files, reason } — reason is type, size or count | onReject |
change | — | onChange |
Methods: addFiles(files), removeFile(index).
Slots: default — replaces the "Drop files here" prompt.
Parts: zone, browse, file-list, file, remove, plus the shared ones.
<e-drop-zone name="images" label="Images" accept="image/*" multiple max-size="5000000">
Drop product photos here
</e-drop-zone>Feedback
e-badge
A small piece of state: a status, a count, a label. progress adds the admin's filled, half or empty dot for order-style statuses.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
tone | tone | neutral | info | success | warning | critical | attention | neutral | Reflects |
size | size | md | lg | md | Reflects |
progress | progress | incomplete | partial | complete | "" |
Slots: default. Parts: none.
<e-badge tone="success">Paid</e-badge>
<e-badge tone="warning" progress="partial">Partially fulfilled</e-badge>e-spinner
For a wait with no shape. When you know the shape of what is coming, use <e-skeleton> — it keeps the layout from jumping.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
size | size | sm | md | lg | md | Reflects |
accessibility-label | accessibilityLabel | string | "Loading" | Say what is loading |
Slots: none. Parts: none.
<e-spinner size="lg" accessibility-label="Loading orders"></e-spinner>e-skeleton
The shape of content on its way. Skeletons are hidden from assistive technology; mark the loading container with aria-busy="true" once instead.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
variant | variant | text | heading | block | circle | thumbnail | text | Reflects |
lines | lines | number | 1 | For text. The last line is shorter |
width | width | CSS length | "" | |
height | height | CSS length | "" |
Slots: none. Parts: skeleton.
<div aria-busy="true">
<e-skeleton variant="heading"></e-skeleton>
<e-skeleton lines="3"></e-skeleton>
</div>e-progress-bar
How far along a task is. Always give it a label — it is the only thing a screen reader can announce.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
value | value | number | 0 | 0–100, clamped |
label | label | string | "" | Accessible name |
tone | tone | default | success | critical | default | Reflects |
size | size | sm | md | md | Reflects |
indeterminate | indeterminate | boolean | false | For a task of unknown length. Reflects |
Slots: none. Parts: track, fill.
<e-progress-bar value="40" label="Import progress"></e-progress-bar>e-empty-state
What a page or table shows before there is anything in it. Say what will appear here, and offer the one action that makes it appear. The image is decoration; the heading carries the meaning.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
heading | heading | string | "" | |
image | image | URL | "" | Decorative illustration |
Slots: default (description), primary-action, secondary-action.
Parts: empty-state, image, heading, description, actions.
<e-empty-state heading="No discounts yet">
Create a discount code to reward loyal customers.
<e-button slot="primary-action">Create discount</e-button>
<e-link slot="secondary-action" href="https://docs.example.com" external>Learn more</e-link>
</e-empty-state>Data
e-data-table
A list of records the merchant scans, sorts, selects and pages through. The table never sorts or pages by itself: your data lives on your server, so the table reports what the merchant asked for (e-sort, e-page) and shows whatever rows you give it next. Selection is kept by row key, so it survives paging. Put it in <e-card padding="none">.
Columns are objects:
| Field | Type | Notes |
|---|---|---|
key | string | The row field shown, and the key e-sort reports |
header | string | |
numeric | boolean | Right-aligned, tabular figures |
sortable | boolean | |
width | CSS length | e.g. 8rem or 20% |
render(row) | function | Custom cell content: a string, number, DOM node or lit template. Not JSX |
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
| — | columns | DataTableColumn[] | [] | |
| — | rows | DataTableRow[] | [] | |
row-key | rowKey | string | "id" | The field that identifies a row |
accessibility-label | accessibilityLabel | string | "" | The table's name |
selectable | selectable | boolean | false | Checkbox column. Reflects |
| — | selectedKeys | string[] | [] | |
loading | loading | boolean | false | Skeleton rows. Reflects |
clickable-rows | clickableRows | boolean | false | Rows fire e-row-click. Reflects |
sort-key | sortKey | string | "" | The column the rows are sorted by |
sort-direction | sortDirection | asc | desc | asc | |
empty-heading | emptyHeading | string | "No items found" | |
page | page | number | 1 | 1-based |
page-size | pageSize | number | 25 | |
total | total | number | — | Shows "Showing a–b of total" |
has-next · has-previous | hasNext · hasPrevious | boolean | false | For cursor pagination, when the total is unknown |
Events:
| Event | Detail | React |
|---|---|---|
e-sort | { key, direction } | onSort |
e-page | { page } | onPageChange |
e-selection-change | { keys } | onSelectionChange |
e-row-click | { row, key } — not fired for clicks on links, buttons or inputs inside the row | onRowClick |
Slots: bulk-actions (shown while rows are selected), empty (replaces the default empty state).
Parts: table, scroll, header-row, header-cell, row, cell, bulk-actions, empty, footer.
<e-card padding="none">
<e-data-table id="orders" selectable clickable-rows sort-key="created" sort-direction="desc"
page="1" page-size="25" total="112" accessibility-label="Orders">
<e-button slot="bulk-actions" variant="secondary" size="sm">Archive</e-button>
<e-empty-state slot="empty" heading="No orders yet"></e-empty-state>
</e-data-table>
</e-card>
<script type="module">
const table = document.getElementById("orders");
table.columns = [
{ key: "name", header: "Order", sortable: true },
{ key: "created", header: "Date", sortable: true },
{ key: "total", header: "Total", numeric: true },
];
table.rows = orders;
table.addEventListener("e-sort", (event) => refetch(event.detail));
table.addEventListener("e-row-click", (event) => open(event.detail.key));
</script>e-thumbnail
A small square image of a product, file or collection. A missing or broken image shows a same-sized placeholder, so rows stay aligned. Set alt="" when the text beside it already says what it is.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
src | src | URL | "" | |
alt | alt | string | "" | |
size | size | xs | sm | md | lg | md | Reflects |
Slots: none. Parts: thumbnail, image, placeholder.
<e-thumbnail src="https://cdn.example.com/hat.jpg" alt="Black wool hat" size="sm"></e-thumbnail>e-avatar
A person or a business. Without a picture — or when it fails to load — it shows initials on a colour picked from the name, so the same customer gets the same colour everywhere.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
name | name | string | "" | Accessible name and initials |
src | src | URL | "" | |
size | size | xs | sm | md | lg | md | Reflects |
shape | shape | round | square | round | Reflects |
initials (read-only) returns what the avatar shows without a picture.
Slots: none. Parts: avatar, image.
<e-avatar name="Farah Khan" size="sm"></e-avatar>e-tabs
Switches between views of the same thing. Tabs are data; give each view slot equal to its tab's id, and only the selected one is shown. With no panel children the tabs are just a bar, and you swap content yourself. Arrow keys move and select at once.
TabItem is { id, label, badge?, disabled? }. With nothing selected, the first enabled tab is — without an event.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
| — | tabs | TabItem[] | [] | |
selected | selected | string | "" | The selected tab's id. Reflects |
fitted | fitted | boolean | false | Tabs share the full width. Reflects |
accessibility-label | accessibilityLabel | string | "" | Names the tab list |
Events: e-select — { id }. Cancelable: prevent it to stay on the current tab, e.g. while there are unsaved changes. React: onSelect.
Slots: one named slot per tab id.
Parts: tablist, tab, tab-selected, panel.
<e-tabs id="status" selected="all" accessibility-label="Order status">
<div slot="all">…</div>
<div slot="unfulfilled">…</div>
</e-tabs>
<script type="module">
document.getElementById("status").tabs = [
{ id: "all", label: "All" },
{ id: "unfulfilled", label: "Unfulfilled", badge: 3 },
];
</script>e-popover
A panel anchored to a trigger. Clicking the trigger toggles it; clicking outside or pressing Escape closes it. The panel is drawn in the browser's top layer, so a card's overflow: hidden does not clip it. It is still inside your frame.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
open | open | boolean | false | Reflects |
placement | placement | bottom-start | bottom-end | top-start | top-end | bottom-start | Flips when there is no room. Reflects |
Events: e-open, e-close. React: onOpen, onClose.
Methods: reposition() — call it after the panel's content changes size.
Slots: trigger, default (panel content).
Parts: panel.
<e-popover placement="bottom-end">
<e-button slot="trigger" variant="secondary">Filters</e-button>
<e-stack gap="2">…</e-stack>
</e-popover>e-tooltip
A short hint for the element it wraps, shown after a moment on hover and at once on keyboard focus. Escape hides it. Never put anything interactive or essential in a tooltip: touch screens have no hover.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
content | content | string | "" | The hint text |
placement | placement | top | bottom | top | Reflects |
Methods: show(), hide().
Slots: default — the element the hint describes. Parts: tooltip.
<e-tooltip content="Shown to buyers at checkout">
<e-button variant="tertiary" accessibility-label="Help">?</e-button>
</e-tooltip>e-modal
A dialog that blocks the page until it is dealt with. Escape, the close button and a backdrop click all fire a cancelable e-request-close; prevent it to keep the modal open, for example while saving.
Inside the admin, a text-only modal is drawn by the admin itself, over the whole window rather than inside your frame. That happens when all of these hold:
- the app is embedded in the admin,
- the body is plain text: the only child elements are
<e-button>s inprimary-actionandsecondary-action, with at most two secondary, in-frameandloadingare not set, andsizeis notfullscreen.
The first <e-button> in primary-action and the <e-button>s in secondary-action become the admin's buttons, and pressing one clicks your <e-button> — your click listeners work unchanged. Anything else — rich content, a footer, a standalone page, an admin that does not answer — draws inside your frame, with the same API. If an admin-drawn modal later gains content the admin cannot draw, it moves into your frame. App Bridge has the details.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
open | open | boolean | false | Reflects |
heading | heading | string | "" | |
size | size | sm | md | lg | fullscreen | md | fullscreen is always drawn in-frame. Reflects |
loading | loading | boolean | false | Covers the body with a spinner; drawn in-frame. Reflects |
in-frame | inFrame | boolean | false | Always draw inside the app's frame. Reflects |
Events:
| Event | Detail | React |
|---|---|---|
e-request-close | { reason } — escape, backdrop or close-button. Cancelable | onRequestClose |
e-open | — the modal is showing (for an admin-drawn one, once the admin has confirmed) | onOpen |
e-close | — | onClose |
Methods: requestClose(reason?) — asks to close, as Escape would. Returns false if a listener prevented it.
Slots: default (body), primary-action, secondary-action, footer. The admin-drawn modal shows only the text body and the action buttons; a label comes from the button's text, or its accessibility-label when it has none.
Parts: dialog, header, body, footer.
<e-modal id="confirm" heading="Delete product?" size="sm">
This can't be undone.
<e-button slot="secondary-action" variant="secondary" id="cancel">Cancel</e-button>
<e-button slot="primary-action" variant="critical" id="delete">Delete</e-button>
</e-modal>
<script type="module">
const modal = document.getElementById("confirm");
document.getElementById("cancel").addEventListener("click", () => (modal.open = false));
document.getElementById("delete").addEventListener("click", async () => {
await deleteProduct();
modal.open = false;
});
</script>e-save-bar
Tells the merchant a form has unsaved changes, and offers Save and Discard. Inside the admin, the admin shows it in its own "unsaved changes" style, and blocks the merchant from leaving the page exactly as it does for its own forms. Outside the admin — or with an admin that does not answer — the kit draws a sticky bar at the top of its container instead.
With watch-form, the bar runs itself:
inputorchangeinside that form opens the bar,- Save sets
savingand callsform.requestSubmit()(a form that fails validation is not submitted, andsavingis cleared), - Discard calls
form.reset()and closes the bar.
You end the save: set open = false when it worked, or saving = false when it did not. Closing the bar also clears saving. The admin only shows its Save spinner while saving is set.
Without watch-form, set open yourself and handle e-save and e-discard.
| Attribute | Property | Type | Default | Notes |
|---|---|---|---|---|
open | open | boolean | false | Reflects |
saving | saving | boolean | false | Save spins and Discard waits. Save is ignored while set. Reflects |
discard-confirmation | discardConfirmation | boolean | false | Ask "Discard all unsaved changes?" before discarding |
message | message | string | "Unsaved changes" | Only shown in the in-frame bar |
watch-form | watchForm | string | "" | id of a <form> in the same document |
Events:
| Event | Detail | React |
|---|---|---|
e-save | — Save was pressed. Cancelable: prevent it to skip requestSubmit() and save your own way | onSave |
e-discard | — Discard was pressed (and confirmed). Cancelable: prevent it to keep the form and the bar as they are | onDiscard |
Slots: none. Parts: bar, message — the in-frame bar only.
<form id="settings" method="post">
<e-text-field name="title" label="Title"></e-text-field>
</form>
<e-save-bar id="save-bar" watch-form="settings" discard-confirmation></e-save-bar>
<script type="module">
const bar = document.getElementById("save-bar");
document.getElementById("settings").addEventListener("submit", async (event) => {
event.preventDefault(); // the bar has already set saving
const ok = await save(new FormData(event.target));
if (ok) {
bar.open = false;
window.eekaam.toast("Settings saved", "success");
} else {
bar.saving = false;
}
});
</script>Updated 17 September 2026