Skip to main content

Using Polaris web components

Web components are the UI building blocks that you use to display data and trigger functionality in UI extensions. These components are native UI elements that follow Shopify's design system and are built with remote-dom, Shopify's library for building cross-platform user interfaces.

This guide covers essential concepts for working with web components across all UI extension surfaces.


UI extensions are scaffolded with Preact by default. This means you can use Preact patterns and principles within your extension.

Since Preact is included as a standard dependency, you have access to all of its features including hooks like useState and useEffect for managing component state and side effects. You can also use Preact Signals for reactive state management, and take advantage of standard web APIs just like you would in a regular Preact application.

Example: Using Preact hooks to manage state

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

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

function Extension() {
const [count, setCount] = useState(0);

return (
<>
<s-text>Count: {count}</s-text>
<s-button
onClick={() => setCount(count + 1)}
>
Increment
</s-button>
</>
);
}

Web components have accessibility built in. They use semantic HTML, support keyboard navigation, include proper ARIA attributes, manage focus, and provide appropriate color contrast. Components also log warnings when required accessibility properties are missing.

To keep your app accessible:

  • Always set label and error properties on form elements.
  • Use appropriate heading levels with s-heading or the heading property.
  • Test keyboard navigation throughout your extension.
  • Use labelAccessibilityVisibility to visually hide labels while keeping them available to assistive technologies.
  • Use accessibilityRole to specify the ARIA role of a component.

Example: Ensuring accessibility with labels

{/* Good - provides a label */}
<s-text-field label="Email address"></s-text-field>

{/* Bad - missing a label */}
<s-text-field></s-text-field>

Commands let you control components declaratively without writing JavaScript. Set commandFor to the ID of the target component, and command to the action you want to perform. The browser handles the interaction automatically.

Available commands:

  • --toggle: Toggle the target component's visibility.
  • --show: Show the target component.
  • --hide: Hide the target component.
  • --auto: Perform the most appropriate action for the target component (default).
  • --copy: Copy the target ClipboardItem.

Example: Controlling components with commands

<s-box>
<s-modal id="example-modal" heading="Product Details">
<s-text>
This modal is controlled using the commands API. No JavaScript event
handlers are needed—the browser handles all interactions automatically.
</s-text>

<s-stack slot="primaryAction">
<s-button commandFor="example-modal" command="--hide">
Close
</s-button>
</s-stack>
</s-modal>

<s-stack gap="small">
<s-heading>Modal Controls</s-heading>

<s-button
commandFor="example-modal"
command="--toggle"
variant="primary">
Toggle Modal
</s-button>

<s-button
commandFor="example-modal"
command="--show"
tone="success">
Show Modal
</s-button>

<s-button
commandFor="example-modal"
command="--hide"
tone="critical">
Hide Modal
</s-button>

<s-button
commandFor="example-modal"
command="--auto">
Auto Command (Toggle)
</s-button>
</s-stack>
</s-box>

UI extensions execute the module's default export so it can render a user interface. UI extensions are powered by remote-dom, a fast and secure environment for custom (non-DOM) UIs.

Remote-dom enables UI extensions to run in an isolated sandbox while maintaining high performance and security. This architecture ensures that extensions can't access sensitive data or interfere with the host application, while still providing a rich, interactive user experience.

Example: Basic extension structure

import '@shopify/ui-extensions/preact';
import {render} from 'preact';

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

function Extension() {
return <s-banner>Your extension</s-banner>;
}

The s-form component manages form state and handles submissions to your app's backend or directly to Shopify using API access. It triggers callbacks when the form is submitted or reset.

Forms track whether inputs are "dirty" (changed from their original value) using the defaultValue property:

  • With defaultValue set: An input is dirty when its current value differs from defaultValue. Update defaultValue after submission to reset the dirty state.
  • Without defaultValue set: An input is dirty when its current value differs from the initial value or the last programmatic update.
Note

Each input must have a name attribute for dirty state tracking to work.

Example: Managing form dirty state

import { render } from 'preact';
import { useState } from 'preact/hooks';

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

const defaultValues = {
text: 'default value',
number: 50,
};

function Extension() {
const [textValue, setTextValue] = useState('');
const [numberValue, setNumberValue] = useState('');

return (
<s-form onSubmit={() => console.log('submit', {textValue, numberValue})}>
<s-stack gap="base">
<s-text-field
label="Default Value"
name="my-text"
defaultValue={defaultValues.text}
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
/>
<s-number-field
label="Percentage field"
name="my-number"
defaultValue={defaultValues.number}
value={numberValue}
onChange={(e) => setNumberValue(e.target.value)}
/>
</s-stack>
</s-form>
);
}
import { render } from 'preact';
import { useState } from 'preact/hooks';

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

async function Extension() {
const data = await fetch('/data.json');
const {text, number} = await data.json();
return <App text={text} number={number} />;
}

function App({text, number}) {
// The initial values set in the form fields will be the default values
const [textValue, setTextValue] = useState(text);
const [numberValue, setNumberValue] = useState(number);

return (
<s-form onSubmit={() => console.log('submit', {textValue, numberValue})}>
<s-stack gap="base">
<s-text-field
label="Default Value"
name="my-text"
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
/>
<s-number-field
label="Percentage field"
name="my-number"
value={numberValue}
onChange={(e) => setNumberValue(e.target.value)}
/>
</s-stack>
</s-form>
);
}

Web components use standard DOM events, making them work with your preferred framework. You can attach event handlers using the same patterns as with native HTML elements.

Anchor to Basic event handlingBasic event handling

Event handlers in Polaris components work just like standard HTML elements. In frameworks, use the familiar camelCase syntax (like onClick in Preact). In plain HTML, use lowercase attributes or addEventListener.

Example: Attaching event handlers

<s-button onClick={() => console.log('Clicked!')}>
Inline Handler
</s-button>
<s-button onClick={(event) => {
console.log('Event details:', event.type);
console.log('Target:', event.currentTarget);
}}>
With Event Object
</s-button>
<s-button onclick="console.log('Button clicked!')">
Click me
</s-button>
<s-button id="eventButton">
Click me (addEventListener)
</s-button>
<script>
const eventButton = document.getElementById('eventButton');
eventButton.addEventListener('click', () => {
console.log('Button clicked using addEventListener!');
});
</script>

Form components support two event types for tracking changes:

  • onInput: Fires on every keystroke or value change. Use this for real-time validation or character counting.
  • onChange: Fires when the value is committed—on blur for text fields, or immediately after input for checkboxes and radio buttons. Use this to validate after the user finishes their input.

Example: Handling input and change events

// OnInput fires on every keystroke
<s-email-field
label="Email"
name="email"
onInput={(e) => console.log('Typing:', e.currentValue.value)}
></s-email-field>
// OnChange fires on blur or Enter press
<s-text-field
label="Name"
name="name"
onChange={(e) => console.log('Value committed:', e.currentValue.value)}
></s-text-field>
// Using both together
<s-email-field
label="Email"
name="email"
onInput={(e) => console.log('Real-time:', e.currentTarget.value)}
onChange={(e) => console.log('Final value:', e.currentTarget.value)}
></s-email-field>
<s-email-field
label="Email"
name="email"
oninput="console.log('Typing:', this.value)"
></s-email-field>
<s-text-field
label="Name"
name="name"
onchange="console.log('Value committed:', this.value)"
></s-text-field>
<!-- Using addEventListener -->
<s-email-field
label="Email"
name="email"
></s-email-field>
<script>
const field = document.querySelector('s-email-field');
field.addEventListener('input', (e) => {
console.log('Real-time:', e.currentTarget.value);
});
field.addEventListener('change', (e) => {
console.log('Final value:', e.currentTarget.value);
});
</script>

Use onFocus and onBlur to track when users enter and leave form fields.

Example: Managing focus events

<s-email-field
label="email"
name="email"
onFocus={() => console.log('Field focused')}
onBlur={() => console.log('Field blurred')}
></s-email-field>
<s-text-field
label="Tab to next field to trigger blur"
name="name"
onFocus={(e) => {
e.currentTarget.setAttribute('label', 'Field is active!')
}}
onBlur={(e) => {
e.currentTarget.setAttribute('label', 'Tab to next field to trigger blur')
}}
></s-text-field>
<s-email-field
label="Email"
name="email"
onfocus="console.log('Field focused')"
onblur="console.log('Field blurred')"
></s-email-field>
<s-text-field
label="Tab to next field to trigger blur"
name="name"
></s-text-field>
<script>
const field = document.querySelector('s-text-field');
field.addEventListener('focus', () => {
field.setAttribute('label', 'Field is active!');
});
field.addEventListener('blur', () => {
field.setAttribute('label', 'Tab to next field to trigger blur');
});
</script>

Anchor to Form values and typesForm values and types

All form elements return string values in events, even numeric inputs—convert them if needed. Access single values using event.currentTarget.value, or use event.currentTarget.values (an array of strings) for multi-select components like s-choice-list.

Example: Working with form values and types

// Number field example - values are strings
<s-number-field
label="Quantity"
name="quantity"
onChange={(e) => {
// e.currentTarget.value is a string, convert if needed
const quantity = Number(e.currentTarget.value);
console.log('Quantity as number:', quantity);
}}
/>
// Multi-select example - values is an array of strings
<s-choice-list
name="colors"
label="Colors"
multiple
onChange={(e) => {
// e.currentTarget.values is an array of strings
console.log('Selected colors:', e.currentTarget.values);
}}
>
<s-choice label="Red" value="red" />
<s-choice label="Blue" value="blue" />
<s-choice label="Green" value="green" />
</s-choice-list>
<!-- Number field example - values are strings -->
<s-number-field
label="Quantity"
name="quantity"
onchange="console.log('Value type:', typeof this.value, 'Value:', this.value)"
></s-number-field>
<!-- Multi-select example - values is an array of strings -->
<s-choice-list name="colors" label="Colors" multiple>
<s-choice value="red">Red</s-choice>
<s-choice value="blue">Blue</s-choice>
<s-choice value="green">Green</s-choice>
</s-choice-list>
<script>
const choiceList = document.querySelector('s-choice-list');
choiceList.addEventListener('change', (e) => {
// e.currentTarget.values is an array of strings
console.log('Selected colors:', e.currentTarget.values);
});
</script>

Anchor to Controlled and uncontrolled componentsControlled and uncontrolled components

Form components can be uncontrolled (simpler) or controlled (more advanced):

  • Uncontrolled: The component manages its own state. Set defaultValue for the initial value.
  • Controlled: Your code manages the state. Set value and update it in your event handler. Use this when you need to validate as the user types, format values, or synchronize multiple inputs.

Example: Controlled and uncontrolled components

// Uncontrolled component - internal state
<s-text-field
label="Comment"
name="comment"
defaultValue="Initial value"
onChange={(e) => console.log('New value:', e.currentTarget.value)}
/>
// Controlled component - external state
// In a real component, 'name' would be from framework state
const name = "John Doe";
<s-text-field
label="Name"
name="name"
value={name}
onChange={(e) => {
console.log('Would update state:', e.currentTarget.value)
}}
/>
<!-- Uncontrolled component - internal state -->
<s-text-field
label="Comment"
name="comment"
value="Initial value"
onchange="console.log('New value:', this.value)"
></s-text-field>
<!-- Controlled component - external state -->
<s-text-field
id="nameField"
label="Name"
name="name"
value="John Doe"
></s-text-field>
<button onclick="updateName()">
Change Name
</button>
<script>
const nameField = document.getElementById('nameField');
// Listen for changes to update our "state"
nameField.addEventListener('input', (e) => {
console.log('Value changed:', e.currentTarget.value);
});
// Manually update the component value (controlled)
function updateName() {
nameField.value = "Jane Smith";
}
</script>

Anchor to Technical implementationTechnical implementation

Web components register events using addEventListener rather than setting attributes.

Event names are automatically converted to lowercase. When you write <s-button onClick={handleClick}>, the component checks that "onClick" in element is true, then registers your handler with addEventListener('click', handler).

All event handlers receive standard DOM events as their first argument.

Example: Event handling implementation

<s-button onClick={() => console.log('Clicked!')}>
Click me
</s-button>
<s-email-field
label="Email"
name="email"
onChange={(e) => console.log('Value changed:', e.currentTarget.value)}
onFocus={() => console.log('Field focused')}
onBlur={() => console.log('Field blurred')}
></s-email-field>
<s-button onclick="console.log('Clicked!')">
Click me
</s-button>
<s-text-field
label="Email"
name="email"
onchange="console.log('Value changed:', e.currentTarget.value)"
onfocus="console.log('Field focused')"
onblur="console.log('Field blurred')"
></s-text-field>
<!-- or -->
<script>
const textField = document.querySelector('s-text-field');
textField.addEventListener('change', (e) => {
console.log('Value changed:', e.currentTarget.value);
});
</script>

Anchor to Interactive elementsInteractive elements

s-button, s-link, and s-clickable render as anchor elements when they have an href, or as button elements when they have an onClick without an href. Per the HTML spec, interactive elements can't contain other interactive elements.

Set target="auto" to automatically open internal links in the same tab (_self) and external URLs in a new tab (_blank).

Use s-clickable as an escape hatch when s-link or s-button can't achieve a specific design. Consider using s-link and s-button when possible.

Example: Interactive elements as buttons and links

<s-stack gap="base">
{/* s-button with onClick renders as a button element */}
<s-button onClick={() => console.log('Action triggered')}>
Trigger Action
</s-button>

{/* s-button with href renders as an anchor element */}
<s-button href="/products" variant="primary">
View Products
</s-button>

{/* s-link with href renders as an anchor element */}
<s-link href="/settings">Go to Settings</s-link>

{/* s-link with onClick renders as a button element */}
<s-link onClick={() => console.log('Link action')}>
Trigger Link Action
</s-link>

{/* target="auto" uses _self for internal, _blank for external URLs */}
<s-link href="/internal-page" target="auto">Internal Link</s-link>
<s-link href="https://shopify.com" target="auto">External Link</s-link>

{/* s-clickable is an escape hatch for custom interactive designs */}
<s-clickable onClick={() => console.log('Custom click')}>
<s-box padding="base" background="subdued" borderRadius="base">
<s-text>Custom Clickable Area</s-text>
</s-box>
</s-clickable>
</s-stack>

Use s-stack, s-grid, and s-box to build custom layouts.

s-stack and s-grid have no spacing between children by default. Use the gap property to add space. Setting direction="inline" on s-stack makes children wrap to a new line when space is limited. s-grid allows children to overflow unless you configure template rows or columns.

For shorthand properties like border, order matters: use size-keyword, color-keyword, style-keyword.

Example: Building a custom layout with stack and gap

import '@shopify/ui-extensions/preact';
import {render} from 'preact';

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

function Extension() {
return (
<s-stack direction="inline">
<s-image src="https://cdn.shopify.com/YOUR_IMAGE_HERE" />
<s-stack>
<s-heading>Heading</s-heading>
<s-text type="small">Description</s-text>
</s-stack>
<s-button
onClick={() => {
console.log('button was pressed');
}}
>
Button
</s-button>
</s-stack>
);
}

Methods are functions available on components for programmatic control. Components like Modal, Sheet, and Announcement provide hideOverlay() or dismiss() to control their behavior imperatively when needed.

Use methods when you need to trigger actions that can't be achieved through property changes alone, such as closing an overlay after an async operation or resetting component state.

Example: Calling component methods

function Methods() {
const modalRef = useRef(null);

return (
<>
<s-button
command="--show"
commandFor="modal-1"
>
Open modal
</s-button>
<s-modal
id="modal-1"
ref={modalRef}
heading="Test Modal"
>
<s-text>Modal content</s-text>
<s-button
onClick={() => {
modalRef.current.hideOverlay();
}}
>
Close modal
</s-button>
</s-modal>
</>
);
}
function Methods() {
const button =
document.createElement('s-button');
const modal = document.createElement('s-modal');

button.textContent = 'Open Modal';
button.commandFor = 'modal-1';

modal.id = 'modal-1';
modal.heading = 'Test Modal';

const closeButton =
document.createElement('s-button');

closeButton.textContent = 'Close modal';
closeButton.onclick = () => modal.hideOverlay();

modal.appendChild(closeButton);

document.body.appendChild(button);
document.body.appendChild(modal);
}

Anchor to Properties and attributesProperties and attributes

Web components follow standard HTML patterns. Attributes appear in markup, while properties are accessed directly on the DOM element. Most attributes are reflected as properties, except value and checked which follow HTML's standard behavior.

In JSX, the framework checks if the element has a matching property name. If it does, the value is set as a property; otherwise, it's applied as an attribute:

Example: How JSX applies properties vs attributes

if (propName in element) {
// Set as a property
element[propName] = propValue;
} else {
// Set as an attribute
element.setAttribute(propName, propValue);
}

In practice, just use the property names as documented and everything will work as expected.

Example: Using documented property names

{/* This works as expected - the "gap" property accepts string values */}
<s-stack gap="base">...</s-stack>;

{/* This also works - the "checked" property accepts boolean values */}
<s-checkbox checked={true}>...</s-checkbox>;

Some properties accept responsive values that change based on the parent container's inline size.

Responsive values follow a ternary-like syntax: @container (inline-size > 500px) large, small means use large when the container exceeds 500px, otherwise use small.

The pattern is: @container [name] (condition) valueIfTrue, valueIfFalse

Use a mobile-first approach for browser compatibility. The fallback value (when the condition is false) should work at the smallest size. For example, <Stack direction="@container (inline-size > 300px) inline, block"> ensures browsers without container query support get a layout that works everywhere.

Example: Using responsive values with container queries

<s-query-container>
<s-box padding="@container (inline-size > 500px) large-400, small">
This padding will be "large-400" when the container is more than 500px.
Otherwise it will be "small".
</s-box>
</s-query-container>
<s-query-container>
<s-box padding="@container (inline-size > 500px) large-400, small">
This padding will be "large-400" when the
container is more than 500px. Otherwise it will
be "small".
</s-box>
</s-query-container>

Anchor to Using s-query-containerUsing s-query-container

Wrap your content in <s-query-container> to enable responsive value queries. By default, queries target the closest container. To target a specific ancestor, add containername to that container and reference it in your query: @container outer (inline-size > 500px).

Example: Targeting named containers

<s-query-container containername="outer">
<s-section>
<s-query-container>
<s-box padding="@container outer (inline-size > 500px) large-400, small">
This padding will be "large-400" when the "outer" container is more than 500px.
Otherwise it will be "small".
</s-box>
</s-query-container>
</s-section>
</s-query-container>
<s-query-container containername="outer">
<s-box padding="@container outer (inline-size > 500px) large-400, small">
This padding will be "large-400" when the "outer" container is more than 500px.
Otherwise it will be "small".
</s-box>
</s-query-container>

Anchor to Values with reserved charactersValues with reserved characters

If your values contain brackets () or commas ,, wrap them in quotes to escape them.

Example: Escaping reserved characters

<s-query-container>
<s-grid
gridtemplatecolumns="@container (inline-size > 500px) 'repeat(4, 1fr)', 'repeat(2, 1fr)'"
>
...
</s-grid>
</s-query-container>

The syntax supports compound conditions, and/or logic, and nested conditions.

Example: Advanced responsive patterns

<s-query-container>
<s-box padding="@container (300px < inline-size < 500px) small, large-500">
The padding will be "small" when the container is between 300px and 500px
wide. Otherwise it will be "large-500".
</s-box>
</s-query-container>
<s-query-container>
<s-box
padding="@container (inline-size > 500px) and (inline-size < 1000px) large-400, small"
>
This padding will be "large-400" when the container is more than 500px wide and
when the container is smaller than 1000px wide. Otherwise it will be
"small".
</s-box>
</s-query-container>
<s-query-container>
<s-box
padding="@container (inline-size > 500px) base, (inline-size > 1000px) large-400, small"
>
This padding will be "base" when the container is greater than 500px wide,
"large-400" when the container is larger than 1000px wide, and "small" otherwise.
</s-box>
</s-query-container>

Properties like padding, size, and gap use a middle-out scale.

Values radiate from base (the default): small-100 through small-500 get progressively smaller, while large-100 through large-500 get progressively larger.

Use small and large as shorthand for small-100 and large-100.

Example: Scale values from small to large

export type Scale =
| 'small-500'
| 'small-400'
| 'small-300'
| 'small-200'
| 'small-100'
| 'small' // alias of small-100
| 'base'
| 'large' // alias of large-100
| 'large-100'
| 'large-200'
| 'large-300'
| 'large-400'
| 'large-500';

Slots allow you to insert custom content into specific areas of web components. Use the slot attribute to specify where your content should appear.

  • Named slots (for example, slot="title") place content in designated areas.
  • Multiple elements can share the same slot name.
  • Elements without a slot attribute go into the default (unnamed) slot.

Example: Using slots for custom content

<s-banner heading="Order created" status="success">
The order has been created successfully.
<s-button slot="primary-action">View order</s-button>
</s-banner>

Components automatically apply styling based on the properties you set and the context in which they're used. For example, headings display at progressively less prominent sizes based on nesting depth within sections. All components inherit merchant brand settings, and the CSS can't be altered or overridden.

Component styling is controlled by the merchant's branding settings and can't be overridden with custom CSS. Extensions render using Shopify's custom HTML elements (like <s-banner> or <s-button>) rather than standard DOM elements like <div> or <script> tags.

Example: Styling components with properties

<s-box
padding="base"
background="subdued"
border="base"
borderRadius="base"
class="my-custom-class"
>
Content
</s-box>

Anchor to Tone, color, and variantTone, color, and variant

Use these properties to control component appearance:

  • tone: Applies a group of color design tokens to the component (critical, success, info)
  • color: Adjusts the intensity of the tone (subdued, strong)
  • variant: Changes how the component is rendered to match the design language (varies by component)

Example: Using tone, color, and variant properties

<s-button tone="critical" variant="primary">
Primary Critical Button
</s-button>
<s-badge tone="success" color="strong">
Success Strong Badge
</s-badge>

Review the following common issues and debugging tips for using web components.

  1. Properties not updating: Ensure you're using the property name as documented, not a different casing or naming convention.
  2. Event handlers not firing: Check that you're using the correct event name (for example, onClick for click events).
  3. Form values not being submitted: Make sure your form elements have name attributes.

  1. Inspect the element in your browser's developer tools to see the current property and attribute values.
  2. Use console.log to verify that event handlers are being called and receiving the expected event objects.
  3. Check for any errors in the browser console that might indicate issues with your component usage.

Was this page helpful?