eekaam.docs

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.js from 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

URLWhat it servesCache
https://themes-cdn.eekaam.com/ui/v1/ui.jsThe latest 1.x build. Apps load this.5 minutes
https://themes-cdn.eekaam.com/ui/<x.y.z>/ui.jsOne 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 dev

React

@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, items and tabs are passed as real values, not strings.
  • Events are on props. Each component lists its handlers in the reference below. Custom events arrive as a CustomEvent, with the data in event.detail.
  • className, style, id, slot, hidden, tabIndex, aria-* and data-* go straight to the element as attributes.
  • ref is the element. Use it to call methods such as requestClose() or reportValidity().

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-react
import { 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 input and change, like native inputs. Both bubble and cross shadow roots.
  • Richer components dispatch e-* custom events with a typed detail: e-sort, e-page, e-select, e-files, e-dismiss, e-action and 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 their name.
  • required blocks submit while empty, and the browser shows its own message.
  • error marks 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:

AttributePropertyTypeDefaultNotes
namenamestring""Key in FormData
valuevaluestring""The current value
labellabelstring""Visible label
label-hiddenlabelHiddenbooleanfalseHides the label visually, keeps it for screen readers
help-texthelpTextstring""Hint under the control
errorerrorstring""Error message; makes the control invalid
placeholderplaceholderstring""
requiredrequiredbooleanfalseReflects
disableddisabledbooleanfalseReflects

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:

AttributeValues
variantprimary · secondary · tertiary · plain · critical
toneneutral · info · success · warning · critical · attention
sizesm · 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.

AttributePropertyTypeDefaultNotes
headingheadingstring""Page title; also sets the admin title bar
subtitlesubtitlestring""
back-hrefbackHrefstring""Shows a back arrow linking here
widthwidthdefault | narrow | fulldefaultReflects
keep-titlekeepTitlebooleanfalseLeave 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.

AttributePropertyTypeDefaultNotes
headingheadingstring""
paddingpaddingdefault | nonedefaultReflects
subduedsubduedbooleanfalseReflects

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.

AttributePropertyTypeDefaultNotes
directiondirectionvertical | horizontalverticalReflects
gapgap0 1 2 3 4 5 6 84Steps on the spacing scale. Reflects
align-itemsalignItemsstart | center | end | stretch | baselineunset (stretch)Reflects
justify-contentjustifyContentstart | center | end | space-betweenunsetReflects
wrapwrapbooleanfalseReflects

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.

AttributePropertyTypeDefaultNotes
columnscolumnsnumber2
min-column-widthminColumnWidthCSS length""e.g. 16rem. Wins over columns
gapgap0 1 2 3 4 5 6 84Reflects

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.

AttributePropertyTypeDefaultNotes
orientationorientationhorizontal | verticalhorizontalReflects

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-button

A link when it has href, a button otherwise. type="submit" submits the form it sits in, as a native button would. While disabled or loading, clicks are swallowed.

AttributePropertyTypeDefaultNotes
variantvariantprimary | secondary | tertiary | plain | criticalprimaryReflects
sizesizesm | md | lgmdReflects
typetypebutton | submit | resetbutton
hrefhrefstring""Renders a link
targettargetstring""For links. _top leaves the admin's frame — use navigate() instead
disableddisabledbooleanfalseReflects
loadingloadingbooleanfalseShows a spinner and blocks clicks. Reflects
full-widthfullWidthbooleanfalseReflects
accessibility-labelaccessibilityLabelstring""Required when the button holds only an icon

Events: click (native). React: onClick.

Slots: default (label), icon.

Parts: button.

<e-button variant="secondary" size="sm">Cancel</e-button>
<e-button variant="critical" loading>Deleting</e-button>
<e-button href="/app/settings">Settings</e-button>

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.

AttributePropertyTypeDefaultNotes
typetypetext | email | number | password | search | tel | urltext
prefixprefixstring""Text inside the field, before the value, e.g. Rs.
suffixsuffixstring""Text after the value, e.g. kg
autocompleteautocompletestring""
minlengthminlengthnumber
maxlengthmaxlengthnumber
min · max · stepsamestring""For type="number"
patternpatternstring""
readonlyreadonlybooleanfalse

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.

AttributePropertyTypeDefaultNotes
rowsrowsnumber3
max-rowsmaxRowsnumberGrow up to this many lines. Reflects
minlengthminlengthnumber
maxlengthmaxlengthnumberAlso shows used/max
autocompleteautocompletestring""
readonlyreadonlybooleanfalse

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.

AttributePropertyTypeDefaultNotes
optionsSelectOption[]{ 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.

AttributePropertyTypeDefaultNotes
checkedcheckedbooleanfalseReflects
indeterminateindeterminatebooleanfalseNeither on nor off, e.g. "select all". Cleared by a click. Reflects
valuevaluestring"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.

AttributePropertyTypeDefaultNotes
checkedcheckedbooleanfalseReflects
valuevaluestring"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.

AttributePropertyTypeDefaultNotes
minminYYYY-MM-DD""Earliest pickable date
maxmaxYYYY-MM-DD""Latest pickable date
readonlyreadonlybooleanfalse
localelocalestringbrowser'sFor 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.

AttributePropertyTypeDefaultNotes
optionsComboboxOption[][]{ label, value, disabled? }
allow-customallowCustombooleanfalseTyped text can be the value
remoteremotebooleanfalseAlways fire e-search; never filter locally
loadingloadingbooleanfalseShows a loading row. Reflects
empty-textemptyTextstring"No results"
autocompleteautocompletestring"off"

Events:

EventDetailReact
e-select{ value, label } — an option was chosenonSelect
e-search{ query } — fetch matching optionsonSearch
change— the value changedonChange
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.

AttributePropertyTypeDefaultNotes
acceptacceptstring""As for <input type="file">: image/*, .csv, application/pdf
multiplemultiplebooleanfalseWithout it, a new file replaces the current one
max-sizemaxSizenumberLargest file, in bytes
action-labelactionLabelstring"Add files"Browse button text
filesFile[][]The accepted files. Replace the array to change it

Events:

EventDetailReact
e-files{ files } — the full list after a changeonFiles
e-reject{ files, reason }reason is type, size or countonReject
changeonChange

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.

AttributePropertyTypeDefaultNotes
tonetoneneutral | info | success | warning | critical | attentionneutralReflects
sizesizemd | lgmdReflects
progressprogressincomplete | partial | complete""

Slots: default. Parts: none.

<e-badge tone="success">Paid</e-badge>
<e-badge tone="warning" progress="partial">Partially fulfilled</e-badge>

e-banner

A message about the whole page or a card. Warning and critical banners are announced as soon as they appear; the others are polite. A dismissed banner hides itself unless you prevent e-dismiss, so a plain-HTML app needs no handler.

AttributePropertyTypeDefaultNotes
tonetoneinfo | success | warning | criticalinfoReflects
headingheadingstring""
dismissibledismissiblebooleanfalseShows a close button. Reflects

Events: e-dismiss — cancelable; prevent it to keep the banner. React: onDismiss.

Slots: default (message), actions.

Parts: banner, heading, body, actions, dismiss.

<e-banner tone="warning" heading="Your plan ends in 3 days" dismissible>
  Choose a plan to keep your listings live.
  <e-button slot="actions" variant="secondary" size="sm">Choose plan</e-button>
</e-banner>

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.

AttributePropertyTypeDefaultNotes
sizesizesm | md | lgmdReflects
accessibility-labelaccessibilityLabelstring"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.

AttributePropertyTypeDefaultNotes
variantvarianttext | heading | block | circle | thumbnailtextReflects
lineslinesnumber1For text. The last line is shorter
widthwidthCSS length""
heightheightCSS 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.

AttributePropertyTypeDefaultNotes
valuevaluenumber00–100, clamped
labellabelstring""Accessible name
tonetonedefault | success | criticaldefaultReflects
sizesizesm | mdmdReflects
indeterminateindeterminatebooleanfalseFor 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.

AttributePropertyTypeDefaultNotes
headingheadingstring""
imageimageURL""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:

FieldTypeNotes
keystringThe row field shown, and the key e-sort reports
headerstring
numericbooleanRight-aligned, tabular figures
sortableboolean
widthCSS lengthe.g. 8rem or 20%
render(row)functionCustom cell content: a string, number, DOM node or lit template. Not JSX
AttributePropertyTypeDefaultNotes
columnsDataTableColumn[][]
rowsDataTableRow[][]
row-keyrowKeystring"id"The field that identifies a row
accessibility-labelaccessibilityLabelstring""The table's name
selectableselectablebooleanfalseCheckbox column. Reflects
selectedKeysstring[][]
loadingloadingbooleanfalseSkeleton rows. Reflects
clickable-rowsclickableRowsbooleanfalseRows fire e-row-click. Reflects
sort-keysortKeystring""The column the rows are sorted by
sort-directionsortDirectionasc | descasc
empty-headingemptyHeadingstring"No items found"
pagepagenumber11-based
page-sizepageSizenumber25
totaltotalnumberShows "Showing a–b of total"
has-next · has-previoushasNext · hasPreviousbooleanfalseFor cursor pagination, when the total is unknown

Events:

EventDetailReact
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 rowonRowClick

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.

AttributePropertyTypeDefaultNotes
srcsrcURL""
altaltstring""
sizesizexs | sm | md | lgmdReflects

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.

AttributePropertyTypeDefaultNotes
namenamestring""Accessible name and initials
srcsrcURL""
sizesizexs | sm | md | lgmdReflects
shapeshaperound | squareroundReflects

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>

Overlays and navigation

e-app-nav

Lists your app's pages in the admin's navigation. Its children are plain <a> links, with paths relative to your embedded URL: / is your home page, /settings is <embedded URL>/settings. New in 1.1.

Inside the admin it draws nothing. It sends the links to the admin, which lists them under your app in its own sidebar, after any admin_page extensions, and marks the current one. When the merchant clicks one, the admin loads your frame at that path. Report the page you are on after a client-side navigation with setAppLocation, so the admin's address bar and sidebar follow.

Outside the admin, it draws the links as a row of tabs, so your app can still be moved around standalone. It does the same in an admin that cannot list pages — one that answers unsupported, or does not answer at all. Mark the current link with aria-current="page" to underline it.

  • The home link is not listed. That is the first link with rel="home", else the first with href="/". Your app's own entry in the sidebar already leads there. It is still drawn in the standalone row.
  • Only links with text and a path starting with / are listed, and at most 10. Labels are cut to 40 characters.
  • The links are slotted, not copied. They stay your elements, so your own click handlers keep working. The href is what the admin lists, so it must be the path relative to your embedded URL. If your router's paths include the embedded URL's path, use plain links and route their clicks yourself, as below and in apps made with eekaam app init.
  • Changes are sent again. Add, remove or rename a link, or change its href or rel, and the admin's list follows.

It takes no attributes or properties and fires no events.

Slots: default — the <a> links.

Parts: nav — the standalone row only.

<e-app-nav>
  <a href="/" rel="home">Home</a>
  <a href="/reviews" aria-current="page">Reviews</a>
  <a href="/settings">Settings</a>
</e-app-nav>

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.

AttributePropertyTypeDefaultNotes
tabsTabItem[][]
selectedselectedstring""The selected tab's id. Reflects
fittedfittedbooleanfalseTabs share the full width. Reflects
accessibility-labelaccessibilityLabelstring""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.

AttributePropertyTypeDefaultNotes
openopenbooleanfalseReflects
placementplacementbottom-start | bottom-end | top-start | top-endbottom-startFlips 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-menu

A list of actions. Items are data, not children, and text only — a menu cannot hold markup. Inside an <e-popover>, opening puts focus on the first item and choosing an item closes the popover. Arrow keys, Home/End and first-letter typeahead all work.

MenuItem is { id, label, tone?: "critical", disabled?, section? }. Consecutive items with the same section are grouped under it.

AttributePropertyTypeDefaultNotes
itemsMenuItem[][]
accessibility-labelaccessibilityLabelstring""

Events: e-action{ id } of the chosen item. React: onAction.

Slots: none. Parts: menu, item.

<e-popover>
  <e-button slot="trigger" variant="secondary">Actions</e-button>
  <e-menu id="actions"></e-menu>
</e-popover>
<script type="module">
  const menu = document.getElementById("actions");
  menu.items = [
    { id: "duplicate", label: "Duplicate" },
    { id: "delete", label: "Delete", section: "Manage", tone: "critical" },
  ];
  menu.addEventListener("e-action", (event) => run(event.detail.id));
</script>

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.

AttributePropertyTypeDefaultNotes
contentcontentstring""The hint text
placementplacementtop | bottomtopReflects

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 in primary-action and secondary-action, with at most two secondary,
  • in-frame and loading are not set, and size is not fullscreen.

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.

AttributePropertyTypeDefaultNotes
openopenbooleanfalseReflects
headingheadingstring""
sizesizesm | md | lg | fullscreenmdfullscreen is always drawn in-frame. Reflects
loadingloadingbooleanfalseCovers the body with a spinner; drawn in-frame. Reflects
in-frameinFramebooleanfalseAlways draw inside the app's frame. Reflects

Events:

EventDetailReact
e-request-close{ reason }escape, backdrop or close-button. CancelableonRequestClose
e-open— the modal is showing (for an admin-drawn one, once the admin has confirmed)onOpen
e-closeonClose

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:

  • input or change inside that form opens the bar,
  • Save sets saving and calls form.requestSubmit() (a form that fails validation is not submitted, and saving is 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.

AttributePropertyTypeDefaultNotes
openopenbooleanfalseReflects
savingsavingbooleanfalseSave spins and Discard waits. Save is ignored while set. Reflects
discard-confirmationdiscardConfirmationbooleanfalseAsk "Discard all unsaved changes?" before discarding
messagemessagestring"Unsaved changes"Only shown in the in-frame bar
watch-formwatchFormstring""id of a <form> in the same document

Events:

EventDetailReact
e-save— Save was pressed. Cancelable: prevent it to skip requestSubmit() and save your own wayonSave
e-discard— Discard was pressed (and confirmed). Cancelable: prevent it to keep the form and the bar as they areonDiscard

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