con un clic
emotion-migrate
// 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.
[HINT] Descarga el directorio completo de la habilidad incluyendo SKILL.md y todos los archivos relacionados
| 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. |
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.
Box, Flex, Stack, Group, Text, Title, Card. This is the DEFAULT. Every CSS property must be checked against style props FIRST..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.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.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 propcolor → c prop (c="brand", c="text-primary")background-color → bg prop (bg="background-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, prmargin (all variants) → m, mx, my, mt, mb, ml, mrwidth → w, min-width → miw, max-width → mawheight → h, min-height → mih, max-height → mahflex → 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 proptop/right/bottom/left → top, right, bottom, left propsopacity → opacity propProperties that NEED CSS modules (no style prop equivalent):
:hover, :focus, :active, ::before, ::after (pseudo-selectors)box-shadow, border (shorthand with color), outlinecursor, pointer-eventsoverflow, text-overflow, white-spaceanimation, 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:
<Flex
className={S.root} /* for :hover, box-shadow, border */
align="center" /* style prop */
gap="sm" /* style prop */
p="md" /* style prop */
bg="background-primary" /* style prop */
>
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 {
}
Modifier/state classes also use camelCase:
.selected {
}
.disabled {
}
.interactive {
}
.draggable {
}
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:
.item { &:hover { } }.item { &.selected { } }.root:hover .showOnHover { opacity: 1; } — but only when structurally necessary (the child has no way to know about the parent's hover state)Always import the CSS module as S:
import S from "./ComponentName.module.css";
Read the .styled.tsx file AND every component that imports from it. Understand:
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="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 ? "brand" : "text-primary"}), cx() with CSS module classes for complex state combinations involving pseudo-selectors |
Create ComponentName.module.css alongside the component file:
/* Use design system tokens — NEVER raw color/spacing values */
.root {
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-md);
background-color: var(--mb-color-background-primary);
}
/* Conditional states as separate classes, combined with cx() */
.active {
background-color: var(--mb-color-brand);
color: var(--mb-color-text-primary-inverse);
}
.disabled {
color: var(--mb-color-text-tertiary);
pointer-events: none;
}
/* Hover/focus/pseudo-selectors — nest with & */
.interactive {
cursor: pointer;
&:hover {
color: var(--mb-color-brand);
background-color: var(--mb-color-background-hover);
}
}
/* Hover-reveal: acceptable parent→child nesting */
.root:hover .showOnHover {
opacity: 1;
}
/* Responsive styles */
@media (--breakpoint-min-md) {
.root {
padding: var(--mantine-spacing-lg);
}
}
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%"
>
.styled.tsx FileRemove the old styled file entirely. Remove all imports of it from other files.
@emotion/styled or @emotion/react imports remain in the migrated filesSpacing (p, px, py, pt, pb, pl, pr, m, mx, my, mt, mb, ml, mr):
"xs" = 4px, "sm" = 8px, "md" = 16px, "lg" = 24px, "xl" = 32pxrem(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-primary", "text-secondary", "text-tertiary""background-primary", "background-secondary", "background-hover""brand", "error", "success", "warning"Typography (fz, fw, lh, ta, ff):
"xs" = 11px, "sm" = 12px, "md" = 14px, "lg" = 17px, "xl" = 21px"bold", "normal", or numeric 700"xs" = 100%, "sm" = 115%, "md" = 122%, "lg" = 138%, "xl" = 150%"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) }}
.module.css files)Colors — var(--mb-color-<name>):
--mb-color-text-primary, --mb-color-text-secondary, --mb-color-text-tertiary--mb-color-background-primary, --mb-color-background-secondary, --mb-color-background-hover--mb-color-border, --mb-color-border-strong, --mb-color-border-subtle--mb-color-brand, --mb-color-brand-hover--mb-color-error, --mb-color-success, --mb-color-warning--mb-color-shadow, --mb-color-focus--mb-color-accent0 through --mb-color-accent7Mantine spacing — var(--mantine-spacing-<size>):
--mantine-spacing-xs (4px), --mantine-spacing-sm (8px), --mantine-spacing-md (16px), --mantine-spacing-lg (24px), --mantine-spacing-xl (32px)Mantine radius — var(--mantine-radius-<size>):
--mantine-radius-xs (4px), --mantine-radius-sm (6px), --mantine-radius-md (8px), --mantine-radius-xl (40px)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)--breakpoint-max-* variantsWhen 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:
48px spacing), use rem(48) for style props or the literal value in CSS modulessize props, etc.Before:
// Component.styled.tsx
export const Container = styled.div`
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
width: 100%;
`;
After:
<Flex align="center" gap="sm" p="md" w="100%">
Before:
export const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 2rem;
`;
After:
<Stack gap="lg" p="xl">
Before:
export const Item = styled.div<{ isSelected: boolean; disabled: boolean }>`
color: ${(props) =>
props.disabled ? color("text-tertiary") : color("text-primary")};
background-color: ${(props) =>
props.isSelected ? color("brand") : "transparent"};
cursor: ${(props) => (props.disabled ? "default" : "pointer")};
&:hover {
color: ${(props) => !props.disabled && color("brand")};
}
`;
After (CSS module):
/* Item.module.css */
.itemRoot {
color: var(--mb-color-text-primary);
cursor: pointer;
&:hover {
color: var(--mb-color-brand);
}
}
.selected {
background-color: var(--mb-color-brand);
}
.disabled {
color: var(--mb-color-text-tertiary);
cursor: default;
&:hover {
color: var(--mb-color-text-tertiary);
}
}
After (TSX):
<Box
className={cx(S.itemRoot, {
[S.selected]: isSelected,
[S.disabled]: disabled,
})}
>
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:
export const CardIcon = styled(Icon)`
display: block;
flex: 0 0 auto;
color: var(--mb-color-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):
<Box display="block" flex="0 0 auto" c="brand">
<Icon {...icon} />
</Box>
<Box c="text-primary" fz="md" fw="bold" ml="md">
<Ellipsified>{title}</Ellipsified>
</Box>
After — CSS module (use only when component has pseudo-selectors or non-expressible styles):
.icon {
color: var(--mb-color-brand);
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
&:hover {
color: var(--mb-color-brand-hover);
}
}
<Icon className={S.icon} name="warning" />
styled.span / styled.p with Text Styles → Text ComponentBefore:
export const Label = styled.span`
color: var(--mb-color-text-secondary);
font-weight: bold;
`;
export const Title = styled.span`
color: var(--mb-color-text-primary);
font-size: 1rem;
font-weight: bold;
margin-left: 0.5rem;
`;
After — Text component with style props (NO CSS module needed):
<Text component="span" c="text-secondary" fw="bold">
{label}
</Text>
<Text component="span" c="text-primary" fz="md" fw="bold" ml="sm">
{title}
</Text>
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.
Before:
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
export const Spinner = styled.div`
animation: ${spin} 1s infinite linear;
`;
After (CSS module):
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.spinner {
animation: spin 1s infinite linear;
}
After (TSX):
<Box className={S.spinner} />
| Emotion (JS) | CSS Module | Mantine Style Prop |
|---|---|---|
color("brand") | var(--mb-color-brand) | c="brand" or bg="brand" |
color("text-primary") | var(--mb-color-text-primary) | c="text-primary" |
color("border") | var(--mb-color-border) | N/A (use CSS module) |
alpha("brand", 0.2) | color-mix(in srgb, var(--mb-color-brand), transparent 80%) | N/A (use CSS module) |
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.
Before:
export const CaptionRoot = styled.div`
margin-bottom: 1.5rem;
${breakpointMinExtraLarge} {
margin-bottom: 2rem;
}
`;
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.
Before:
export const Bar = styled.div<{ width: number }>`
width: ${(props) => props.width}%;
`;
After:
<Box style={{ width: `${width}%` }} />
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:
Instead of CS.* | Use |
|---|---|
CS.overflowHidden | CSS module: overflow: hidden |
CS.cursorPointer | CSS module: cursor: pointer |
CS.flex1 | Style prop: flex={1} |
CS.flexNoShrink | Style prop: flex="0 0 auto" |
CS.textBrandHover | CSS module: &:hover { color: var(--mb-color-brand); } |
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):
// ChartSkeleton.styled.tsx — defines shared animation
const fadingKeyframes = keyframes`
0% { opacity: 0.0625; }
50% { opacity: 0.125; }
100% { opacity: 0.0625; }
`;
export const animationStyles = css`
opacity: 0.1;
animation: ${fadingKeyframes} 1.5s infinite;
`;
// AreaSkeleton.styled.tsx — consumes it
import { animationStyles } from ".../ChartSkeleton.styled";
export const SkeletonImage = styled.svg`
${animationStyles};
flex: 1 1 0;
margin-top: 1rem;
border-bottom: 1px solid currentColor;
`;
// BarSkeleton.styled.tsx — also consumes it
import { animationStyles } from ".../ChartSkeleton.styled";
export const SkeletonImage = styled.svg`
${animationStyles};
flex: 1 1 0;
margin-top: 1rem;
`;
After — shared CSS module:
/* ChartSkeleton.module.css */
@keyframes fading {
0% {
opacity: 0.0625;
}
50% {
opacity: 0.125;
}
100% {
opacity: 0.0625;
}
}
.animated {
opacity: 0.1;
animation: fading 1.5s infinite;
}
After — per-component CSS modules with own styles only:
/* AreaSkeleton.module.css */
.skeletonImage {
flex: 1 1 0;
margin-top: 1rem;
border-bottom: 1px solid currentColor;
}
/* BarSkeleton.module.css */
.skeletonImage {
flex: 1 1 0;
margin-top: 1rem;
}
After — component TSX uses cx() to compose:
// AreaSkeleton.tsx
import cx from "classnames";
import ChartSkeletonS from "../ChartSkeleton/ChartSkeleton.module.css";
import S from "./AreaSkeleton.module.css";
const AreaSkeleton = (): JSX.Element => {
return (
<svg
className={cx(ChartSkeletonS.animated, S.skeletonImage)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 371 113"
preserveAspectRatio="none"
>
<path d="..." fill="currentColor" />
</svg>
);
};
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 cx from "classnames"; // only if needed for conditional classes
import { Box, Flex, Group, Stack, Text, rem } from "metabase/ui";
import S from "./ComponentName.module.css"; // only if CSS module is needed
c, bg, p, m, fw, fz, flex, gap, align, w, h, etc.) is expressed as a style prop, NOT in a CSS modulestyled.span/styled.p with only color/weight/size are replaced with Text component="span" + style props, NOT CSS module classesstyled.div with only layout props are replaced with Flex/Stack/Group/Box + style props, NOT CSS module classesmb={{ base: "lg", xl: "xl" }}), NOT CSS module @media queries@emotion/styled and @emotion/react imports removed from migrated files.styled.tsx file is deletedstyle={{ }} used only for truly dynamic runtime valuesc="brand", var(--mb-color-brand)), not raw hex/rgbp="md", var(--mantine-spacing-md)), not arbitrary px values — hardcoded rem/px literals snapped to the nearest token (see mapping table above)var(--mantine-radius-md)), not hardcoded 0.5remfz="md", var(--mantine-font-size-md)), not hardcoded 1remFlex, Stack, Group, Box, Text) not raw divs/spans with CSSWhen 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.
Toggle between light and dark mode using the Metabase settings API. Use Playwright's browser_evaluate or browser_navigate with fetch:
// Switch to dark mode
await fetch("/api/setting/color-scheme", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: "dark" }),
});
// Switch to light mode
await fetch("/api/setting/color-scheme", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: "light" }),
});
After switching, reload the page for the theme to take effect.
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):
browser_navigate)browser_wait_for with key text, or textGone: "Loading...")browser_click the toggle button)color-scheme to "light" via API, reload):
after-light-desktop.pngafter-light-narrow.pngcolor-scheme to "dark" via API, reload):
after-dark-desktop.pngafter-dark-narrow.pngStash changes to capture "before" screenshots:
git stash --include-untracked to temporarily revert to the Emotion versionbefore- prefix:
before-light-desktop.png, before-light-narrow.pngbefore-dark-desktop.png, before-dark-narrow.pnggit stash pop to restore the migrated codeRestore 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.
http://localhost:3000)browser_snapshot to find element refs when you need to click buttons (e.g., sidebar toggle)var(--mb-color-*)) are used correctly — hardcoded colors will look wrong in dark mode