---
name: emotion-migrate
description: Migrate Emotion styled-components to Mantine components with style props and CSS modules. Use when converting .styled.tsx files or removing @emotion imports from components.
---

# Emotion → Mantine + CSS Modules Migration Skill

Migrate Emotion styled-components (`@emotion/styled`, `@emotion/react`) to Mantine layout components with style props and CSS modules. The goal is zero Emotion imports, zero inline styles, and maximum use of design system tokens.

## Priority Order (Strict)

1. **Mantine components + style props** — `Box`, `Flex`, `Stack`, `Group`, `Text`, `Title`, `Card`. This is the DEFAULT. Every CSS property must be checked against style props FIRST.
2. **CSS modules** (`.module.css`) — ONLY for properties that Mantine style props genuinely cannot express: pseudo-selectors (`:hover`, `:focus`, `::before`), `box-shadow`, `border` shorthand, `animation`/`@keyframes`, complex selectors, `cursor`, `pointer-events`, `overflow`, `text-overflow`, `white-space`, `transition`.
3. **Inline styles ONLY for dynamic values** — `style={{ }}` is allowed only for truly dynamic runtime values (e.g., computed widths, positions, data-driven colors). All static styles must use Mantine props or CSS modules.

### Mantine-First Decision Gate (CRITICAL)

For EACH styled component, go through every CSS property and ask: "Can this be a Mantine style prop?" If yes → style prop. If no → CSS module. Do NOT dump an entire component into a CSS module just because one property needs it — split them.

**Properties that ARE style props** (use these, not CSS modules):

- `display` → `display` prop
- `color` → `c` prop (`c="core-brand"`, `c="text-primary"`)
- `background-color` → `bg` prop (`bg="background_page-primary"`)
- `font-size` → `fz` prop (`fz="md"`)
- `font-weight` → `fw` prop (`fw="bold"`)
- `line-height` → `lh` prop (`lh="md"`)
- `text-align` → `ta` prop (`ta="center"`)
- `padding` (all variants) → `p`, `px`, `py`, `pt`, `pb`, `pl`, `pr`
- `margin` (all variants) → `m`, `mx`, `my`, `mt`, `mb`, `ml`, `mr`
- `width` → `w`, `min-width` → `miw`, `max-width` → `maw`
- `height` → `h`, `min-height` → `mih`, `max-height` → `mah`
- `flex` → `flex` prop (`flex="0 0 auto"`, `flex={1}`)
- `gap` → `gap` prop (on Flex/Stack/Group)
- `align-items` → `align` prop (on Flex/Stack/Group)
- `justify-content` → `justify` prop (on Flex/Stack/Group)
- `flex-direction` → `direction` prop (on Flex)
- `flex-wrap` → `wrap` prop (on Flex)
- `position` → `pos` prop
- `top/right/bottom/left` → `top`, `right`, `bottom`, `left` props
- `opacity` → `opacity` prop

**Properties that NEED CSS modules** (no style prop equivalent):

- `:hover`, `:focus`, `:active`, `::before`, `::after` (pseudo-selectors)
- `box-shadow`, `border` (shorthand with color), `outline`
- `cursor`, `pointer-events`
- `overflow`, `text-overflow`, `white-space`
- `animation`, `transition`, `transform`
- `@media` queries (UNLESS it's simple responsive spacing/sizing — then use responsive syntax: `p={{ base: "md", lg: "xl" }}`)

**Hybrid approach** — when a component needs BOTH, put style props on the Mantine component AND add a CSS module class for the rest:

```tsx
<Flex
  className={S.root}       /* for :hover, box-shadow, border */
  align="center"            /* style prop */
  gap="sm"                  /* style prop */
  p="md"                    /* style prop */
  bg="background_page-primary"   /* style prop */
>
```

## CSS Module Conventions (Strict)

### Class Naming: camelCase

All CSS module class names MUST use **camelCase**. This is the dominant convention across the codebase (~830 camelCase vs ~620 PascalCase classes), used consistently in Mantine UI components, and matches standard CSS module conventions.

```css
/* CORRECT */
.root {
}
.settingsSection {
}
.dragHandle {
}
.closeIcon {
}

/* WRONG — do not use PascalCase or kebab-case */
.ItemRoot {
}
.settings-section {
}
```

Modifier/state classes also use camelCase:

```css
.selected {
}
.disabled {
}
.interactive {
}
.draggable {
}
```

### No Cascading — Direct Class Assignment

Cascading selectors are **discouraged**. Instead of styling through parent-child relationships, assign a class directly to the element that needs styling.

```css
/* WRONG — cascading/descendant selectors */
.root > input {
}
.root .label {
}
.container > div > span {
}

/* CORRECT — direct class on the target element */
.input {
}
.label {
}
.title {
}
```

The only acceptable nesting patterns are:

- **Pseudo-selectors on the same element**: `.item { &:hover { } }`
- **Modifier composition**: `.item { &.selected { } }`
- **Hover-reveal patterns** where a parent hover affects a child: `.root:hover .showOnHover { opacity: 1; }` — but only when structurally necessary (the child has no way to know about the parent's hover state)

### Import Alias

Always import the CSS module as `S`:

```tsx
import S from "./ComponentName.module.css";
```

## Step-by-Step Migration Process

### Step 1: Read and Understand

Read the `.styled.tsx` file AND every component that imports from it. Understand:

- Which styled components are used and where
- Which props drive dynamic styles
- Which styles are static vs conditional
- Which styles can map directly to Mantine style props

### Step 2: Classify Each Styled Component

For each styled component, apply the Mantine-First Decision Gate above. Then determine the migration target:

| Emotion Pattern                                              | Migration Target                                                                                                                                                                |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `styled.div` with only layout/spacing/color                  | `Box`, `Flex`, `Stack`, or `Group` with style props. **NO CSS module needed.**                                                                                                  |
| `styled.div` with flexbox column                             | `Stack` component with style props                                                                                                                                              |
| `styled.div` with flexbox row                                | `Flex` or `Group` component with style props                                                                                                                                    |
| `styled.span` / `styled.p` with color/weight/size            | **`Text component="span"` with style props** (`c`, `fw`, `fz`). NO CSS module.                                                                                                  |
| `styled.div` with hover/focus/pseudo-selectors               | **Hybrid**: Mantine component with style props for expressible properties + CSS module class for pseudo-selectors only                                                          |
| `styled.div` with media queries (simple spacing/sizing)      | **Responsive style props**: `p={{ base: "md", lg: "xl" }}`. NO CSS module.                                                                                                      |
| `styled.div` with media queries (complex/non-spacing)        | CSS module for the media query parts, style props for the rest                                                                                                                  |
| `styled.div` with animations/keyframes                       | CSS module for animation, style props for layout                                                                                                                                |
| `styled(SomeComponent)` with only color/spacing/flex         | **Wrap in Mantine component** with style props: `<Box c="core-brand" flex="0 0 auto"><Icon /></Box>`, or pass style props if the component accepts them                         |
| `styled(SomeComponent)` with pseudo-selectors/complex styles | CSS module `className` on the component                                                                                                                                         |
| Dynamic props `styled.div<{ isActive: boolean }>`            | Mantine style props for simple toggles (`c={active ? "core-brand" : "text-primary"}`), `cx()` with CSS module classes for complex state combinations involving pseudo-selectors |

### Step 3: Create CSS Module (if needed)

Create `ComponentName.module.css` alongside the component file:

```css
/* Use design system tokens — NEVER raw color/spacing values */
.root {
  border: 1px solid var(--mb-color-border-neutral);
  border-radius: var(--mantine-radius-sm);
  background-color: var(--mb-color-background_page-primary);
}

/* Conditional states as separate classes, combined with cx() */
.active {
  background-color: var(--mb-color-core-brand);
  color: var(--mb-color-text-primary-inverse);
}

.disabled {
  color: var(--mb-color-text-disabled);
  pointer-events: none;
}

/* Hover/focus/pseudo-selectors — nest with & */
.interactive {
  cursor: pointer;

  &:hover {
    color: var(--mb-color-core-brand);
    background-color: var(--mb-color-background_surface-hover);
  }
}

/* Hover-reveal: acceptable parent→child nesting */
.root:hover .showOnHover {
  opacity: 1;
}

/* Responsive styles */
@media (--breakpoint-min-md) {
  .root {
    padding: var(--mantine-spacing-lg);
  }
}
```

### Step 4: Update the Component TSX

```tsx
import cx from "classnames";
import CS from "metabase/css/core/index.css";
import { Box, Flex, Group, Stack, Text } from "metabase/ui";
import S from "./ComponentName.module.css";

// Dynamic props → cx() with conditional classes
<Flex
  className={cx(S.root, {
    [S.active]: isActive,
    [S.disabled]: disabled,
  })}
  align="center"
  gap="sm"
  p="md"
  w="100%"
>
```

### Step 5: Delete the `.styled.tsx` File

Remove the old styled file entirely. Remove all imports of it from other files.

### Step 6: Verify

- Confirm zero `@emotion/styled` or `@emotion/react` imports remain in the migrated files
- Confirm no static inline styles (dynamic runtime values are fine)
- Confirm all colors use tokens, not raw hex/rgb values

## Design System Token Reference

### Mantine Style Props (use directly on components)

**Spacing** (`p`, `px`, `py`, `pt`, `pb`, `pl`, `pr`, `m`, `mx`, `my`, `mt`, `mb`, `ml`, `mr`):

- `"xxxs"` = 0.125rem, `"xxs"` = 0.25rem, `"xs"` = 0.375rem, `"sm"` = 0.5rem, `"md"` = 0.75rem, `"lg"` = 1rem, `"xl"` = 1.5rem, `"xxl"` = 2rem, `"xxxl"` = 2.5rem
- Use numeric `0` for zero spacing.
- Custom: `rem(48)` for non-standard values (import `rem` from `metabase/ui`)

**Dimensions** (`w`, `h`, `maw`, `mah`, `miw`, `mih`):

- `"100%"`, `"100vh"`, `rem(400)`, etc.

**Colors** (`c`, `bg`):

- Text: `"text-primary"`, `"text-secondary"`, `"text-disabled"`
- Background: `"background_page-primary"`, `"background_page-secondary"`, `"background_surface-hover"`
- Brand: `"core-brand"`, `"feedback-negative"`, `"feedback-positive"`, `"feedback-warning"`

**Typography** (`fz`, `fw`, `lh`, `ta`, `ff`):

- Font size: `"xs"` = 11px, `"sm"` = 12px, `"md"` = 14px, `"lg"` = 17px, `"xl"` = 21px
- Font weight: `"bold"`, `"normal"`, or numeric `700`
- Line height: `"xs"` = 100%, `"sm"` = 115%, `"md"` = 122%, `"lg"` = 138%, `"xl"` = 150%
- Text align: `"center"`, `"left"`, `"right"`

**Flexbox** (`align`, `justify`, `gap`, `direction`, `wrap`, `flex`):

- `align="center"`, `justify="space-between"`, `gap="sm"`, `direction="column"`, `wrap="nowrap"`
- `flex={1}`, `flex="0 0 auto"`

**Other**: `pos` (position), `display`

**Responsive syntax**: `px={{ base: "md", md: "lg", lg: rem(48) }}`

### CSS Variable Tokens (use in `.module.css` files)

**Colors** — `var(--mb-color-<name>)`:

- `--mb-color-text-primary`, `--mb-color-text-secondary`, `--mb-color-text-disabled`
- `--mb-color-background_page-primary`, `--mb-color-background_page-secondary`, `--mb-color-background_surface-hover`
- `--mb-color-border`, `--mb-color-border-strong`, `--mb-color-border-subtle`
- `--mb-color-core-brand`, `--mb-color-core-brand-hover`
- `--mb-color-feedback-negative`, `--mb-color-feedback-positive`, `--mb-color-feedback-warning`
- `--mb-color-shadow`, `--mb-color-focus`
- `--mb-color-accent0` through `--mb-color-accent7`

**Mantine spacing** — `var(--mantine-spacing-<size>)`:

- `--mantine-spacing-xxxs` (0.125rem), `--mantine-spacing-xxs` (0.25rem), `--mantine-spacing-xs` (0.375rem), `--mantine-spacing-sm` (0.5rem), `--mantine-spacing-md` (0.75rem), `--mantine-spacing-lg` (1rem), `--mantine-spacing-xl` (1.5rem), `--mantine-spacing-xxl` (2rem), `--mantine-spacing-xxxl` (2.5rem)

**Mantine radius** — `var(--mantine-radius-<size>)`:

- `--mantine-radius-xxxs` (0.125rem), `--mantine-radius-xxs` (0.25rem), `--mantine-radius-xs` (0.375rem), `--mantine-radius-sm` (0.5rem), `--mantine-radius-md` (0.75rem), `--mantine-radius-lg` (1rem), `--mantine-radius-xl` (1.5rem)

**Mantine shadows (elevation)** — `var(--mantine-shadow-<size>)`:

- `--mantine-shadow-xs`, `--mantine-shadow-xs_outline`, `--mantine-shadow-sm`, `--mantine-shadow-sm_outline`, `--mantine-shadow-lg_outline`
- Light and dark modes use different shadow values (defined in `frontend/src/metabase/ui/theme.ts`)

**Mantine font sizes** — `var(--mantine-font-size-<size>)`:

- `--mantine-font-size-xs` (11px), `--mantine-font-size-sm` (12px), `--mantine-font-size-md` (14px), `--mantine-font-size-lg` (17px), `--mantine-font-size-xl` (21px)

**Breakpoints** (in `.module.css` files):

- `@media (--breakpoint-min-sm)` (40em / 640px)
- `@media (--breakpoint-min-md)` (60em / 960px)
- `@media (--breakpoint-min-lg)` (80em / 1280px)
- `@media (--breakpoint-min-xl)` (120em / 1920px)
- Also available: `--breakpoint-max-*` variants

### Snap Hardcoded Literals to Nearest Design Token (CRITICAL)

When migrating, **never carry over hardcoded `rem`/`px` values** from the original Emotion code. Instead, snap them to the nearest design system token. The original values were often arbitrary — the migration is an opportunity to align with the design system.

**Spacing** — snap to the nearest Mantine spacing token:

| Hardcoded value                   | Nearest token | Style prop | CSS variable                    |
| --------------------------------- | ------------- | ---------- | ------------------------------- |
| `0` (0px)                         | —             | `0`        | `0`                             |
| `0.125rem` (2px)                  | **xxxs**      | `"xxxs"`   | `var(--mantine-spacing-xxxs)`   |
| `0.2rem` (3.2px), `0.25rem` (4px) | **xxs**       | `"xxs"`    | `var(--mantine-spacing-xxs)`    |
| `0.375rem` (6px)                  | **xs**        | `"xs"`     | `var(--mantine-spacing-xs)`     |
| `0.5rem` (8px)                    | **sm**        | `"sm"`     | `var(--mantine-spacing-sm)`     |
| `0.75rem` (12px)                  | **md**        | `"md"`     | `var(--mantine-spacing-md)`     |
| `1rem` (16px)                     | **lg**        | `"lg"`     | `var(--mantine-spacing-lg)`     |
| `1.5rem` (24px)                   | **xl**        | `"xl"`     | `var(--mantine-spacing-xl)`     |
| `2rem` (32px)                     | **xxl**       | `"xxl"`    | `var(--mantine-spacing-xxl)`    |
| `2.5rem` (40px)                   | **xxxl**      | `"xxxl"`   | `var(--mantine-spacing-xxxl)`   |

Values between steps: keep the literal (e.g. `rem(10)`), don't round to a token.

**Border radius** — snap to the nearest Mantine radius token:

| Hardcoded value  | Nearest token | CSS variable                  |
| ---------------- | ------------- | ----------------------------- |
| `0` (0px)        | —             | `0`                           |
| `0.125rem` (2px) | **xxxs**      | `var(--mantine-radius-xxxs)`  |
| `0.25rem` (4px)  | **xxs**       | `var(--mantine-radius-xxs)`   |
| `0.375rem` (6px) | **xs**        | `var(--mantine-radius-xs)`    |
| `0.5rem` (8px)   | **sm**        | `var(--mantine-radius-sm)`    |
| `0.75rem` (12px) | **md**        | `var(--mantine-radius-md)`    |
| `1rem` (16px)    | **lg**        | `var(--mantine-radius-lg)`    |
| `1.5rem` (24px)  | **xl**        | `var(--mantine-radius-xl)`    |

Fully-round pills/badges (radius ≥ half the element height): keep a literal
such as `9999px`.

**Font sizes** — snap to the nearest Mantine font size token:

| Hardcoded value                  | Nearest token | Style prop | CSS variable                  |
| -------------------------------- | ------------- | ---------- | ----------------------------- |
| `0.6875rem` (11px)               | **xs** (11px) | `fz="xs"`  | `var(--mantine-font-size-xs)` |
| `0.75rem` (12px)                 | **sm** (12px) | `fz="sm"`  | `var(--mantine-font-size-sm)` |
| `0.875rem` (14px), `1rem` (16px) | **md** (14px) | `fz="md"`  | `var(--mantine-font-size-md)` |
| `1.063rem` (17px)                | **lg** (17px) | `fz="lg"`  | `var(--mantine-font-size-lg)` |
| `1.3rem` (21px)                  | **xl** (21px) | `fz="xl"`  | `var(--mantine-font-size-xl)` |

**Rules:**

- If the hardcoded value is within ~2px of a token, use the token
- If it falls exactly between two tokens, prefer the smaller one (tighter is safer)
- If no token is close (e.g., `48px` spacing), use `rem(48)` for style props or the literal value in CSS modules
- This applies everywhere: style props, CSS module values, Icon `size` props, etc.

## Common Migration Patterns

### Pattern 1: Static Layout Container → Mantine Component

**Before:**

```tsx
// Component.styled.tsx
export const Container = styled.div`
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 1rem;
  width: 100%;
`;
```

**After:**

```tsx
<Flex align="center" gap="sm" p="md" w="100%">
```

### Pattern 2: Vertical Stack → Stack Component

**Before:**

```tsx
export const Wrapper = styled.div`
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
  padding: 2rem;
`;
```

**After:**

```tsx
<Stack gap="lg" p="xl">
```

### Pattern 3: Dynamic Props → cx() with CSS Module Classes

**Before:**

```tsx
export const Item = styled.div<{ isSelected: boolean; disabled: boolean }>`
  color: ${(props) =>
    props.disabled ? color("text-disabled") : color("text-primary")};
  background-color: ${(props) =>
    props.isSelected ? color("core-brand") : "transparent"};
  cursor: ${(props) => (props.disabled ? "default" : "pointer")};

  &:hover {
    color: ${(props) => !props.disabled && color("core-brand")};
  }
`;
```

**After (CSS module):**

```css
/* Item.module.css */
.itemRoot {
  color: var(--mb-color-text-primary);
  cursor: pointer;

  &:hover {
    color: var(--mb-color-core-brand);
  }
}

.selected {
  background-color: var(--mb-color-core-brand);
}

.disabled {
  color: var(--mb-color-text-disabled);
  cursor: default;

  &:hover {
    color: var(--mb-color-text-disabled);
  }
}
```

**After (TSX):**

```tsx
<Box
  className={cx(S.itemRoot, {
    [S.selected]: isSelected,
    [S.disabled]: disabled,
  })}
>
```

### Pattern 4: Extending a Component → Style Props First, CSS Module Only If Needed

When a styled component wraps another component, first check if the styles can be expressed as style props on a Mantine wrapper or on the component itself. Only use a CSS module when properties genuinely need it.

**Before:**

```tsx
export const CardIcon = styled(Icon)`
  display: block;
  flex: 0 0 auto;
  color: var(--mb-color-core-brand);
`;

export const CardTitle = styled(Ellipsified)`
  color: var(--mb-color-text-primary);
  font-size: 1rem;
  font-weight: bold;
  margin-left: 1rem;
`;
```

**After — style props on Mantine wrapper (preferred when ALL properties are expressible):**

```tsx
<Box display="block" flex="0 0 auto" c="core-brand">
  <Icon {...icon} />
</Box>
<Box c="text-primary" fz="md" fw="bold" ml="md">
  <Ellip