clickhouse/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.
npx skills add https://github.com/ClickHouse/click-ui --skill component-css-modules-migration
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.args (preferred) or a render that returns the actual component. Never define a *Harness/wrapper component and render it inside render (e.g. render: () => <FooHarness size="xs" />). Storybook's "Show code" copies the story body verbatim, so a harness makes the copyable snippet <FooHarness …> instead of the real <Foo …> a consumer would write.decorators entry — decorators are *not* included in "Show code". Use a meta-level decorator when every story in the file shares the wrapper (see src/components/Alert/Alert.stories.tsx); use a story-level decorator (a shared Decorator const spread into each story's decorators) when only some stories need it and others have bespoke renders (see src/components/Icon/Icon.stories.tsx, whose Playground and Icons gallery must stay unwrapped). A decorator can read the story's args via its second argument ((Story, { args }) => …) to vary layout per story (see src/components/Separator/Separator.stories.tsx).data-testid="<name>-harness" that the Playwright spec screenshots. Keep the exact same test-id, element structure, and inline styles the wrapper had — the screenshot region and the snapshots stay byte-for-byte, and the spec's harnessLocator needs no change.tests/<area>/<name>.spec.ts — New Playwright spec. Pick <area> carefully:tests/utils/. That folder is reserved for shared test helpers like getStoryUrl (tests/utils/index.ts). Mixing specs and helpers there confuses both human readers and Copilot reviewers.tests/buttons/, tests/cards/).tests/display/.tests/forms/, tests/overlays/), not the component.Required header — @covers directive. Begin the spec file with a comment declaring which source directory it guards:
// Affected-spec coverage for scoped visual-regression runs in CI.
// See .scripts/js/affected-visual-specs
// @covers src/components/<Name>
CI reads these directives to run only the specs a PR's diff touches instead of the whole suite (.github/workflows/visual-regression-tests.yml → .scripts/js/affected-visual-specs). The resolver throws if any spec lacks a @covers directive, so the visual-regression job fails fast until you add one. Point it at the component's source directory — the resolver verifies the path exists. If the spec screenshots more than one component (e.g. an overview spec), add one @covers line per component directory. The visual specs navigate to Storybook stories by string id rather than importing the component, so this directive is the *only* link the resolver has between a src/ change and its spec — there is no fallback.
Mirror the structure from tests/buttons/button.spec.ts or tests/buttons/buttongroup.spec.ts. Cover:
getStoryUrl(storyId, theme) from tests/utils/index.ts (imported as from '../utils' from a sibling test folder)page.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..block_fill-width requires .block, and .block__icon_size_sm requires .block__icon. Never emit a lone family of modifier classes (.accordion_fill-width with no .accordion, .external-icon_size_* with no .external-icon). This shows up in the TSX as the tell-tale cva('', { variants: … }) — an empty base string means the block/element has no base class. Always pass the base class as cva's first argument: cva(styles.block, { variants: … }). Two consequences:_indicator-dir_start and _indicator-dir_end both set user-select: none, that lives on .block__header, and the modifiers keep only what differs.block-no-empty disable (the stylelint-config-standard rule rejects empty rules) whose reason names it as the base. Keep the reason stable — don't describe what the base currently holds or which modifiers it serves, since those change. base block required by BEM / base element required by BEM is enough: /* stylelint-disable-next-line block-no-empty -- base block required by BEM */
.external-icon {
}
Do not apply a base class as a bare string literal in the TSX (cn('external-icon', …)); that emits an unscoped global class that matches nothing in the CSS Module. The base must be styles['external-icon'] so it resolves to the scoped name.
stylelint.config.js (display → position → box-model → flexbox → border → background → typography → ...). yarn lint:css is the source of truth.$color) to pick from a token tree like theme.click.card.horizontal[$color].color.background[$state], do *not* write a separate :hover / :active / :disabled rule per variant. Instead, in each variant block (.wrapper_color_default, .wrapper_color_muted, …), declare local custom properties (--card-bg-default, --card-bg-hover, …) pointing at the long global tokens. Then write the state rules once on the base class using the short local names (background-color: var(--card-bg-hover)). The cascade picks the right value because the local var is set by whichever color modifier is on the element. Concretely, for M color variants × N states × P properties, this is M × N × P long token references collapsed into M × N × P local-var declarations plus N × P state rules — same token count, but the state rules deduplicate across variants. See src/components/CardHorizontal/CardHorizontal.module.css for the canonical example (.wrapper_color_default / .wrapper_color_muted define --card-bg-*, --card-title-*, --card-stroke-*, --card-desc-*; the :hover, :active, .wrapper_disabled rules below reference the short names). Skip the indirection when there is only one variant on the axis — the savings don't justify the extra layer.height: var(--container-height), flex: var(--container-grow), overflow: var(--container-overflow)), CSS custom properties inherit by default. Any property the component sets inline *only when its prop is passed* will therefore leak into nested instances of the same component — a parent <Container fillHeight grow="1"> forces height: 100% and flex-grow: 1 onto every descendant <Container> that didn't set those props. The styled-components original scoped each instance's rules, so nothing leaked; the var() version must reproduce that isolation explicitly. In the base class, reset every *conditionally-emitted* custom property to initial (its guaranteed-invalid value, so the fallback wins over any inherited value) and give each var() an explicit fallback matching the styled-components default-when-unset: .container {
--container-height: initial;
--container-grow: initial;
--container-overflow: initial;
/* …one line per optional prop… */
height: var(--container-height, auto);
flex: var(--container-grow, 0 1 auto);
overflow: var(--container-overflow, visible);
}
Custom properties set on every instance — emitted inline unconditionally (e.g. --container-width) or via a modifier class that's always present (e.g. --container-padding from container_padding_*, --container-gap from container_gap_*) — already override inheritance and need no reset; only the optional ones leak. This regressed post-merge on Container (#1059, fixed in #1067): a nested <Container> in control-plane's alert banner stretched to fill the viewport. The byte-for-byte visual-regression snapshots did not catch it — the leak only appears when the component is nested inside itself in a real app, which the isolated stories don't exercise. So when a component reads props from inline custom properties, add a story that nests it inside a prop-bearing instance of itself and assert computed styles (see Container.stories.tsx NestedInheritance + the computed-style test in tests/display/container.spec.ts).
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.){...props} spread position relative to hardcoded attributes. If the original styled-components JSX placed {...props} *after* attributes like aria-disabled, tabIndex, or onClick, the migration must do the same. Reversing the order (spreading {...props} first, then writing hardcoded attributes) silently strips consumer overrides — e.g. a consumer passing tabIndex={-1} to remove keyboard focus will be quietly overridden by the component's hardcoded tabIndex={0}. CardPrimaryProps extends HTMLAttributes<HTMLDivElement> and similar bases admit those attributes via the type, so the bug is invisible to TypeScript. The safe rewrite is: hardcoded attributes first, then {...props}, then className={cn(...)} last (because className is destructured separately so the spread can't undo it).style (e.g. style={{ '--container-width': fillWidth ? '100%' : 'auto', ...style }}), build that object inside useMemo keyed on the underlying prop values and the incoming style — never as a fresh object literal in JSX. A new object every render hands a new style reference down, which matters for the layout primitives this migration touches (Container, Icon) since they render everywhere. Place the useMemo above any early return null guard so the hook runs unconditionally. For polymorphic components whose render function is named _Name (wrapped by forwardRef, as in Container), react-hooks/rules-of-hooks will false-positive on the hook — its component-name heuristic only recognizes PascalCase — so add // eslint-disable-next-line react-hooks/rules-of-hooks with a one-line note that the hook is validly placed.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
---
Migrate <Name> from styled-components to css modules with no change in behavior
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().{...props} last (after aria-disabled, tabIndex, onClick, etc.), the migration must keep it last. Spreading {...props} *before* the hardcoded attributes flips the precedence and consumer-passed values are silently ignored — TypeScript won't catch it because HTMLAttributes<...> admits those props. Caught post-merge on CardPrimary (#1038) and CardHorizontal (#1039).style often write the object literal directly in JSX, allocating a fresh reference on every render. Wrap it in useMemo keyed on the underlying values (and any incoming style). On a polymorphic _Name render function this trips react-hooks/rules-of-hooks (PascalCase-only heuristic) — disable that rule on the line with a note. Container and Icon both follow this pattern.--container-height, --container-grow, --container-overflow, …) inherits into descendant instances of the same component — a parent <Container fillHeight> stretched every nested <Container> to full height. Reset each conditionally-emitted custom property to initial in the base class with a matching var() fallback (see the .module.css rule above). Visual-regression snapshots do not catch this — the component renders fine in isolation — so guard it with a nested-instance computed-style test. Caught in control-plane on Container (#1059), fixed in #1067.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.*Harness wrapper inside a story's render (render: () => <FooHarness size="xs" />) makes the copyable code show the harness, not the real <Foo …>. Render the real component via args/render and move the wrapper (test-id + styles) into a decorators entry, which "Show code" omits. Reproduce the wrapper exactly so the screenshotted [data-testid="<name>-harness"] region and the snapshots stay byte-for-byte. Caught on the first migration batch (Link/Avatar/Icon/Label/Separator/Spacer).as must stay genuinely polymorphic — copy Container. If a styled wrapper is exported and rendered as={Something} elsewhere (e.g. SidebarItemWrapper rendered as={Collapsible.Header}), keep it a real polymorphic component typed exactly like src/components/Container/Container.tsx: a generic <T extends ElementType = 'div'>, props Omit<ComponentProps<T>, keyof OwnProps> & OwnProps, and the forwardRef result cast to a polymorphic call signature — just use as where Container uses component. Keep the same name, export, and $-prefixed props, and destructure the $ props out so they never reach the DOM (cn() doesn't filter them like styled-components did). A fixed as?: ElementType plus a hardcoded prop type is not polymorphic and gets bounced in review. Migrate the wrapper and its consumers in one PR; verify parity through the consumers' stories (the primitive may not need its own).cva('', { variants: … }) emits a family of modifier classes (.accordion_fill-width, .alert__icon_size_*, .collapsible__header_indicator-dir_*, .external-icon_size_*) with no base block/element for them to modify. Always give the block/element a base class — hoist any shared declaration onto it, or emit it empty with a block-no-empty disable — and pass it as cva's first argument (cva(styles.block, …)). And never apply a base as a bare string (cn('external-icon', …)): that's an unscoped global that matches nothing; use styles['external-icon']. Caught post-merge across Accordion, Alert, Collapsible, and Link.as a classed target. Both classes land on one element, so any property both set (the target's display/color/font: inherit resets) is decided by CSS-module bundle order, not specificity. If the target's reset wins (e.g. wrong font), bump only the *conflicting* declarations to a doubled-class selector (.wrapper.wrapper). Leave box metrics and --var defaults single-class, or they'll outrank the _modifier classes that override those vars. (Caught on SidebarNavigationTitle rendered as={Collapsible.Trigger}.)Before marking the PR ready:
main (a fresh forwardRef or unrelated commit may have landed during the work).@covers src/components/<Name> directive (CI's visual-regression job throws without it).<Name>.tsx, <Name>.module.css, and the changeset.initial in the base class (with a matching var() fallback) so it can't inherit into nested instances, and a nested-instance test guards it.cva('', …), no lone modifier families); shared declarations are hoisted onto the base and genuinely-empty bases carry a block-no-empty disable.patch and reads exactly Migrate <Name> from styled-components to css modules with no change in behavioryarn 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>/.Take clickhouse/component-css-modules-migration from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.