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.
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.
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)
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.
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.
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):
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.
/* CORRECT */.root {
}
.settingsSection {
}
.dragHandle {
}
.closeIcon {
}
/* WRONG — do not use PascalCase or kebab-case */.ItemRoot {
}
.settings-section {
}
Cascading selectors are discouraged. Instead of styling through parent-child relationships, assign a class directly to the element that needs styling.
/* 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:
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)
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.125rem (2px)
2px
2 (number)
2px (keep literal, no token)
0.2rem (3.2px), 0.25rem (4px)
xs (4px)
"xs"
var(--mantine-spacing-xs)
0.5rem (8px)
sm (8px)
"sm"
var(--mantine-spacing-sm)
0.75rem (12px)
between sm/md
rem(12)
0.75rem (no exact token)
1rem (16px)
md (16px)
"md"
var(--mantine-spacing-md)
1.5rem (24px)
lg (24px)
"lg"
var(--mantine-spacing-lg)
2rem (32px)
xl (32px)
"xl"
var(--mantine-spacing-xl)
Border radius — snap to the nearest Mantine radius token:
Hardcoded value
Nearest token
CSS variable
0.25rem (4px)
xs (4px)
var(--mantine-radius-xs)
0.375rem (6px)
sm (6px)
var(--mantine-radius-sm)
0.5rem (8px)
md (8px)
var(--mantine-radius-md)
1rem+ (16px+)
xl (40px) or keep literal
var(--mantine-radius-xl)
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.
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.
Text renders as <p> by default. Use component="span" to render inline. All color, typography, and spacing props work directly — no CSS module needed for pure text styling.
When a styled component only changes spacing, sizing, or other style-prop-expressible values at breakpoints, use Mantine's responsive syntax instead of CSS module media queries.
After — responsive style prop (NO CSS module needed):
<Flex mb={{ base: "lg", xl: "xl" }}>
Breakpoint keys: base (default), xs (40em), sm (48em), md (60em), lg (80em), xl (120em).
Only use CSS module @media queries when the responsive change involves non-style-prop properties (e.g., box-shadow, border, cursor changes at breakpoints).
Inline styles are allowed only for truly dynamic values computed at runtime (e.g., widths, positions, colors from data). Everything else must use Mantine style props or CSS modules.
Do NOT introduce new CS utility class usage. Core CSS utilities (CS from metabase/css/core/index.css) are legacy and discouraged for new code. Prefer Mantine style props or CSS modules instead.
If existing code already uses CS classes and you're not migrating that specific code, leave them alone. But when migrating Emotion → Mantine, replace with the proper alternative:
When Emotion exports a shared css block (like animationStyles) imported by many files, create ONE CSS module with the shared keyframe and a reusable class, then compose via cx().
Before (Emotion — shared animation used by 13+ skeleton files):
The shared module owns the @keyframes and the .animated class. Each consumer composes it with its own module class via cx(). No duplication of the keyframe definition.
Import Template
import cx from"classnames"; // only if needed for conditional classesimport { Box, Flex, Group, Stack, Text, rem } from"metabase/ui";
import S from"./ComponentName.module.css"; // only if CSS module is needed
Checklist Before Finishing
MANTINE-FIRST CHECK: Every CSS property that has a style prop equivalent (c, bg, p, m, fw, fz, flex, gap, align, w, h, etc.) is expressed as a style prop, NOT in a CSS module
styled.span/styled.p with only color/weight/size are replaced with Text component="span" + style props, NOT CSS module classes
styled.div with only layout props are replaced with Flex/Stack/Group/Box + style props, NOT CSS module classes
CSS modules are used ONLY for: pseudo-selectors, box-shadow, border, cursor, overflow, animation, transition, transform, or complex selectors
All @emotion/styled and @emotion/react imports removed from migrated files
The .styled.tsx file is deleted
All imports of the deleted styled file are updated
No static inline styles — style={{ }} used only for truly dynamic runtime values
All colors use design tokens (c="brand", var(--mb-color-brand)), not raw hex/rgb
All spacing uses design tokens (p="md", var(--mantine-spacing-md)), not arbitrary px values — hardcoded rem/px literals snapped to the nearest token (see mapping table above)
All border-radius values use radius tokens (var(--mantine-radius-md)), not hardcoded 0.5rem
All font sizes use font-size tokens (fz="md", var(--mantine-font-size-md)), not hardcoded 1rem
Layout uses Mantine components (Flex, Stack, Group, Box, Text) not raw divs/spans with CSS
All CSS module class names use camelCase
No cascading/descendant selectors targeting raw elements — use direct class assignment instead
Component renders identically to the original (visually verify if possible)
Visual Verification with Screenshots (Optional — Only When Explicitly Asked)
When the user explicitly asks to create before/after screenshots, use the Playwright MCP tools to capture them. This is NOT done by default — only when requested.
Screenshots must be taken in both light and dark mode to verify token usage is correct in both themes.
Switching Color Scheme via API
Toggle between light and dark mode using the Metabase settings API. Use Playwright's browser_evaluate or browser_navigate with fetch:
After switching, reload the page for the theme to take effect.
Process
For each state (after = migrated code, before = stashed original), capture screenshots in both themes at both viewport widths. This produces 8 screenshots total.
Take "after" screenshots first (migrated code is already in place):
Navigate to the page that renders the migrated components (browser_navigate)
Wait for content to load (browser_wait_for with key text, or textGone: "Loading...")
Close the sidebar if it overlaps content on narrow viewports (browser_click the toggle button)
Light mode (set color-scheme to "light" via API, reload):
Desktop (1280x900): after-light-desktop.png
Narrow (640x900), close sidebar: after-light-narrow.png
Dark mode (set color-scheme to "dark" via API, reload):
Desktop (1280x900): after-dark-desktop.png
Narrow (640x900), close sidebar: after-dark-narrow.png
Stash changes to capture "before" screenshots:
Run git stash --include-untracked to temporarily revert to the Emotion version
Repeat the same 4 screenshots with before- prefix:
before-light-desktop.png, before-light-narrow.png
before-dark-desktop.png, before-dark-narrow.png
Run git stash pop to restore the migrated code
Restore the user's original color scheme (set back to "auto" or whatever it was before).
Display all 8 screenshots using the Read tool so the user can visually compare before/after in both themes at both viewport sizes.
Key Details
Save screenshots in the repo root (they will be untracked — user can delete after review)
The dev server must be running (typically http://localhost:3000)
Use browser_snapshot to find element refs when you need to click buttons (e.g., sidebar toggle)
The greeting text on the home page changes randomly on each load — this is expected, not a regression
If the page being tested is not the home page, navigate to the correct URL that exercises the migrated components
Dark mode screenshots are critical for verifying that CSS variable tokens (var(--mb-color-*)) are used correctly — hardcoded colors will look wrong in dark mode