Design and implement Shopify Admin interfaces using the Polaris Design System. Use this skill when building Shopify Apps, Admin extensions, or any interface that needs to feel native to Shopify.
This skill ensures that interfaces are built using Shopify's Polaris Design System (React implementation v13.x), guaranteeing a native, accessible, and professional look and feel for Shopify Merchants.
Note (2025-2026): Polaris React (
@shopify/polaris) is in maintenance mode. Shopify has introduced Polaris Web Components for new development. However, Polaris React remains fully functional and supported for existing applications. This guide covers the React implementation.
Button, Link, TextField) rather than custom div implementations.{
"@shopify/polaris": "^13.9.0",
"@shopify/polaris-icons": "^9.x",
"@shopify/app-bridge-react": "^4.x"
}
Many UI components are now handled by App Bridge instead of Polaris React:
| Deprecated Polaris Component | Use App Bridge Instead |
|---|---|
Modal |
@shopify/app-bridge-react Modal API |
Navigation |
App Bridge Navigation Menu API |
Toast |
App Bridge Toast API |
ContextualSaveBar |
App Bridge useSaveBar() hook |
TopBar |
App Bridge Title Bar API |
Loading |
App Bridge Loading API |
// Example: Using App Bridge for Modal (instead of deprecated Polaris Modal)
import { Modal, TitleBar } from '@shopify/app-bridge-react';
function MyComponent() {
return (
<Modal id="my-modal">
<TitleBar title="Confirm Action">
<button variant="primary" onClick={handleConfirm}>Confirm</button>
<button onClick={handleCancel}>Cancel</button>
</TitleBar>
<p>Are you sure you want to proceed?</p>
</Modal>
);
}
// Example: Using App Bridge for Toast
import { useAppBridge } from '@shopify/app-bridge-react';
function showToast() {
shopify.toast.show('Product saved successfully');
}
// Example: Using App Bridge Save Bar
import { useSaveBar } from '@shopify/app-bridge-react';
function SettingsForm() {
const saveBar = useSaveBar();
useEffect(() => {
if (hasChanges) {
saveBar.show();
} else {
saveBar.hide();
}
}, [hasChanges]);
}
AppProvider: All Polaris apps must be wrapped in <AppProvider i18n={enTranslations}>.
Page: The top-level container for a route. Always set title and primaryAction (if applicable).
<Page
title="Products"
primaryAction={{content: 'Add product', onAction: handleAdd}}
backAction={{content: 'Settings', url: '/settings'}}
>
v13.x Note:
backActionprop inPage.Headeris deprecated. Use App Bridge navigation instead for complex navigation patterns.
Layout: Use Layout and Layout.Section to structure content.
Layout.AnnotatedSection: For settings pages (Title/Description on left, Card on right).Layout.Section: Standard Full (default), 1/2 (variant="oneHalf"), or 1/3 (variant="oneThird") width columns.Card: The primary container for content pieces. Group related information in a Card.
BlockStack (vertical) or InlineStack (horizontal) for internal layout within a Card.LegacyCard - it is deprecated. Use Card with layout primitives.import { Box, BlockStack, InlineStack, InlineGrid, Bleed, Divider } from '@shopify/polaris';
// Box - Low-level layout primitive with full token access
<Box padding="400" background="bg-surface-secondary" borderRadius="200">
Content here
</Box>
// BlockStack - Vertical stacking with gap
<BlockStack gap="400">
<Item1 />
<Item2 />
</BlockStack>
// InlineStack - Horizontal layout with alignment
<InlineStack gap="200" align="center" blockAlign="center">
<Icon />
<Text>Label</Text>
</InlineStack>
// InlineGrid - Responsive grid layout
<InlineGrid columns={{xs: 1, sm: 2, md: 3}} gap="400">
<Card>...</Card>
<Card>...</Card>
<Card>...</Card>
</InlineGrid>
// Bleed - Negative margin for edge-to-edge content
<Card>
<Bleed marginInline="400">
<img src="banner.jpg" style={{width: '100%'}} />
</Bleed>
</Card>
IndexTable: For lists of objects (Products, Orders) with bulk actions and filtering.
import { IndexTable, Card, Text, Badge, useIndexResourceState } from '@shopify/polaris';
function ProductList({ products }) {
const { selectedResources, allResourcesSelected, handleSelectionChange } =
useIndexResourceState(products);
const rowMarkup = products.map((product, index) => (
<IndexTable.Row
id={product.id}
key={product.id}
selected={selectedResources.includes(product.id)}
position={index}
>
<IndexTable.Cell>
<Text variant="bodyMd" fontWeight="bold">{product.name}</Text>
</IndexTable.Cell>
<IndexTable.Cell>{product.sku}</IndexTable.Cell>
<IndexTable.Cell>
<Badge tone={product.status === 'active' ? 'success' : 'info'}>
{product.status}
</Badge>
</IndexTable.Cell>
</IndexTable.Row>
));
return (
<Card padding="0">
<IndexTable
resourceName={{singular: 'product', plural: 'products'}}
itemCount={products.length}
selectedItemsCount={allResourcesSelected ? 'All' : selectedResources.length}
onSelectionChange={handleSelectionChange}
headings={[
{title: 'Name'},
{title: 'SKU'},
{title: 'Status'},
]}
>
{rowMarkup}
</IndexTable>
</Card>
);
}
DataTable: For simple, non-interactive data grids (e.g., analytics data).
ResourceList: For simpler lists without table structure (use ResourceItem for each item).
import {
Form, FormLayout, TextField, Select, Checkbox,
ChoiceList, RadioButton, RangeSlider, ColorPicker,
DropZone, Tag, Autocomplete
} from '@shopify/polaris';
function ProductForm() {
const [formState, setFormState] = useState({
title: '',
description: '',
status: 'draft',
tags: [],
});
const [errors, setErrors] = useState({});
return (
<Form onSubmit={handleSubmit}>
<FormLayout>
<TextField
label="Product title"
value={formState.title}
onChange={(value) => setFormState({...formState, title: value})}
error={errors.title}
autoComplete="off"
helpText="This will be displayed to customers"
/>
<TextField
label="Description"
value={formState.description}
onChange={(value) => setFormState({...formState, description: value})}
multiline={4}
autoComplete="off"
/>
<Select
label="Status"
options={[
{label: 'Draft', value: 'draft'},
{label: 'Active', value: 'active'},
{label: 'Archived', value: 'archived'},
]}
value={formState.status}
onChange={(value) => setFormState({...formState, status: value})}
/>
<FormLayout.Group>
<TextField label="Price" type="number" prefix="$" />
<TextField label="Compare at price" type="number" prefix="$" />
</FormLayout.Group>
<ChoiceList
title="Availability"
choices={[
{label: 'Online Store', value: 'online'},
{label: 'Point of Sale', value: 'pos'},
{label: 'Buy Button', value: 'buy_button'},
]}
selected={formState.channels}
onChange={(value) => setFormState({...formState, channels: value})}
allowMultiple
/>
</FormLayout>
</Form>
);
}
import {
IndexFilters, useSetIndexFiltersMode, IndexFiltersMode,
ChoiceList, RangeSlider, TextField
} from '@shopify/polaris';
function FilteredList() {
const [queryValue, setQueryValue] = useState('');
const [status, setStatus] = useState([]);
const { mode, setMode } = useSetIndexFiltersMode(IndexFiltersMode.Filtering);
const filters = [
{
key: 'status',
label: 'Status',
filter: (
<ChoiceList
title="Status"
titleHidden
choices={[
{label: 'Active', value: 'active'},
{label: 'Draft', value: 'draft'},
{label: 'Archived', value: 'archived'},
]}
selected={status}
onChange={setStatus}
allowMultiple
/>
),
shortcut: true,
},
];
const appliedFilters = status.length > 0
? [{key: 'status', label: `Status: ${status.join(', ')}`}]
: [];
return (
<IndexFilters
queryValue={queryValue}
queryPlaceholder="Search products"
onQueryChange={setQueryValue}
onQueryClear={() => setQueryValue('')}
filters={filters}
appliedFilters={appliedFilters}
onClearAll={() => setStatus([])}
mode={mode}
setMode={setMode}
tabs={[
{content: 'All', id: 'all'},
{content: 'Active', id: 'active'},
{content: 'Draft', id: 'draft'},
]}
selected={0}
/>
);
}
gap, padding, align, justify).| Token | Value | Usage |
|---|---|---|
--p-space-050 |
2px | Minimal spacing |
--p-space-100 |
4px | Tight spacing |
--p-space-200 |
8px | Compact spacing |
--p-space-300 |
12px | Default small |
--p-space-400 |
16px | Default standard |
--p-space-500 |
20px | Medium spacing |
--p-space-600 |
24px | Large spacing |
--p-space-800 |
32px | Section spacing |
--p-space-1000 |
40px | Page spacing |
--p-space-1200 |
48px | Extra large |
/* Backgrounds */
--p-color-bg /* Default page background */
--p-color-bg-surface /* Card/surface background */
--p-color-bg-surface-secondary /* Secondary surface */
--p-color-bg-surface-hover /* Hover state */
--p-color-bg-surface-selected /* Selected state */
--p-color-bg-fill-brand /* Primary brand fill */
--p-color-bg-fill-success /* Success background */
--p-color-bg-fill-warning /* Warning background */
--p-color-bg-fill-critical /* Critical/error background */
/* Text */
--p-color-text /* Default text */
--p-color-text-secondary /* Subdued text */
--p-color-text-disabled /* Disabled text */
--p-color-text-brand /* Brand colored text */
--p-color-text-success /* Success text */
--p-color-text-warning /* Warning text */
--p-color-text-critical /* Error text */
/* Borders */
--p-color-border /* Default border */
--p-color-border-hover /* Hover border */
--p-color-border-focus /* Focus ring */
--p-color-border-brand /* Brand border */
--p-border-radius-100 /* 4px - Small elements */
--p-border-radius-200 /* 8px - Cards, buttons */
--p-border-radius-300 /* 12px - Large cards */
--p-border-radius-full /* 9999px - Pills, avatars */
--p-shadow-100 /* Subtle shadow */
--p-shadow-200 /* Card shadow */
--p-shadow-300 /* Elevated shadow */
--p-shadow-400 /* Modal shadow */
// Use Text component with variants instead of HTML tags
<Text variant="headingXl">Page Title</Text> // 28px bold
<Text variant="headingLg">Section Title</Text> // 24px bold
<Text variant="headingMd">Card Title</Text> // 20px semibold
<Text variant="headingSm">Subsection</Text> // 16px semibold
<Text variant="headingXs">Small Header</Text> // 14px semibold
<Text variant="bodyLg">Large body</Text> // 16px regular
<Text variant="bodyMd">Default body</Text> // 14px regular
<Text variant="bodySm">Small text</Text> // 12px regular
// Tones for semantic meaning
<Text tone="subdued">Secondary information</Text>
<Text tone="success">Success message</Text>
<Text tone="critical">Error message</Text>
<Text tone="caution">Warning message</Text>
These components will be removed in future versions:
| Deprecated | Use Instead |
|---|---|
LegacyCard |
Card + BlockStack |
LegacyStack |
BlockStack / InlineStack |
LegacyFilters |
IndexFilters |
LegacyTabs |
Tabs |
Modal |
App Bridge Modal API |
Navigation |
App Bridge Navigation Menu |
Toast |
App Bridge Toast API |
ContextualSaveBar |
App Bridge useSaveBar() |
TopBar |
App Bridge Title Bar |
Loading |
App Bridge Loading API |
Frame |
App Bridge handles this |
Sheet |
App Bridge Modal or custom |
DisplayText |
Text with variant="heading*" |
Heading |
Text with variant="heading*" |
Subheading |
Text with variant="headingSm" |
Caption |
Text with variant="bodySm" |
TextStyle |
Text with tone prop |
TextContainer |
BlockStack with gap |
SettingToggle |
Custom with Card + InlineStack + Button |
PageActions |
Page primaryAction/secondaryActions props |
VisuallyHidden |
Use visuallyHidden prop on Text |
import {
Page, Layout, Card, BlockStack, InlineStack,
Text, Button, Badge, Box, Divider, Banner,
IndexTable, useIndexResourceState
} from '@shopify/polaris';
import { ExportIcon, PlusIcon } from '@shopify/polaris-icons';
export default function Dashboard({ products }) {
const { selectedResources, allResourcesSelected, handleSelectionChange } =
useIndexResourceState(products);
const promotedBulkActions = [
{ content: 'Export selected', icon: ExportIcon },
];
const rowMarkup = products.map((product, index) => (
<IndexTable.Row
id={product.id}
key={product.id}
selected={selectedResources.includes(product.id)}
position={index}
>
<IndexTable.Cell>
<Text variant="bodyMd" fontWeight="bold">{product.title}</Text>
</IndexTable.Cell>
<IndexTable.Cell>
<Badge tone={product.status === 'active' ? 'success' : 'info'}>
{product.status}
</Badge>
</IndexTable.Cell>
<IndexTable.Cell>${product.price}</IndexTable.Cell>
</IndexTable.Row>
));
return (
<Page
title="Dashboard"
primaryAction={{
content: 'Add product',
icon: PlusIcon,
onAction: () => {}
}}
secondaryActions={[
{content: 'Export', icon: ExportIcon, onAction: () => {}}
]}
>
<Layout>
<Layout.Section>
<Banner tone="info" onDismiss={() => {}}>
<p>New: Try our improved bulk editing features.</p>
</Banner>
</Layout.Section>
<Layout.Section>
<Card padding="0">
<IndexTable
resourceName={{singular: 'product', plural: 'products'}}
itemCount={products.length}
selectedItemsCount={allResourcesSelected ? 'All' : selectedResources.length}
onSelectionChange={handleSelectionChange}
headings={[
{title: 'Product'},
{title: 'Status'},
{title: 'Price', alignment: 'end'},
]}
promotedBulkActions={promotedBulkActions}
>
{rowMarkup}
</IndexTable>
</Card>
</Layout.Section>
<Layout.Section variant="oneThird">
<Card>
<BlockStack gap="400">
<Text as="h2" variant="headingMd">Quick Stats</Text>
<Divider />
<BlockStack gap="200">
<InlineStack align="space-between">
<Text tone="subdued">Total products</Text>
<Text fontWeight="semibold">{products.length}</Text>
</InlineStack>
<InlineStack align="space-between">
<Text tone="subdued">Active</Text>
<Text fontWeight="semibold">
{products.filter(p => p.status === 'active').length}
</Text>
</InlineStack>
</BlockStack>
</BlockStack>
</Card>
<Box paddingBlockStart="400">
<Card>
<BlockStack gap="300">
<Text as="h3" variant="headingSm">Quick Actions</Text>
<Button variant="plain" url="/settings">
View all settings
</Button>
</BlockStack>
</Card>
</Box>
</Layout.Section>
</Layout>
</Page>
);
}
LegacyCard, Modal, Toast, etc.).style={{ margin: 10 }}. Use <Box padding="400"> or <BlockStack gap="400">.<SkeletonPage> or <SkeletonBodyText> for loading states.<h1>, <h2>, etc. directly. Use <Text as="h2" variant="headingMd">.@shopify/polaris/build/esm/.... Use named exports from @shopify/polaris.Polaris v13.10 added translations for 8 new languages: Hindi, Lithuanian, Bulgarian, Hungarian, Romanian, Russian, Indonesian, and Greek.
import enTranslations from '@shopify/polaris/locales/en.json';
import viTranslations from '@shopify/polaris/locales/vi.json';
// Use the appropriate translations based on merchant locale
<AppProvider i18n={merchantLocale === 'vi' ? viTranslations : enTranslations}>
<App />
</AppProvider>