← Files Shopify App BuilderARCHIVED FILE

skills/ux-polaris-antipatterns/SKILL.md

31.6 KB · Oct 5, 2026 · 18:30 UTC

↓ Download file

---
name: ux-polaris-antipatterns
description: "Use when reviewing or writing Polaris UI for common design mistakes — using deprecated Stack instead of BlockStack/InlineStack, modal overuse, blocking validation, wrong tone (success/critical/warning/info), custom CSS overrides instead of tokens, off-brand colors, mis-sized cards, missing helpText, no FormLayout, mobile responsive failures, accessibility failures inside Polaris components, drifting between Polaris versions. Triggers: 'polaris mistake', 'polaris anti-pattern', 'polaris stack deprecated', 'polaris design review', 'shopify ui code review', 'polaris best practice', 'polaris vs custom', 'polaris tokens'."
---

# Polaris UI Anti-Patterns (v12+)

A field guide to the 20 most common ways Shopify app teams break the Polaris contract — and the exact fix for each. Use this when reviewing a PR, writing a new screen, or auditing an app pre-submission for Built for Shopify.

The principle behind every rule: **Shopify admin is a shared design surface.** Merchants have already learned its patterns. The closer your app feels to the rest of admin, the higher your install-to-activation rate. The further you drift, the more your app feels like a third-party graft — and the more support tickets you get from confused merchants.

---

## 1. When to use

Pull this skill in when you are:

- Reviewing a pull request that touches `@shopify/polaris` components
- Writing a new screen and unsure whether a custom component is warranted
- Migrating from Polaris 10 or 11 to 12+
- Preparing an app for App Store submission or Built for Shopify certification
- Debugging "why does our app look off-brand?" complaints
- Diagnosing accessibility audit failures
- Deciding between `<Modal>`, `<Banner>`, `<Toast>`, or inline error
- Choosing between custom CSS and design tokens

If the user mentions Polaris, Stack, BlockStack, design tokens, `--p-color-*`, `className` on Polaris components, Modal overuse, or "this feels off-brand" — trigger this skill.

---

## 2. The 20 anti-patterns, ranked by severity

Ranked by how badly each one degrades the merchant experience (1 = catastrophic, 20 = nit). Fixes are in the "do this instead" column.

| # | Anti-pattern | Severity | Do this instead |
|---|---|---|---|
| 1 | Using `<Stack>` / `<LegacyStack>` / `<VerticalStack>` / `<HorizontalStack>` | Build-breaking on v12+ | `<BlockStack>` (vertical) or `<InlineStack>` (horizontal). Run `npx @shopify/polaris-migrator` |
| 2 | Wrapping Polaris components in custom `className` for styling | High — most Polaris components do not accept `className` | Use the component's built-in props (`tone`, `variant`, `padding`, `gap`). For exceptions, use `<Box>` with token props |
| 3 | Hex codes (`color: #008060`) instead of design tokens | High — instantly off-brand on theme changes | `var(--p-color-bg-fill-success)` or a `tone` prop |
| 4 | Modal for non-blocking errors or confirmations | High — blocks the merchant; high abandonment | `<Banner>` for in-context info, `<Toast>` for transient confirmations, inline error for forms |
| 5 | Toast for errors that need explanation | High — disappears in 3s, easily missed | `<Banner tone="critical">` with title + body + recovery action |
| 6 | Using `tone="success"` for neutral or informational | Medium — semantic noise | `tone="info"` for info, no tone for neutral |
| 7 | Using `tone="critical"` for warnings | Medium — desensitizes the merchant to real errors | `tone="warning"` for cautionary, `tone="critical"` only for destructive/error |
| 8 | Missing `<FormLayout>` around form fields | Medium — inconsistent spacing | Always wrap related fields in `<FormLayout>` inside `<Form>` |
| 9 | Blocking validation on every keystroke | Medium — feels hostile, breaks flow | Validate on blur or on submit; clear errors on next change |
| 10 | Missing `helpText` on non-obvious fields | Medium — drives support tickets | `helpText` describing format, examples, or constraints |
| 11 | Forgetting to import `@shopify/polaris/build/esm/styles.css` | High — entire app looks unstyled | Import once in the app root, before `<AppProvider>` |
| 12 | More than 2 filled/shaped buttons in one Card | Medium — destroys hierarchy | One primary, one secondary, rest as plain or inside `<ActionList>` |
| 13 | `<IndexTable>` on mobile without responsive plan | Medium — horizontal scroll hell on phones | Hide columns or switch to `<ResourceList>` below the mobile breakpoint |
| 14 | Modal sized larger than the mobile viewport | Medium — content clipped on phones | `size="small"` for mobile flows; never assume desktop |
| 15 | Icon-only buttons missing `accessibilityLabel` | Medium — screen readers see nothing | Always pass `accessibilityLabel` to icon-only `<Button>` |
| 16 | Error states relying on color alone | Medium — fails WCAG, fails colorblind users | Pair red border with text error message and icon |
| 17 | Importing old icon names (`DeleteMinor`, `EditMajor`) | Medium — works on v10–11, gone in v12+ | Use new icon names (`DeleteIcon`, `EditIcon`) |
| 18 | Custom focus styles that override Polaris focus rings | Medium — keyboard users get lost | Leave focus rings alone, or use `--p-focused` tokens |
| 19 | Card with no padding wrapper | Low — content touches Card edge | `<Box padding="400">` inside, or use `<Card padding="400">` (v12+) |
| 20 | Mixing `tone="subdued"` with low-contrast text on top | Low — fails AA contrast | Don't stack subdued tones; use one level of de-emphasis |

---

## 3. Layout anti-patterns (deepest dive)

### 3.1. The deprecated Stack family

In Polaris 10 there was `<Stack>` with `vertical` and `distribution` props. Polaris 11 split it into `<VerticalStack>` and `<HorizontalStack>`. Polaris 12 renamed those to `<BlockStack>` and `<InlineStack>` to match CSS logical-property language (block axis = vertical, inline axis = horizontal).

Anything still using the old names is either dead code or a build error on v12.

**Wrong (Polaris 10):**
```tsx
<Stack vertical spacing="loose">
  <TextField label="Name" value={name} onChange={setName} />
  <TextField label="SKU" value={sku} onChange={setSku} />
</Stack>
```

**Wrong (Polaris 11):**
```tsx
<VerticalStack gap="4">
  <TextField label="Name" value={name} onChange={setName} />
</VerticalStack>
```

**Right (Polaris 12+):**
```tsx
<BlockStack gap="400">
  <TextField label="Name" value={name} onChange={setName} />
  <TextField label="SKU" value={sku} onChange={setSku} />
</BlockStack>
```

Note the gap scale also changed: `"loose"` / `"4"` became `"400"` (the numeric token scale). Migrator handles this:

```bash
npx @shopify/polaris-migrator react-rename-component@v12 \
  --renameFrom=VerticalStack --renameTo=BlockStack \
  "src/**/*.tsx"
```

### 3.2. Modal overuse

Shopify's own guidance: modals block the merchant until they make a decision. They are a power move. Reach for them only when:

1. The action is consequential and reversible needs explicit consent (delete, publish, send invoice)
2. You need a focused mini-form that does not fit in the existing page
3. You are showing a confirmation step that genuinely benefits from an interruption

Do **not** use modals for:

- Telling a merchant that something went wrong (use `<Banner>` in-context)
- Confirming a successful save (use `<Toast>`)
- Showing extra information the merchant can read later (use `<Popover>` or expand a section)
- "Are you sure?" on non-destructive actions (just do the action; offer undo via Toast)

### 3.3. Oversized cards

A common mistake: stuffing an entire feature into a single Card with deeply nested sections. This breaks visual hierarchy and forces merchants to scroll past unrelated content.

**Wrong:**
```tsx
<Card>
  <Card.Section title="Basic info">...</Card.Section>
  <Card.Section title="Pricing">...</Card.Section>
  <Card.Section title="Inventory">...</Card.Section>
  <Card.Section title="Shipping">...</Card.Section>
  <Card.Section title="SEO">...</Card.Section>
  <Card.Section title="Advanced">...</Card.Section>
</Card>
```

**Right:**
```tsx
<BlockStack gap="400">
  <Card>
    <BlockStack gap="300">
      <Text variant="headingMd" as="h2">Basic info</Text>
      {/* fields */}
    </BlockStack>
  </Card>
  <Card>
    <BlockStack gap="300">
      <Text variant="headingMd" as="h2">Pricing</Text>
      {/* fields */}
    </BlockStack>
  </Card>
  {/* one Card per logical section */}
</BlockStack>
```

Rule of thumb: a Card is a unit of meaning. If two sections do not belong on the same screen for the same task, they should be separate Cards. If a Card has more than 3 sections, split it.

---

## 4. Form anti-patterns

### 4.1. Wrong field types

Shopify admin merchants type tens of fields per day. Wrong field types cost them speed and trust.

| Wrong | Right | Why |
|---|---|---|
| `<TextField>` for currency without `prefix="$"` and `type="number"` | `<TextField type="currency" prefix="$">` | Mobile keyboard, alignment, parsing |
| `<TextField>` for a fixed choice | `<Select>` or `<ChoiceList>` | Free-text invites errors |
| `<Select>` with 2 options | `<Checkbox>` or `<RadioButton>` | Two-state UI should not require a dropdown |
| `<Checkbox>` for "either A or B" | `<RadioButton>` group | Checkbox implies independent toggles |
| Inline `<input>` mixed with Polaris fields | `<TextField>` | Inconsistent focus rings, sizing, error states |

### 4.2. Blocking validation

**Wrong (validates on every keystroke):**
```tsx
const handleChange = (value: string) => {
  setName(value);
  if (value.length < 3) {
    setNameError("Must be at least 3 characters");
  }
};
```

The merchant types "Ab" and immediately sees an error before they can type the third letter. This is hostile.

**Right (validates on blur, clears on change):**
```tsx
const handleChange = (value: string) => {
  setName(value);
  if (nameError) setNameError(""); // clear on next change
};

const handleBlur = () => {
  if (name.length > 0 && name.length < 3) {
    setNameError("Must be at least 3 characters");
  }
};

<TextField
  label="Name"
  value={name}
  onChange={handleChange}
  onBlur={handleBlur}
  error={nameError}
/>
```

For form-level validation (required fields, cross-field rules) — validate on submit, surface all errors via `<Banner tone="critical">` at the top of the form **plus** inline `error` props on each field.

### 4.3. Missing FormLayout

`<FormLayout>` enforces the correct vertical rhythm and field grouping. Without it, fields touch each other or float with wrong spacing.

**Wrong:**
```tsx
<Form onSubmit={handleSubmit}>
  <TextField label="Name" value={name} onChange={setName} />
  <TextField label="Email" value={email} onChange={setEmail} />
  <Button submit>Save</Button>
</Form>
```

**Right:**
```tsx
<Form onSubmit={handleSubmit}>
  <FormLayout>
    <TextField label="Name" value={name} onChange={setName} />
    <TextField label="Email" value={email} onChange={setEmail} />
  </FormLayout>
  <Button submit>Save</Button>
</Form>
```

Also use `<FormLayout.Group>` to put related short fields on the same row (first name + last name, city + zip).

---

## 5. Color and tone anti-patterns

Polaris uses semantic `tone` props rather than direct colors. Four tones cover almost everything:

| Tone | Meaning | Use for |
|---|---|---|
| `success` | Positive completion | Order shipped, settings saved, payment received |
| `critical` | Destructive or error | Delete, payment failed, validation error |
| `warning` | Cautionary, reversible | Low stock, plan limit, draft will be lost |
| `info` | Neutral information | New feature notice, helpful tip, ongoing sync |

Plus tone modifiers on text: `tone="subdued"` for de-emphasized copy.

**Common misuses:**

```tsx
// WRONG — success tone for a neutral confirmation
<Banner tone="success">Your settings are saved automatically.</Banner>

// RIGHT
<Banner tone="info">Your settings are saved automatically.</Banner>
```

```tsx
// WRONG — critical for a warning
<Banner tone="critical">You have 3 days left in your trial.</Banner>

// RIGHT
<Banner tone="warning">You have 3 days left in your trial.</Banner>
```

```tsx
// WRONG — no tone, custom red color
<Banner style={{ borderColor: "red" }}>Action required</Banner>

// RIGHT
<Banner tone="warning" title="Action required">...</Banner>
```

Rule: if you reach for a hex code in a Polaris context, you are probably picking the wrong tone instead.

---

## 6. Custom CSS overrides — when prohibited

Polaris components are designed as a closed contract. Most do **not** accept a `className` prop. Even when you can shim CSS in via wrappers, doing so is an anti-pattern.

**Hard no:**
- Overriding internal selectors via `.Polaris-Button { ... }` in global CSS
- Wrapping in a `<div className="my-custom-styles">` and targeting child Polaris classes
- Using `!important` to win against Polaris styles
- Patching at runtime via `style={{ }}` on Polaris components that do not document a `style` prop

**Acceptable:**
- Using `<Box>` with token props (`padding`, `background`, `borderRadius`, `borderColor`) to wrap Polaris content
- Adding custom CSS to your own non-Polaris components, using `var(--p-*)` tokens
- Using documented props (`tone`, `variant`, `size`, `gap`)
- Wrapping the entire app shell in a layout div with custom CSS (outermost only)

**The decision rule:** if you want to change how a Polaris component looks, your three options are:

1. Use a documented prop (`tone`, `variant`, `size`).
2. Wrap with `<Box>` and adjust spacing/background.
3. Build a custom component using `<Box>` + Polaris tokens and stop using the Polaris component for that case.

There is no fourth option. Reaching past the contract via CSS will break on every Polaris minor release.

---

## 7. Off-brand styles — use design tokens

Polaris exposes its design tokens as CSS custom properties prefixed `--p-`. They are the single source of truth for color, spacing, radius, shadow, type, motion.

### 7.1. Most-used token families

| Family | Examples |
|---|---|
| Color — surface | `--p-color-bg-surface`, `--p-color-bg-surface-secondary`, `--p-color-bg-surface-hover` |
| Color — fill | `--p-color-bg-fill`, `--p-color-bg-fill-success`, `--p-color-bg-fill-warning`, `--p-color-bg-fill-critical` |
| Color — text | `--p-color-text`, `--p-color-text-subdued`, `--p-color-text-success`, `--p-color-text-critical` |
| Color — border | `--p-color-border`, `--p-color-border-subdued`, `--p-color-border-focused` |
| Spacing | `--p-space-100` (4px), `--p-space-200` (8px), `--p-space-300` (12px), `--p-space-400` (16px), `--p-space-500` (20px), `--p-space-600` (24px), `--p-space-800` (32px) |
| Border radius | `--p-border-radius-100`, `--p-border-radius-200`, `--p-border-radius-300` |
| Shadow | `--p-shadow-100`, `--p-shadow-200`, `--p-shadow-300` |
| Typography | `--p-font-size-300`, `--p-font-weight-medium`, `--p-font-line-height-500` |

### 7.2. Off-brand examples

```css
/* WRONG — hardcoded brand color, breaks if Shopify rebrands or merchant theme changes */
.my-success-pill {
  background: #008060;
  color: white;
  padding: 8px 12px;
  border-radius: 4px;
}

/* RIGHT — uses Polaris tokens, follows admin theme automatically */
.my-success-pill {
  background: var(--p-color-bg-fill-success);
  color: var(--p-color-text-on-color);
  padding: var(--p-space-200) var(--p-space-300);
  border-radius: var(--p-border-radius-200);
}
```

**Even better:** don't write the CSS at all. Use `<Badge tone="success">Active</Badge>`.

Always prefer **semantic** tokens (`--p-color-bg-fill-success`) over **primitive** tokens (`--p-color-green-500`). Primitive tokens may shift; semantic tokens hold their meaning across themes and versions.

---

## 8. Mobile responsive failures

Shopify admin is used on mobile by ~40% of merchants on certain workflows (order check, inventory tweak, support reply). Embedded apps that assume desktop fail in production.

### 8.1. IndexTable on narrow viewports

`<IndexTable>` is wide by default. On a 375px viewport, anything past 3 columns scrolls horizontally inside an iframe — confusing and easy to miss.

**Fixes, ranked:**

1. **Hide columns below a breakpoint.** Polaris does not have a built-in `hideOnMobile`; use a conditional render based on viewport width.
   ```tsx
   const isMobile = useMediaQuery("(max-width: 768px)");

   const headings = isMobile
     ? [{ title: "Name" }, { title: "Status" }]
     : [
         { title: "Name" },
         { title: "SKU" },
         { title: "Inventory" },
         { title: "Status" },
         { title: "Last updated" },
       ];
   ```
2. **Switch to `<ResourceList>` for mobile.** Lists stack naturally; tables do not.
3. **Pin the most important column.** Polaris IndexTable supports sticky columns since v12.

### 8.2. Modal sizing

```tsx
// WRONG — assumes desktop; clips on phones
<Modal open={open} onClose={close} title="Edit" size="large">

// RIGHT — small modal on mobile, large on desktop
<Modal
  open={open}
  onClose={close}
  title="Edit"
  size={isMobile ? "small" : "large"}
>
```

### 8.3. Card padding on small screens

Generous desktop padding (e.g., `padding="500"`) eats the small screen. Use responsive padding:

```tsx
<Card padding={{ xs: "300", md: "500" }}>
  ...
</Card>
```

### 8.4. Layout sections

Two-column layouts must collapse to one column under ~768px. `<Layout>` and `<InlineGrid>` handle this automatically only if you use the responsive `columns` prop:

```tsx
// WRONG — always two columns, broken on mobile
<InlineGrid columns="1fr 1fr" gap="400">

// RIGHT — collapses to one column on mobile
<InlineGrid columns={{ xs: 1, md: 2 }} gap="400">
```

---

## 9. Accessibility failures inside Polaris

Polaris is WCAG 2.1 AA compliant out of the box. App teams break that compliance with custom code around Polaris.

### 9.1. Missing labels

```tsx
// WRONG — no label, no accessibility label
<TextField placeholder="Search..." value={query} onChange={setQuery} />

// RIGHT
<TextField
  label="Search products"
  labelHidden
  placeholder="Search..."
  value={query}
  onChange={setQuery}
/>
```

`labelHidden` keeps the visual layout clean while still providing an accessible label to screen readers.

### 9.2. Icon-only buttons

```tsx
// WRONG — screen reader hears "Button"
<Button icon={DeleteIcon} onClick={handleDelete} />

// RIGHT
<Button
  icon={DeleteIcon}
  accessibilityLabel="Delete product"
  onClick={handleDelete}
/>
```

### 9.3. Focus order

When you build a modal or popover, the first focusable element should be the most likely action. Polaris handles this for `<Modal>` (first input or primary action gets focus). But if you build a custom flow:

- First focusable on open = main task action or first input
- Tab should move forward through fields in reading order
- Escape closes the modal
- Focus returns to the trigger element on close

If you implement a custom dropdown or popover, replicate this. Better: just use `<Popover>` and `<ActionList>`.

### 9.4. Contrast

Polaris tokens are designed to pass AA. Two common ways teams break contrast:

1. **Stacking subdued tones.** `<Text tone="subdued">` on `<Box background="bg-surface-secondary">` can drop below 4.5:1.
2. **Custom colors over branded backgrounds.** If you change a Card background via `<Box background="...">`, re-test all text inside it.

Run an Axe audit before submission. The most common embedded-app failures are: missing form labels, icon-only buttons without aria-label, and color-only error indication.

---

## 10. Migration drift — Polaris 10 → 11 → 12+

If your codebase has been around for more than 18 months, you have drift. Symptoms: components that worked have started warning, build size grew, some screens use new patterns and some use old.

### 10.1. Component renames (10 → 11 → 12)

| Polaris 10 | Polaris 11 | Polaris 12+ |
|---|---|---|
| `<Stack vertical>` | `<VerticalStack>` | `<BlockStack>` |
| `<Stack>` (horizontal) | `<HorizontalStack>` | `<InlineStack>` |
| `<Stack.Item>` | (n/a — children are direct) | (n/a) |
| `<TextStyle variation="strong">` | `<Text fontWeight="semibold">` | `<Text fontWeight="semibold">` |
| `<DisplayText>` | `<Text variant="headingXl">` | `<Text variant="headingXl">` |
| `<Heading>` | `<Text variant="headingMd">` | `<Text variant="headingMd">` |
| `<Subheading>` | `<Text variant="headingSm">` | `<Text variant="headingSm">` |
| `<Caption>` | `<Text variant="bodySm">` | `<Text variant="bodySm">` |
| `<Visually Hidden>` | `<VisuallyHidden>` | (use `labelHidden` or `Text visuallyHidden`) |
| `<Card sectioned>` | `<Card><Card.Section>` | `<Card padding="400">` |
| `<Stack spacing="loose">` | `gap="4"` | `gap="400"` |

### 10.2. Icon renames

Old: `DeleteMinor`, `EditMajor`, `SaveMinor`, `SearchMinor`, `CancelMinor`, `PlusMinor`, `MinusMinor`, `RefreshMinor`, `ChevronDownMinor`.

New (v12+): `DeleteIcon`, `EditIcon`, `SaveIcon`, `SearchIcon`, `XIcon`, `PlusIcon`, `MinusIcon`, `RefreshIcon`, `ChevronDownIcon`.

The "Minor / Major" suffix is gone. Always `*Icon`.

```tsx
// WRONG (Polaris 10–11)
import { DeleteMinor, EditMajor } from "@shopify/polaris-icons";

// RIGHT (Polaris 12+)
import { DeleteIcon, EditIcon } from "@shopify/polaris-icons";
```

### 10.3. Use the migrator

```bash
# Install once
npm install --save-dev @shopify/polaris-migrator

# Run all v11 → v12 migrations
npx @shopify/polaris-migrator migrate v11-react-rename-components "src/**/*.tsx"
npx @shopify/polaris-migrator migrate v12-react-replace-icons "src/**/*.tsx"
npx @shopify/polaris-migrator migrate v12-react-update-spacing-tokens "src/**/*.tsx"
```

After running, do a manual audit of:
- `<Card>` instances (sectioned → padding)
- Custom CSS using old spacing names
- Storybook stories
- Snapshot tests

---

## 11. Anti-pattern → fix table with before/after code

### 11.1. Deprecated Stack

```tsx
// BEFORE
<Stack vertical spacing="tight">
  <Stack.Item><TextField label="Name" /></Stack.Item>
  <Stack.Item><TextField label="SKU" /></Stack.Item>
</Stack>

// AFTER
<BlockStack gap="200">
  <TextField label="Name" />
  <TextField label="SKU" />
</BlockStack>
```

### 11.2. Modal overuse for confirmation

```tsx
// BEFORE — interrupts the merchant
<Modal
  open={savedOpen}
  onClose={() => setSavedOpen(false)}
  title="Saved!"
  primaryAction={{ content: "OK", onAction: () => setSavedOpen(false) }}
>
  <Modal.Section>Your changes are saved.</Modal.Section>
</Modal>

// AFTER — Toast for transient success
shopify.toast.show("Changes saved", { duration: 3000 });
// or in React:
<Toast content="Changes saved" onDismiss={dismiss} />
```

### 11.3. Toast for an error that needs detail

```tsx
// BEFORE — disappears in 3s, no recovery path
<Toast content="Failed" error onDismiss={dismiss} />

// AFTER — Banner with title, body, and recovery action
<Banner
  tone="critical"
  title="Could not save product"
  action={{ content: "Retry", onAction: handleRetry }}
>
  <p>The inventory API timed out. Your changes are not saved.</p>
</Banner>
```

### 11.4. Wrong tone

```tsx
// BEFORE
<Banner tone="success">You have 3 days left in your trial.</Banner>

// AFTER
<Banner tone="warning" title="Trial ending soon">
  You have 3 days left in your trial.
</Banner>
```

### 11.5. Hex colors

```tsx
// BEFORE
<div style={{ backgroundColor: "#d3f9d8", padding: "12px" }}>
  Connected
</div>

// AFTER (option A — use the component)
<Badge tone="success">Connected</Badge>

// AFTER (option B — use Box with tokens)
<Box background="bg-fill-success" padding="300">
  <Text tone="success">Connected</Text>
</Box>
```

### 11.6. className override

```tsx
// BEFORE — does nothing on most Polaris components; relies on fragile selectors
<Button className="my-custom-button">Save</Button>
// in CSS:
.my-custom-button { background: orange !important; }

// AFTER — use documented props
<Button variant="primary" tone="success">Save</Button>
```

### 11.7. No FormLayout

```tsx
// BEFORE
<Form onSubmit={submit}>
  <TextField label="Name" value={name} onChange={setName} />
  <TextField label="Email" value={email} onChange={setEmail} />
  <Checkbox label="Subscribe" checked={sub} onChange={setSub} />
  <Button submit>Save</Button>
</Form>

// AFTER
<Form onSubmit={submit}>
  <FormLayout>
    <TextField label="Name" value={name} onChange={setName} />
    <TextField label="Email" value={email} onChange={setEmail} />
    <Checkbox label="Subscribe" checked={sub} onChange={setSub} />
  </FormLayout>
  <Box paddingBlockStart="400">
    <Button submit variant="primary">Save</Button>
  </Box>
</Form>
```

### 11.8. Blocking keystroke validation

```tsx
// BEFORE
<TextField
  label="Name"
  value={name}
  onChange={(v) => {
    setName(v);
    setError(v.length < 3 ? "Too short" : "");
  }}
  error={error}
/>

// AFTER
<TextField
  label="Name"
  value={name}
  onChange={(v) => { setName(v); if (error) setError(""); }}
  onBlur={() => {
    if (name.length > 0 && name.length < 3) setError("Must be at least 3 characters");
  }}
  error={error}
  helpText="At least 3 characters"
/>
```

### 11.9. Old icon names

```tsx
// BEFORE
import { DeleteMinor, EditMajor, SaveMinor } from "@shopify/polaris-icons";

// AFTER
import { DeleteIcon, EditIcon, SaveIcon } from "@shopify/polaris-icons";
```

### 11.10. IndexTable on mobile

```tsx
// BEFORE — 6 columns, horizontal scroll on mobile
<IndexTable
  headings={[
    { title: "Name" }, { title: "SKU" }, { title: "Vendor" },
    { title: "Inventory" }, { title: "Price" }, { title: "Status" }
  ]}
  ...
/>

// AFTER — collapse to 2 columns under md
const isMobile = useBreakpoints().smDown;
<IndexTable
  headings={
    isMobile
      ? [{ title: "Name" }, { title: "Status" }]
      : [
          { title: "Name" }, { title: "SKU" }, { title: "Vendor" },
          { title: "Inventory" }, { title: "Price" }, { title: "Status" }
        ]
  }
  ...
/>
```

---

## 12. Decision tree

Use this when in doubt about which component to pick.

**Need to tell the merchant something happened.**

- Was the merchant's action successful, and they do not need to think about it further? → `<Toast>`
- Did something fail or need attention that the merchant should see, in context, but can still keep working? → `<Banner>` (use the right tone)
- Does the merchant need to confirm or make a decision before continuing? → `<Modal>`
- Is it a form field problem? → inline `error` prop on the field + `<Banner tone="critical">` summary at top of form

**Need to lay something out.**

- Vertical stack of items? → `<BlockStack gap="...">`
- Horizontal row of items? → `<InlineStack gap="...">`
- Grid of items, responsive? → `<InlineGrid columns="..." gap="...">`
- Wrapper with padding / background / border? → `<Box ...>`
- A full page section with title and actions? → `<Card>` containing `<BlockStack>` containing `<Text variant="headingMd">` + body

**Need to show data.**

- Tabular, with sorting / selection / bulk actions? → `<IndexTable>`
- Browseable list with custom item rendering? → `<ResourceList>`
- Static key-value pairs? → `<DescriptionList>` or `<BlockStack>` with labeled rows

**Need to collect input.**

- Single line of text? → `<TextField>`
- Multi-line text? → `<TextField multiline={4}>`
- Pick one of many? → `<Select>` (5+ options) or `<ChoiceList>` (2–4 options, visible)
- Toggle on/off? → `<Checkbox>` (independent) or `<RadioButton>` (mutually exclusive)
- Date? → `<DatePicker>`
- Wrap fields with consistent spacing? → `<FormLayout>` inside `<Form>`

**Need to style something custom.**

- Can I do it with a Polaris prop? → use the prop
- Can I do it with `<Box>` and tokens? → use `<Box>`
- Neither? → build a custom component using `var(--p-*)` tokens; do not override Polaris CSS

---

## 13. Code review checklist (20 items)

Walk through this list on every PR that touches a Polaris file. Each item maps to one of the anti-patterns above.

- [ ] **1. No deprecated Stack.** No imports of `Stack`, `LegacyStack`, `VerticalStack`, `HorizontalStack`. Only `BlockStack` and `InlineStack`.
- [ ] **2. No className on Polaris components.** Custom styling uses `<Box>` + token props, not class overrides.
- [ ] **3. No hex codes.** All colors come from `var(--p-color-*)` tokens or `tone` props.
- [ ] **4. Modals only for blocking decisions.** No modals for success confirmations or non-blocking errors.
- [ ] **5. Toasts only for transient success.** No toasts for errors that need explanation or recovery.
- [ ] **6. Tone semantics are correct.** Success = positive, critical = destructive/error, warning = cautionary, info = neutral.
- [ ] **7. Forms use `<FormLayout>`.** Fields inside `<Form>` are wrapped in `<FormLayout>` for consistent spacing.
- [ ] **8. Validation is not on keystroke.** Errors appear on blur or submit, not as the merchant is typing.
- [ ] **9. `helpText` exists for non-obvious fields.** Format, examples, or constraints are explained inline.
- [ ] **10. Polaris CSS is imported at app root.** `import "@shopify/polaris/build/esm/styles.css"` runs before `<AppProvider>`.
- [ ] **11. Max 2 filled buttons per Card.** Extras move to `<ActionList>` or plain buttons.
- [ ] **12. IndexTable hides columns on mobile.** Column set changes via `useBreakpoints` or media query.
- [ ] **13. Modal `size` is responsive.** Small on mobile, medium/large on desktop.
- [ ] **14. Icon-only buttons have `accessibilityLabel`.** No bare `<Button icon={...} />` without a label.
- [ ] **15. Errors do not rely on color alone.** Red border + text message + (optional) icon.
- [ ] **16. Icon names are the new style.** `DeleteIcon` not `DeleteMinor`. No `*Minor` or `*Major` suffix.
- [ ] **17. No overrides of Polaris focus rings.** Focus styling is left alone or uses `--p-focused` tokens.
- [ ] **18. Cards have padding.** Either `<Card padding="400">` (v12+) or wrapped in `<Box padding="400">`.
- [ ] **19. Subdued tones are not stacked.** `tone="subdued"` text is not on subdued surface without contrast check.
- [ ] **20. AppProvider wraps the tree.** Exactly one `<AppProvider i18n={...}>` at the root.

---

## Quick reference — token cheat sheet

```css
/* Spacing — use on padding, gap, margin */
--p-space-100   /* 4px  */
--p-space-200   /* 8px  */
--p-space-300   /* 12px */
--p-space-400   /* 16px */
--p-space-500   /* 20px */
--p-space-600   /* 24px */
--p-space-800   /* 32px */

/* Color — semantic */
--p-color-bg-surface
--p-color-bg-surface-secondary
--p-color-bg-fill-success
--p-color-bg-fill-warning
--p-color-bg-fill-critical
--p-color-text
--p-color-text-subdued
--p-color-text-success
--p-color-text-critical
--p-color-border
--p-color-border-focused

/* Radius */
--p-border-radius-100  /* 4px  */
--p-border-radius-200  /* 8px  */
--p-border-radius-300  /* 12px */

/* Shadow */
--p-shadow-100
--p-shadow-200
--p-shadow-300

/* Typography */
--p-font-size-300
--p-font-weight-medium
--p-font-line-height-500
```

When in doubt: use the component prop. When no prop fits: use `<Box>` with token props. When `<Box>` does not fit: write CSS using `var(--p-*)` tokens. Never reach past these three layers.

---

## Sources

- [Migrating from v11 to v12 — Shopify Polaris React](https://polaris-react.shopify.com/version-guides/migrating-from-v11-to-v12)
- [Block stack — Shopify Polaris React](https://polaris-react.shopify.com/components/layout-and-structure/block-stack)
- [Inline stack — Shopify Polaris React](https://polaris-react.shopify.com/components/layout-and-structure/inline-stack)
- [Legacy stack — Shopify Polaris React](https://polaris-react.shopify.com/components/deprecated/legacy-stack)
- [Error messages — Shopify Polaris](https://legacy.polaris.shopify.com/patterns/error-messages)
- [Common actions — Shopify Polaris](https://polaris.shopify.com/patterns/common-actions/best-practices)
- [Tokens — Shopify Polaris React](https://polaris-react.shopify.com/design/layout/layout-tokens)
- [Color tokens — Shopify Polaris React](https://polaris-react.shopify.com/design/colors/color-tokens)
- [polaris-tokens — GitHub](https://github.com/Shopify/polaris-tokens)
- [Shopify Polaris App Design: Build Review-Ready Apps — grumspot](https://grumspot.com/blog/shopify-polaris-app-design)

SHA-256: 4d393ae3e2d580ead0e799800011a14085ad75073aed8f9e09b10c467b8f7794