一键导入
component-css-modules-migration
// Migrate a click-ui component from styled-components to CSS Modules with byte-for-byte visual regression coverage. Use whenever a component still uses styled-components and is on the CSS Modules migration list.
// Migrate a click-ui component from styled-components to CSS Modules with byte-for-byte visual regression coverage. Use whenever a component still uses styled-components and is on the CSS Modules migration list.
| name | component-css-modules-migration |
| description | Migrate a click-ui component from styled-components to CSS Modules with byte-for-byte visual regression coverage. Use whenever a component still uses styled-components and is on the CSS Modules migration list. |
A repeatable procedure for migrating one click-ui component at a time from styled-components to CSS Modules + cva / cn. The pattern was first executed end-to-end in PR #1034 (ButtonGroup), which is the canonical reference if anything here is ambiguous.
The procedure is built around a strong, single claim: the migration commit changes nothing visible. Visual regression tests captured against the styled-components rendering must pass byte-for-byte against the CSS Modules rendering. No tolerance for "looks the same"; the snapshots either pass or they don't.
The migration PR is a pure styling refactor. Everything below is out of scope even when tempting:
aria-disabled, removing redundant role="button", plumbing aria-label)type="button", etc.):hover:not(:disabled), :focus-visible outlines, explicit disabled+active rules that the original didn't have)aria-label="..." to existing consumers)If the original styled-components has a bug (hover firing on disabled, missing focus ring, wrong disabled+active color), the migration preserves the bug. Fix it in a separate PR after the migration lands.
The reason this rule exists: the visual regression test is credible only because the migration commit changed nothing visible. If you bundle in a "small" a11y improvement, snapshots regenerate, and the "byte-for-byte" guarantee evaporates. The rule also makes the migration mechanical — there are no judgment calls about which improvements to include.
The only collateral change you may need: a narrow TypeScript widening (e.g. HTMLAttributes<HTMLButtonElement> → ButtonHTMLAttributes<HTMLButtonElement>) when the new test stories need a prop like disabled to typecheck. That's pure TS, no runtime effect — include it in the baseline commit and call it out.
styled-components (check src/components/<Name>/<Name>.tsx).packages/design-tokens/dist/tokens.css (search for --click-<component-name>-*).Button precedent is readable: src/components/Button/Button.tsx and src/components/Button/Button.module.css.yarn test:visual and yarn test:visual:update work (they invoke .scripts/bash/playwright-docker, which runs Playwright inside a Linux container). If they don't work, fix the tooling first as its own commit — don't work around it locally.Two commits. Each must leave main green on its own.
| # | Subject | One-line purpose |
|---|---|---|
| 1 | test(<Name>): add visual regression baseline before CSS Modules migration | Capture the current styled-components rendering as snapshots. |
| 2 | chore(<Name>): migrate styling from styled-components to CSS Modules | Replace styled-components with .module.css + cva/cn. Snapshots from #1 still pass byte-for-byte. |
If you need to fix repo tooling (broken script, version mismatch) to even run yarn test:visual, that goes in its own prep commit before commit 1 — e.g. chore(test:visual): <description>. Don't bundle tooling fixes into the migration.
src/components/<Name>/<Name>.stories.tsx — Extend the existing stories. There must be exactly one named story per visual variant the spec wants to screenshot. Reuse the structure from src/components/ButtonGroup/ButtonGroup.stories.tsx. Required scenarios: each type variant, selected/active state, disabled state (including disabled+active if applicable), fill-width / size variants, multi-select if applicable.tests/<area>/<name>.spec.ts — New Playwright spec. Mirror the structure from tests/buttons/button.spec.ts or tests/buttons/buttongroup.spec.ts. Cover:
getStoryUrl(storyId, theme) from tests/utils/index.tspage.locator('body').click() before Tab to anchor focus into page content (not browser chrome — known flakiness fix)<button> regardless of stylingpage.getByRole(...) matchers, not CSS attribute selectors like [role="button"]:nth-child(N)tests/<area>/<name>.spec.ts-snapshots/*.png — Generated, not committed by hand. See below.src/components/<Name>/<Name>.types.ts — Only touch this if the new stories don't typecheck. The acceptable change is widening (e.g. HTMLAttributes → ButtonHTMLAttributes). Anything more is scope creep.<Name>.tsx — the component stays on styled-components.<Name>.test.tsx — unit tests untouched.tests/**/*.spec.ts file unless the new stories forced an incidental change (rare; if so, put it in its own commit before this one).Always generate snapshots fresh via:
yarn test:visual:update tests/<area>/<name>.spec.ts
This runs Playwright inside the Linux Docker container (.scripts/bash/playwright-docker), so snapshots are named -chromium-linux.png and match what CI generates on ubuntu-latest. The same snapshots work on any host OS because the runtime is normalized via Docker; you do not need platform-agnostic snapshots.
Never cherry-pick PNG baselines from another branch or PR. They were captured against a different point in time and may hide drift that landed on main since.
After generating, run yarn test:visual tests/<area>/<name>.spec.ts to confirm green, then git add everything together (stories + spec + snapshots) and commit.
yarn test:visual tests/<area>/<name>.spec.ts — all green.yarn test <Name> — unit tests pass (nothing changed for them).yarn build — succeeds.src/components/<Name>/<Name>.module.css — New file. Translates the styled-components rules into CSS. Concrete rules:
theme.click.* reference. Find each ${({ theme }) => theme.click.x.y.z} in the styled-components source and replace with var(--click-x-y-z). The token names already exist in packages/design-tokens/dist/tokens.css.&:hover (no :not(:disabled) qualifier), write .component:hover — do NOT "improve" it to .component:hover:not(:disabled). If the source has no :focus-visible rule, don't add one. Preserving wrong behavior is the price of byte-for-byte verification.&[aria-pressed='true'] defined twice (once before :disabled, once after), the second appearance is doing real work via cascade — replicate that ordering.stylelint.config.js: block, block__element, block_modifier, block_modifier_value. Modifier values use underscores, not dashes.stylelint.config.js (display → position → box-model → flexbox → border → background → typography → ...). yarn lint:css is the source of truth.src/components/<Name>/<Name>.tsx — Replace styled-components with cva + cn:
import { cn, cva } from '@/lib/cva';
import styles from './<Name>.module.css';
const wrapperVariants = cva(styles.<root>, {
variants: { /* one per styled-components transient prop */ },
defaultVariants: { /* match the component's defaults */ },
});
Render the wrapper with className={cn(wrapperVariants({ ... }), className)}. The DOM tree must be byte-identical to before:
role="button" on a <button> (redundant but present), keep role="button" on the migrated <button>. If it had role="group" on a <div>, keep it. Don't add new attributes (no aria-disabled, no type="button").$active, $fillWidth, $type, etc.) — they were styled-components plumbing and never appeared in the DOM.className passthrough on both levels. Destructure className from the component's own props and pass through cn(wrapperVariants(...), className). For each child element that accepts consumer props (e.g. options.map(({ value, label, ...buttonProps })), also destructure its className (e.g. className: optionClassName) and merge via cn(buttonVariants(...), optionClassName). Do not rely on {...spread} after className= — the spread will silently override your variants. (styled-components handled this implicitly; cn() makes it explicit.)forwardRef if the original had it. Wrap the new component in forwardRef<HTMLElementType, Props>(...), accept ref as the second arg, and pass it to the wrapper element. Keep the Component.displayName = '<Name>' line if it was there.src/components/<Name>/<Name>.types.ts — Don't change unless commit 1 already had to.
src/components/<Name>/<Name>.test.tsx — Don't change. If the unit tests passed before commit 2, they pass after.
.changeset/migrate-<name>-to-css-modules.md — Exact format:
---
'@clickhouse/click-ui': patch
---
migration of <Name> from styled-components to css modules. no behavior change.
patch bump (no behavior change → not even minor). Do not pad the body with extra prose, lists of preserved attributes, or migration notes — the terse phrasing is intentional and scales across dozens of migration PRs.
<Name>.types.ts (unless commit 1 already touched it for typecheck reasons).Run yarn test:visual tests/<area>/<name>.spec.ts. Every snapshot must pass with zero regenerations. If even one fails:
yarn test:visual:report) and diff the actual vs expected.:disabled[aria-pressed='true'] (or similar) compound selector → add it back.tokens.css for the correct CSS variable name.The only legitimate reason to regenerate is if the snapshot in commit 1 itself was wrong (e.g. captured a flaky animation frame) — and in that case, you go back and fix commit 1, not roll the difference into commit 2.
yarn test:visual tests/<area>/<name>.spec.ts — all green, zero snapshot changes.yarn test <Name> — unit tests pass unchanged.yarn lint:css and yarn lint:code — pass with no new errors.yarn build — succeeds.grep -r 'styled-components' src/components/<Name>/ — empty.:hover:not(:disabled), :focus-visible:not(:disabled), or color: disabled-active rules. Audit every selector against the original styled-components and drop the additions.--update-snapshots. Playwright 1.50+ requires an explicit mode. yarn test:visual:update already passes --update-snapshots=all; if you call playwright directly, you need to pass it yourself.{...spread}. Spreading consumer props after setting className= silently overrides the variants. Always destructure className (and className: optionClassName for child elements) and merge with cn().forwardRef. If the component on main has been wrapped in forwardRef (look for displayName), the migration must preserve it. Wrap the new arrow function in forwardRef<HTMLElementType, Props>(...), take ref as the second arg, pass it to the root element, and keep the .displayName line.@playwright/test, bump both to the latest and update everywhere it's referenced — don't pin the Dockerfile back to match an older yarn.lock.Before marking the PR ready:
main (a fresh forwardRef or unrelated commit may have landed during the work).<Name>.tsx, <Name>.module.css, and the changeset.patch and reads exactly migration of <Name> from styled-components to css modules. no behavior change.yarn test:visual tests/<area>/<name>.spec.ts is green with zero snapshot regenerations between the two commits.styled-components imports remain in src/components/<Name>/.