- name
- migrate-component-to-scss-modules
- description
- Migrates an existing Emotion-styled React component in src/app/components to SCSS Modules. Use when asked to migrate, convert or batch-migrate components off Emotion (index.styles.ts / css prop) to index.module.scss, or when removing Emotion from a component.
# Migrate Component to SCSS Modules
Converts a component in `src/app/components/**` from Emotion (`index.styles.ts` + `css` prop) to SCSS Modules (`index.module.scss` + `className`).
Styling *rules* live in `.github/instructions/styling-standards.instructions.md` and auto-apply to `src/app/components/**`. This skill covers the *mechanical conversion*: token mapping, workflow and verification.
## Step 1: Triage before starting
Check the component against these exclusions and **stop and report** if any apply, rather than working around them:
| Condition | Why it blocks |
|---|---|
| Imports `legacy/components/Grid` or `psammead-grid` | Layout still renders Emotion-styled legacy grid; needs a layout decision, not a styling change |
| Passes a `css` prop into a legacy component | The legacy child consumes Emotion styles directly; `className` is not a drop-in replacement |
| Imports a legacy `index.styles` file | Depends on a legacy Emotion style object that isn't yours to convert |
| Uses a palette/spacing value as a runtime prop (not a style) | SCSS variables can't be passed as props; needs a design decision |
| Component is outside `src/app/components` | Legacy/container/psammead components need modernisation first |
Known blocked components: `Disclaimer`, `MostRead/Canonical/Item`, `MostRead/Canonical/List`, `MostRead/Label`, `RelatedTopics`, `Recommendations`, `RelatedContentSection`.
Rendering a legacy component is **not** by itself a blocker — `ArticleLinksBlock` renders `SkipLinkWrapper` and migrated cleanly. It only blocks when the legacy child controls layout or receives styles.
`getAmpLiteCss` inlines `.module.scss` chunks automatically, so AMP and Lite components need no special wiring. Minimize the compiled CSS for every edition; if the component renders in AMP, manually verify that the final inlined `style[amp-custom]` payload remains below AMP's hard 75 KB limit.
Legacy imports that are **pure utilities** (e.g. `formatDuration` from `psammead-timestamp-container`) do not block migration.
Before converting a shared component, search its consumers for Emotion `css` props such as `css={styles.inlineLink}`. An un-migrated consumer may intentionally replace the shared component's default styles; migrate that consumer in the same change or stop and report it as blocked.
When a consumer owns a real DOM context for an override, express that relationship in SCSS rather than adding a wrapper solely to increase specificity. Use an ancestor selector (for example `.errorLinkWrapper .inlineLink`) or an element-qualified selector when the component fixes the element type (for example `p.copyright`). Exclude pseudo-states that remain owned by the shared component, such as `:visited`, `:hover`, and `:focus`. CSS Modules scopes class names but does not give a parent component automatic precedence. Use duplicated selectors only as a documented last resort when no meaningful context exists.
## Step 2: Map Emotion theme values to SCSS tokens
All tokens are forwarded from `@scss/themeTokens`. Start every file with:
```scss
@use '@scss/themeTokens' as theme;
```
### Palette
Emotion `palette.SCREAMING_SNAKE` → `theme.$palette-kebab-case`.
| Emotion | SCSS |
|---|---|
| `palette.WHITE` | `theme.$palette-white` |
| `palette.GREY_6` | `theme.$palette-grey-6` |
| `palette.GREY_10` | `theme.$palette-grey-10` |
| `palette.SHADOW` | `theme.$palette-shadow` |
| `palette.POSTBOX_30` | `theme.$palette-postbox-30` |
| `palette.MIDNIGHT_BLACK` | `theme.$palette-midnight-black` |
### Spacings
Emotion values are unitless numbers needing `rem`; SCSS values already include units, so interpolate directly.
| Emotion | SCSS |
|---|---|
| `spacings.HALF` | `theme.$spacings-half` |
| `spacings.FULL` | `theme.$spacings-full` |
| `spacings.DOUBLE` | `theme.$spacings-double` |
| `spacings.TRIPLE` | `theme.$spacings-triple` |
| `spacings.QUADRUPLE` | `theme.$spacings-quadruple` |
| `spacings.QUINTUPLE` | `theme.$spacings-quintuple` |
| `spacings.SEXTUPLE` | `theme.$spacings-sextuple` |
```scss
padding: #{theme.$spacings-double};
```
### Media queries
Emotion values include the `@media` prefix; SCSS variables contain only the condition, so they must be interpolated after `@media`.
| Emotion | SCSS |
|---|---|
| `mq.GROUP_1_MIN_WIDTH` | `theme.$mediaQueries-group-1-min-width` |
| `mq.GROUP_2_MIN_WIDTH` | `theme.$mediaQueries-group-2-min-width` |
| `mq.GROUP_3_MIN_WIDTH` | `theme.$mediaQueries-group-3-min-width` |
| `mq.GROUP_4_MIN_WIDTH` | `theme.$mediaQueries-group-4-min-width` |
| `mq.GROUP_5_MIN_WIDTH` | `theme.$mediaQueries-group-5-min-width` |
| `mq.GROUP_3_MAX_WIDTH` | `theme.$mediaQueries-group-3-max-width` |
| `mq.FORCED_COLOURS` | `theme.$mediaQueries-forced-colours` |
| `mq.POINTER` | `theme.$mediaQueries-pointer-fine` |
```scss
@media #{theme.$mediaQueries-group-3-min-width} { ... }
```
`GROUP_N_ONLY` and `GROUP_1_AND_GROUP_2` have no direct equivalent — compose min and max variables in a single query.
### Typography
When the Emotion style sets both a font size and variant, use the combined mixin:
```scss
@include theme.typography-from-scale-and-variant(<scale>, '<variant>');
```
The scale is unquoted and the variant is quoted kebab-case. The mixin emits the font family, style and weight together with responsive font size and line height. Remove any manual `line-height` the Emotion styles set alongside the GEL scale.
Use the lower-level `theme.fontSizes-gel-font-size(<scale>)` or `theme.fontVariants-gel-font-variant('<variant>')` mixin only when the existing component intentionally sets one without the other.
#### Runtime typography (size/variant known only from props)
Sass mixin arguments are resolved at compile time, so `typography-from-scale-and-variant` cannot consume values supplied by component props at runtime. When `size` and/or `fontVariant` are component props, use the paired runtime API instead:
```tsx
import { getTypographyCustomProperties } from '#app/components/ThemeProviderSCSSModules/typography';
<a className={styles.link} style={getTypographyCustomProperties({ size, fontVariant })} />;
```
```scss
.link {
@include theme.typography-from-custom-properties;
}
```
`getTypographyCustomProperties` writes inline `--gel-font-family`, `--gel-font-style`, `--gel-font-weight`, `--gel-font-size-group-{a,b,d}` and `--gel-line-height-group-{a,b,d}` custom properties from the runtime values; `typography-from-custom-properties` reads them back with `inherit` fallbacks. These runtime channel names are deliberately distinct from the static, variant-keyed theme tokens the compile-known mixin resolves directly (`--gel-font-variant-<variant>-font-family`, `--font-size-<scale>-group-{a,b,d}`) — see [typography.test.ts](../../../src/app/components/ThemeProviderSCSSModules/typography.test.ts) for the full static-vs-runtime contract.
Valid scales: `atlas`, `elephant`, `imperial`, `royal`, `foolscap`, `canon`, `trafalgar`, `paragon`, `doublePica`, `greatPrimer`, `bodyCopy`, `pica`, `longPrimer`, `brevier`, `minion`.
| Emotion | SCSS argument |
|---|---|
| `sansRegular` | `'sans-regular'` |
| `sansRegularItalic` | `'sans-regular-italic'` |
| `sansBold` | `'sans-bold'` |
| `sansBoldItalic` | `'sans-bold-italic'` |
| `sansLight` | `'sans-light'` |
| `serifRegular` | `'serif-regular'` |
| `serifMedium` | `'serif-medium'` |
| `serifMediumItalic` | `'serif-medium-italic'` |
| `serifBold` | `'serif-bold'` |
| `serifLight` | `'serif-light'` |
Font fallback resolution belongs to the theme layer. `ThemeProviderSCSSModules`
font variant files expose the raw variant variables and the shared
`fontVariantAliases.scss` file builds the fallback aliases consumed by the
component mixins. Do not copy fallback chains into a component helper or its
component tests; test changes to those chains at the theme/Sass layer.
### Pixel conversion
`pixelsToRem(3)` → `theme.pixelsToRem-px-to-rem(3)` (returns a value with `rem` units already applied).
### Grid widths
`gridWidths.scss` is forwarded as `theme.$gridWidths-*` (e.g. `theme.$gridWidths-grid-width-600`), already in `rem` — use it directly, no conversion needed.
## Step 3: Convert conditional logic out of JavaScript
| Emotion pattern | SCSS Module replacement |
|---|---|
| `css={[styles.base, cond && styles.modifier]}` | `clsx(styles.base, cond && styles.modifier)` |
| Theme callback reading dark UI state | `:global([data-is-dark-ui='true']) &` |
| `dir === 'rtl' ? ... : ...` | Logical properties (`padding-inline-start`) |
| Opera Mini branch | `:global(.is-opera-mini) &` |
| Per-instance numeric value | Inline CSS custom property + `var()` |
| Small set of named style variants | `data-*` attribute + `&[data-x='value']` selector |
| Large set of possible values (e.g. a GEL scale) | Typed helper + inline CSS custom properties consumed by one SCSS rule set |
### Conditional style arrays
The most common pattern in this codebase. Emotion's array syntax becomes composed class names.
The condition is a **prop**, not the style object — `styles.alignWithMargin` always exists and is always truthy. Most components name the two differently (`isActive && styles.tabActive`); `CallToActionLink` is the exception and is easy to misread as a single reference.
**Before:**
```tsx
<a css={[styles.link, alignWithMargin && styles.alignWithMargin]}>
```
**After:**
```tsx
import clsx from 'clsx';
<a className={clsx(styles.link, alignWithMargin && styles.alignWithMargin)}>
```
Emotion drops falsy entries from a `css` array automatically; `className` does not. `clsx` restores that behaviour, so this is a direct translation rather than a rewrite — a plain template literal would carry `&&` across as the literal string `false`:
```tsx
// ❌ class="link_x1 false" when alignWithMargin is false
<a className={`${styles.link} ${alignWithMargin && styles.alignWithMargin}`}>
```
Use `clsx` for any conditional combination of classes. Keep both classes as separate rules in the SCSS file rather than merging them into one.
This works when the second class adds **different** properties to the base. If the variant instead **replaces** the same properties (a different font size, say), apply one class or the other rather than both — two classes setting identical properties makes the result depend on their order in the compiled CSS:
```tsx
<h2 className={isLarge ? styles.titleLarge : styles.title}>
```
A **discrete variant** (boolean or small union) can legitimately select or add a class. What the styling standards prohibit is deriving a class from a **continuous or computed value**, or from `dir` — use a CSS custom property or logical properties for those.
### Props with many possible values
When a prop has many possible values, avoid creating a separate class and CSS
rule for every value unless the list is small. The browser downloads all of
those rules, even though each instance uses only one value.
For a small list, keep the value in a `data-*` attribute and select the matching
rule in SCSS:
```tsx
<a className={styles.link} data-size={size} />
```
```scss
.link[data-size='small'] {
font-size: 1rem;
}
```
For a larger list, use one set of responsive rules and pass the selected values
as CSS custom properties:
```tsx
import type { CSSProperties } from 'react';
type LinkSize = 'small' | 'large';
type LinkStyles = CSSProperties & {
'--link-font-size': string;
'--link-line-height': string;
};
const getStylesForSize = (size: LinkSize): LinkStyles => ({
'--link-font-size': size === 'small' ? '1rem' : '1.25rem',
'--link-line-height': size === 'small' ? '1.5' : '1.25',
});
<a className={styles.link} style={getStylesForSize(size)} />
```
```scss
.link {
font-size: var(--link-font-size, inherit);
line-height: var(--link-line-height, inherit);
}
```
Keep the code that maps prop values to custom-property values in one typed
helper or at the component boundary. Do not repeat that mapping in both
TypeScript and SCSS.
See [src/app/components/InlineLink/index.module.scss](../../../src/app/components/InlineLink/index.module.scss) for a concrete compact CSS custom-property bridge: `size` and `fontVariant` are mapped by [typography.ts](../../../src/app/components/ThemeProviderSCSSModules/typography.ts) to inline `--gel-*` values, which one responsive rule set consumes via `theme.typography-from-custom-properties`.
Keep the CSS small for every edition. If the component renders in AMP, measure
the final inlined `style[amp-custom]` payload and keep the total below AMP's
hard 75 KB limit. This is an additional AMP requirement; the CSS-minimization
guidance applies to all editions.
### Consumer-owned style overrides
When a migrated component needs a consumer-specific default style, keep the shared component's state rules and scope the override to the consumer's DOM context:
```scss
.errorLinkWrapper {
.inlineLink:not(:visited):not(:hover):not(:focus) {
color: theme.$palette-black;
border-bottom: #{theme.pixelsToRem-px-to-rem(1)} solid
theme.$palette-black;
}
}
```
Do not assume the order of classes in the HTML or the React parent-child relationship controls the CSS cascade. Do not add a wrapper only to manufacture a selector context. Custom properties are appropriate for genuinely configurable component values, but adding a new override variable for every consumer couples the shared component to its consumers.
## Step 4: Apply the change
GitHub에서 보기