UI components are Preact components that an app adds to the Swell dashboard. A content or settings field with type: "component" renders one of the app's components in place of a standard input, and the dashboard saves the value the component sets along with the rest of the form. Use them for values that need their own editor, such as a color palette, a rule builder, or a map.

Each component runs in a frame on the app installation's own domain, separate from the dashboard page, and can call the app's functions on behalf of the admin user who is signed in.

A component is a .tsx or .jsx file directly in the app's components/ folder. You can create one with the following CLI command:

swell create component BrandColor

The command writes components/BrandColor.tsx. It also adds preact to the app's dependencies and @swell/apps-sdk to its dev dependencies, and creates components/tsconfig.json to type-check components with browser libraries. Then run npm install.

→ See the CLI reference for more details and options.

The component is the file's default export, and receives the field's value and the functions to change it as props:

components/BrandColor.tsx
import type { ComponentProps } from '@swell/apps-sdk/components';

export const config = {
  description: 'Choose a brand color from a palette',
};

export default function BrandColor({
  value,
  setValue,
  readonly,
  params,
}: ComponentProps<string | null>) {
  const palette = (params.palette as string[] | undefined) ?? ['#111827', '#2563eb'];

  return (
    <div style={{ display: 'flex', gap: '8px' }}>
      {palette.map((color) => (
        <button
          key={color}
          type="button"
          title={color}
          aria-pressed={value === color}
          disabled={readonly}
          onClick={() => setValue(color)}
          style={{
            width: '32px',
            height: '32px',
            borderRadius: '50%',
            background: color,
            border: value === color ? '3px solid #2563eb' : '1px solid #d1d5db',
          }}
        />
      ))}
    </div>
  );
}

Import only the ComponentProps type from @swell/apps-sdk/components. The frame provides the SDK's runtime, so it isn't part of the component's build.

The file name without its extension is the component's name, so components/BrandColor.tsx is BrandColor. Names are case-sensitive. Use letters and digits: the CLI pushes an underscore in a file name as a hyphen, so brand_color.tsx is pushed as brand-color.

Other files under components/, such as .ts files and subfolders, are shared code that components can import. They aren't components themselves.

A component can export a config object with a description. The CLI reads config without running the file, so it must be an object literal with literal values. References to variables, spreads, and computed keys are rejected when you push.

Don't set extension in the config of a field component. extension marks a checkout component of a payment extension, which the CLI builds differently. See Extensions.

To use a component, add a field with type: "component" to a content or settings file, and set component to the component's name. Set params to an object to configure the component for this field. The component receives it as the params prop:

content/products.json
{
  "collection": "products",
  "fields": [
    {
      "id": "brand_color",
      "type": "component",
      "label": "Brand color",
      "component": "BrandColor",
      "required": true,
      "params": {
        "palette": ["#111827", "#2563eb", "#f59e0b"]
      }
    }
  ],
  "views": [
    {
      "id": "edit",
      "tabs": [
        {
          "id": "branding",
          "label": "Branding",
          "fields": [{ "id": "brand_color" }]
        }
      ]
    }
  ]
}

This example adds a Branding tab to product pages, with the BrandColor component as its field. Component fields also support the common field properties, such as label, description, required, readonly, default, conditions, and admin_span. They don't support ui.

Component fields can be used in:

  • Content files, on the record pages of app collections, and in the app's tabs and sections on standard records such as products and orders. This includes fields inside field_group, field_row, and the rows of a collection field.
  • Settings files, on the app's settings page and in the settings of its extensions.
  • Action dialogs, in modal.fields. See Actions.

In a view, refer to a component field by its id. The view takes component and params from the content field. Only apps can define component fields.

swell app push checks that the component fields in content and settings files name components the app has. The CLI pushes components before content and settings, so a new component and the field that uses it can be pushed together. Fields in action dialogs aren't checked when you push. A dialog field that names a missing component shows an error when the dialog opens.

A component field stores its value as a string, unless the app's data model declares the field with another type. To store an object or a list, declare the field in the app's models:

models/products.json
{
  "collection": "products",
  "fields": {
    "shipping_rules": {
      "type": "array",
      "value_type": "object"
    }
  }
}

When the field stores a string, the dashboard refuses other values. If a component sets a value that isn't a string, such as a number or an object, the value doesn't change, and the field shows a message such as The component sent a number; this field stores text.

Settings values are always stored as strings, so a component in a settings file should only set strings. In an action dialog, the value is sent to the function as the component set it.

An empty field's value is null, and setting the value to null clears the field. In list views, a component field's column shows its value when it's a string, a number, or a boolean. An object or a list shows an empty column, unless the view field sets a template.

Components receive the following props. Type them with ComponentProps<TValue, TContext>, for example ComponentProps<string | null>.

PropDescription
valueThe field's value, or null when the field is empty.
setValue(value)Changes the field's value. The component re-renders with the new value, and the dashboard saves it with the rest of the form. Use JSON values.
setValidity(error)Marks the field invalid with a message, or valid again with null. See Validation.
readonlytrue while the field can't be edited. The dashboard ignores setValue while it's true.
paramsThe field's params, or an empty object.
contextAn object with record, the record being edited including unsaved changes, and field, with the field's id, label, required, and type. type is the type the value is stored as, such as string. On the settings page, record holds the settings being edited.
settingsValues of the app's settings fields that set public: true, grouped by settings file. Fields that are empty, false, or 0 are left out.
localeThe store's locale.
fetchWorks like fetch, and adds a token to requests to the component's own domain. See Calling app functions.
on(event, handler)Handles an event from the page that shows the component. The Swell dashboard doesn't send events.

The dashboard sends changes to value, context, and readonly as they happen, so a component re-renders when the admin user edits another field.

When the field sets required: true, the dashboard doesn't save the form while the value is empty: null, an empty string, an empty list, or false. For other rules, call setValidity with a message while the value is invalid, and with null once it's valid:

Validating the value
import { useEffect } from 'preact/hooks';

// In the component
useEffect(() => {
  setValidity(value && !palette.includes(value) ? 'Choose a color from the palette' : null);
}, [value]);

When the admin user saves, the dashboard shows the message below the field and doesn't save the form. When the first invalid field is a component, the dashboard scrolls to it and focuses its first control.

The frame is as wide as the field, and its height follows the component's content. The frame doesn't inherit the dashboard's styles or fonts, and its background is transparent. Components can't import CSS or other non-JavaScript files, so use inline styles or a <style> element.

Content drawn outside the component's own box, such as an absolutely positioned menu, is clipped at the edges of the frame. Keep menus in the component's layout, or use native controls such as <select>, which the browser draws outside the frame.

To open a modal over the dashboard, render an element with position: fixed and inset: 0 as a direct child of document.body, for example with createPortal. While it's open, the frame covers the dashboard window, the rest of the dashboard can't be used, and keyboard focus moves into the modal. Removing the element closes it.

components/NotesEditor.tsx
import { useState } from 'preact/hooks';
import { createPortal } from 'preact/compat';
import type { ComponentProps } from '@swell/apps-sdk/components';

export default function NotesEditor({ value, setValue, readonly }: ComponentProps<string | null>) {
  const [open, setOpen] = useState(false);

  return (
    <div>
      <button type="button" disabled={readonly} onClick={() => setOpen(true)}>
        Edit notes
      </button>
      {open &&
        createPortal(
          <div
            style={{
              position: 'fixed',
              inset: 0,
              display: 'grid',
              placeItems: 'center',
              background: 'rgba(0, 0, 0, 0.4)',
            }}
          >
            <div role="dialog" aria-modal="true" style={{ background: '#fff', padding: '24px' }}>
              <textarea
                rows={10}
                value={value ?? ''}
                onInput={(event) => setValue(event.currentTarget.value)}
              />
              <button type="button" onClick={() => setOpen(false)}>
                Done
              </button>
            </div>
          </div>,
          document.body,
        )}
    </div>
  );
}

Dialogs that a library adds to document.body the same way, such as a payment provider's 3-D Secure challenge, also cover the dashboard.

The dashboard can remove a component's frame and create it again, for example when the admin user switches tabs. The component then starts again with the field's current value, so keep anything that must persist in the value.

Tab and Shift+Tab move focus into the component and out to the fields around it. The frame can use the payment and publickey-credentials-get browser features. Features that a frame needs other permissions for, such as the camera, aren't available.

A component calls the app's functions with its fetch prop, at /functions/<app-id>/<function-name> on its own domain, where <app-id> is the id in swell.json:

Calling a function from a component
const [palette, setPalette] = useState<string[]>([]);

useEffect(() => {
  fetch('/functions/my_app/brand-palette')
    .then((response) => response.json())
    .then((data: { palette: string[] }) => setPalette(data.palette));
}, []);

The fetch prop adds a token to requests to the component's own domain. With the token, a request can call any function that has a route, without an API key and without route.public. Functions without a route, such as action and model event functions, can't be called this way. Responses to these requests aren't cached. The global fetch doesn't send the token.

The function receives the caller in req.swellContext. Its storeUser.userId is the admin user the component runs for, and its surface is admin. The platform verifies the token before it calls the function. Check surface and storeUser before doing anything only a store user may do:

functions/brand-palette.ts
export const config: SwellConfig = {
  description: 'Palette for the BrandColor component',
  route: {
    methods: ['get'],
  },
};

export default async function (req: SwellRequest) {
  const context = req.swellContext;

  // Only for store users, through one of this app's components
  if (context?.surface !== 'admin' || !context.storeUser) {
    throw new SwellError('Forbidden', { status: 403 });
  }

  return { palette: ['#111827', '#2563eb', '#f59e0b'] };
}

Any admin user who can open the page gets a token, whatever their role. If a function should only run for some users, check storeUser.userId. See Functions for the other properties of req.swellContext.

Requests to other paths on the component's domain go to the app's frontend, with a Swell-Context header that identifies the admin user and has a surface claim of admin. The paths /.swell/components and /.swell/sdk on this domain are reserved for components.

While swell app dev runs, the dashboard loads the app's components from your machine instead of the pushed build. A component must have been pushed once for this, which swell app dev does when it starts. Each frame loads its component once, so to see a change, reopen the record or reload the page. After adding a component, reload the page.

When you push, the CLI bundles each component with everything it imports, including Preact and other npm packages, into a single minified module. Imports of react and react-dom use preact/compat. The build doesn't check types, so check them with tsc --noEmit -p components. Unchanged components aren't pushed again, so after updating a dependency such as preact, run swell app push --force.

swell app push rejects components and component fields with these errors. The messages say "Content field" for settings fields too.

MessageCause
Content field '' uses component '', which the app does not have. Add components/.tsx (or .jsx) and push it.The field names a component that hasn't been pushed. Names are case-sensitive, and an underscore in a file name is pushed as a hyphen.
Content field '' property 'component' must be the name of a component in the app's components foldercomponent is missing, or contains characters other than letters, digits, hyphens, and underscores.
Content field '' property 'params' must be an objectparams isn't an object.
Unable to compile component The component failed to build. The message continues with the cause, such as an imported CSS file or a config that isn't static.

In the dashboard, an error in a component shows below its field, with the error's message, and doesn't stop the admin user from saving the rest of the form. The platform reports these errors:

MessageCause
Component "" not found in app ""The installed app has no pushed component with that name.
Component frame did not startThe component's frame didn't load within 10 seconds.
The component sent a number; this field stores text.The component set a value that isn't a string, for a field stored as a string. The message names the kind of value: a number, a boolean, an object, or a list.