Migrate existing antd-style CSS-in-JS (createStyles) components to Tailwind CSS utility classes with full dark mode support, following the project's style system transition strategy. Use when the user asks to migrate, convert, or rewrite antd-style components to Tailwind, or when working on new components in files that already use antd-style.
71
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
createStylesstyles.ts, createStyle, useXxxStyles, or cx()Button — replace with shadcn Button during migrationAlways read these files before starting:
index.tsx or equivalent)styles.tstailwind.config.js and src/index.css if token mapping is unclearstyles.tsIdentify:
styles.tscreateStyles for thoseAlso check if the styles file is imported anywhere else before deleting:
rg "from.*ComponentName/styles" --type tsx --type tscreateStyles wiring- import { useXxxStyles } from "./styles"
+ import { cn } from "@/opensource/lib/utils"
const targetComponent = observer((props) => {
- const { styles, cx } = useXxxStyles()Use cn() everywhere cx() was used.
Do not build class strings with + concatenation. Prefer:
const segmentedClassName = cn(
"rounded-md border border-border p-1",
"[&_.magic-segmented-group]:gap-0.5",
"[&_.magic-segmented-item-label]:text-xs",
)Prefer semantic z-index classes over arbitrary z-[N] values.
| Value | Tailwind class | CSS variable |
|---|---|---|
| 1000 | z-tooltip | --z-index-tooltip |
| 1000 | z-popup | --z-index-popup |
| 1000 | z-dropdown | --z-index-dropdown |
| 1000 | z-dialog | --z-index-dialog |
| 1000 | z-drawer | --z-index-drawer |
Choose by context: use z-tooltip for tooltips, z-dropdown for menus, z-dialog for modals.
| CSS | Tailwind |
|---|---|
width: 100% | w-full |
width: fit-content | w-fit |
height: fit-content | h-fit |
padding: 12px 0 | py-3 |
padding: 10px | p-2.5 |
padding: 6px | p-1.5 |
padding: 4px 8px | px-2 py-1 |
padding: 4px 6px | px-1.5 py-1 |
padding: 0 4px | px-1 |
padding: 0 6px | px-1.5 py-0 |
margin-top: 6px | mt-1.5 |
margin-top: 10px | mt-2.5 |
margin-left: auto | ml-auto |
display: flex | flex |
display: inline-flex | inline-flex |
flex-direction: column | flex-col |
align-items: center | items-center |
align-self: flex-end | self-end |
justify-content: flex-end | justify-end |
gap: 4px | gap-1 |
gap: 6px | gap-1.5 |
gap: 10px | gap-2.5 |
font-size: 12px; line-height: 16px | text-xs leading-4 |
font-size: 10px; line-height: 13px | text-[10px] leading-[13px] |
font-size: 14px; line-height: 1.4 | text-sm leading-[1.4] |
font-weight: 600 | font-semibold |
font-weight: 400 | font-normal |
cursor: pointer | cursor-pointer |
cursor: default | cursor-default |
cursor: not-allowed | cursor-not-allowed |
position: relative | relative |
position: absolute | absolute |
flex: none | flex-none |
flex-shrink: 1 | shrink (default, often omit) |
white-space: nowrap | whitespace-nowrap |
white-space: pre-wrap | whitespace-pre-wrap |
overflow: hidden | overflow-hidden |
text-overflow: ellipsis | text-ellipsis |
min-width: 0 | min-w-0 |
border-radius: 8px | rounded-md (= 8px via --radius-md) |
border-radius: 12px | rounded-[12px] |
border-radius: 0 4px 4px 0 | rounded-r-[4px] |
box-shadow: 0 1px 2px rgba(0,0,0,0.05) | shadow-sm |
Prefer semantic Tailwind vars — they auto-adapt to dark mode.
| antd-style token | Tailwind class | Notes |
|---|---|---|
token.magicColorUsages.text[0] | text-foreground | primary text |
token.magicColorUsages.text[1] | text-foreground/80 | slightly dimmed |
token.magicColorUsages.text[2] | text-muted-foreground | secondary text |
token.magicColorUsages.text[3] | text-foreground/35 | disabled/placeholder |
token.magicColorUsages.fill[0] | bg-fill | hover fill (subtle) |
token.magicColorUsages.fill[1] | bg-fill-secondary | active fill (stronger) |
token.magicColorUsages.fill[2] | bg-black/[0.13] + dark:bg-white/[0.13] | deep active fill |
token.magicColorUsages.border | border-border | |
token.magicColorUsages.bg[0..4] | bg-background | all white in light |
token.magicColorUsages.warning.default | text-orange-500 | ⚠️ orange, not amber |
token.magicColorUsages.warningLight.default | bg-orange-50 | ⚠️ orange-0 |
token.magicColorUsages.danger.default | text-destructive | uses Tailwind semantic |
token.magicColorUsages.dangerLight.default | bg-destructive/10 | prefer slash opacity |
token.magicColorUsages.primary.default | text-primary | brand blue |
token.magicColorUsages.primaryLight.default | bg-primary-10 | ⚠️ Use not bg-primary/10 |
token.magicColorUsages.success.default | text-green-500 | |
token.magicColorUsages.successLight.default | bg-green-50 | |
token.magicColorUsages.info.default | text-blue-500 | |
token.magicColorUsages.infoLight.default | bg-blue-50 | |
token.magicColorUsages.tertiary.default | text-muted-foreground | |
token.magicColorUsages.disabled.bg | bg-gray-200 + dark:bg-white/10 | |
token.magicColorUsages.disabled.text | text-foreground/35 | |
token.magicColorScales.grey[0] | bg-muted / bg-gray-50 | |
token.magicColorScales.grey[2] | border-border (preferred) | |
token.magicColorScales.grey[9] | text-foreground | |
token.borderRadiusSM (4px) | rounded-[4px] | use [4px] not rounded-sm (2px) |
Project-specific opacity tokens (from src/index.css):
bg-primary-10) due mixed light/dark opacity mappingbg-destructive/10, bg-destructive/20)| Avoid | Use | CSS variable |
|---|---|---|
bg-primary/10 | bg-primary-10 | --custom-primary-10-dark-primary-20 (light 10%, dark 20%) |
bg-destructive-10 | bg-destructive/10 | --destructive + opacity |
bg-destructive-20 | bg-destructive/20 | --destructive + opacity |
border-gray-500/10 (outline) | border-[var(--custom-outline-10-dark-outline-20)] | --custom-outline-10-dark-outline-20 |
| Hex | Tailwind equivalent |
|---|---|
#171717 | text-foreground |
#e5e5e5 (border) | border-border |
#9ca3af | text-gray-400 / border-gray-400 |
#3b82f6 | text-blue-500 |
#ebf2fe | bg-[#ebf2fe] + dark: dark:bg-blue-500/10 |
#f5f6f7 | bg-[#f5f6f7] + dark: dark:bg-white/10 |
#f9f9f9 | bg-gray-50 + dark: dark:bg-white/5 |
#ffffff (surface) | bg-white + dark: dark:bg-card |
rgba(46,47,56,0.09) | bg-black/[0.09] + dark: dark:bg-white/[0.09] |
::after / ::before with decorative content:
// Before (antd-style)
wrapper: css`
position: relative;
&::after {
content: " ";
position: absolute;
left: 11px;
top: 0;
width: 1px;
height: 100%;
border-left: 1px dashed ${token.magicColorScales.grey[2]};
}
`
// After (Tailwind)
;("relative after:content-[''] after:absolute after:left-[11px] after:top-0 after:w-px after:h-full after:border-l after:border-dashed after:border-border")| CSS pattern | Tailwind arbitrary variant |
|---|---|
& p { margin-bottom: 0 } | [&_p]:mb-0 |
& p { color: X !important } | [&_p]:!text-X |
& p { font-size: 12px !important } | [&_p]:!text-xs |
& p { line-height: 16px !important } | [&_p]:!leading-4 |
svg { color: X } | [&_svg]:text-X |
| antd-style keyframe | Tailwind |
|---|---|
Custom rotate(0 → 360) spin | animate-spin (1s linear infinite) |
| Custom fade-in / slide | Check tailwind.config.js animation section for project-defined animations |
antd layout primitives (Flex, Space, Row, Col) must be replaced with plain <div> + Tailwind.
Flex → div| antd Flex prop | Tailwind equivalent |
|---|---|
<Flex> | <div className="flex"> |
<Flex vertical> | <div className="flex flex-col"> |
gap={4} | gap-1 |
gap={6} | gap-1.5 |
gap={8} | gap-2 |
gap={10} | gap-2.5 |
gap={12} | gap-3 |
gap={16} | gap-4 |
gap={20} | gap-5 |
gap={24} | gap-6 |
align="center" | items-center |
align="start" | items-start |
align="end" | items-end |
justify="center" | justify-center |
justify="space-between" | justify-between |
justify="flex-end" | justify-end |
wrap / wrap="wrap" | flex-wrap |
flex={1} (child) | flex-1 on the child |
- import { Flex } from "antd"
- <Flex vertical gap={10}>
- <span>…</span>
- <Flex gap={8}>…</Flex>
- </Flex>
+ <div className="flex flex-col gap-2.5">
+ <span>…</span>
+ <div className="flex gap-2">…</div>
+ </div>Space → div- <Space size={8} direction="vertical">
+ <div className="flex flex-col gap-2">MagicScrollBar with shadcn ScrollAreaWhen migrating legacy list areas:
- import MagicScrollBar from "@/opensource/components/base/MagicScrollBar"
+ import { ScrollArea } from "@/opensource/components/shadcn-ui/scroll-area"Map old SimpleBar content padding to viewportClassName:
- <MagicScrollBar className="h-full w-full [&_.simplebar-content]:px-2.5 [&_.simplebar-content]:pb-2.5">
+ <ScrollArea className="min-h-0 h-full w-full" viewportClassName="px-2.5 pb-2.5">Critical height-chain rule (flex layouts):
ScrollArea must provide a calculable heightflex-1 min-h-0 overflow-hidden on parent + min-h-0 h-full on ScrollAreaScrollArea with components that add an extra auto-height layer (for example spinner wrappers) unless that wrapper and its direct child are both h-fullIf loading overlay breaks scrolling, prefer rendering loading as a branch:
{
loading ? (
<div className="flex h-full w-full items-center justify-center">{/* spinner */}</div>
) : (
<ScrollArea className="min-h-0 h-full w-full" viewportClassName="px-2.5 pb-2.5">
{/* list */}
</ScrollArea>
)
}Keep existing data-testid values on list / loading / empty nodes when replacing containers.
When migrating a component that uses antd Button, replace it with shadcn Button from
@/opensource/components/shadcn-ui.
- import { Button } from "antd"
+ import { Button } from "@/opensource/components/shadcn-ui"| antd Button | shadcn Button |
|---|---|
type="primary" | variant="default" (or omit, default) |
type="default" | variant="outline" or variant="secondary" |
type="text" | variant="ghost" |
type="link" | variant="link" |
danger | variant="destructive" |
size="large" | size="lg" |
size="small" | size="sm" |
icon={<Icon />} | Put icon as child: <Button><Icon /> Text</Button> |
loading | Use disabled + loading spinner as child, or a loading state component |
- import { Button } from "antd"
- import { IconDownload } from "@tabler/icons-react"
+ import { Button } from "@/opensource/components/shadcn-ui"
+ import { IconDownload } from "@tabler/icons-react" // or lucide-react
- <Button type="primary" icon={<IconDownload size={20} />} onClick={handleDownload}>
- {t("detail.downloadFile")}
- </Button>
+ <Button onClick={handleDownload} size="sm">
+ <IconDownload className="size-5" />
+ {t("detail.downloadFile")}
+ </Button>shadcn Button has built-in [&_svg]:size-4; use className="size-5" on the icon for 20px.
Remove any className overrides that were needed for antd — shadcn Button uses Tailwind and
supports className for minor tweaks only.
When migrating a component that uses antd Input, replace it with shadcn Input from
@/opensource/components/shadcn-ui/input.
- import { Input } from "antd"
+ import { Input } from "@/opensource/components/shadcn-ui/input"Antd prefix prop is not used in shadcn Input. Wrap Input with a relative container and render
the icon absolutely:
<div className="relative w-full">
<IconSearch
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
className="pl-9"
placeholder={t("mcp.panel.searchPlaceholder")}
/>
</div>data-testid unchanged during migrationw-[240px])When applying Tailwind to other antd components (Dropdown, Modal, etc.), antd's CSS
specificity requires !important for most visual properties. Layout utilities like flex-none
do NOT need ! since they're additive.
Workspace rule (critical): for internal antd class overrides, use .magic-* prefix,
not .ant-*.
"[&_.magic-alert]:text-xs""[&_.ant-alert]:text-xs"// Dropdown overlayClassName targeting internal antd classes
overlayClassName="[&_.magic-dropdown-menu]:!p-[6px] [&_.magic-dropdown-menu]:flex
[&_.magic-dropdown-menu]:flex-col [&_.magic-dropdown-menu]:gap-1
[&_.magic-dropdown-menu-item]:!p-0"Rule of thumb for !: Does antd set this property by default? If yes → !. If it's purely
additive (flex-none, mt-2.5, self-end) → no ! needed.
Always add all four states when styling interactive elements. Missing any state causes visible regressions (e.g., a button that darkens on hover but ignores keyboard focus).
| State | Token equivalent | Tailwind class | Notes |
|---|---|---|---|
| hover bg | fill[0] | hover:bg-fill | semantic, auto dark-mode |
| hover bg (stronger) | fill[1] | hover:bg-fill-secondary | for already-filled surfaces |
| active bg | fill[1] | active:bg-fill-secondary | slightly darker than hover |
| hover text | text[0] | hover:text-foreground | keeps text readable |
| focus ring | ring CSS var | focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring | keyboard only |
| disabled | — | disabled:pointer-events-none disabled:opacity-50 | always include |
// Canonical ghost button pattern — always use cn()
const ghostButton = cn(
"inline-flex items-center cursor-pointer rounded-lg",
"bg-background text-foreground border border-border",
"hover:bg-fill hover:text-foreground",
"active:bg-fill-secondary",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
)const primaryButton = cn(
"bg-primary text-primary-foreground",
"hover:bg-primary/90",
"active:bg-primary/80",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
)const mutedButton = cn(
"bg-fill text-foreground",
"hover:bg-fill-secondary hover:text-foreground",
"active:bg-black/[0.13] dark:active:bg-white/[0.13]",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
)When building custom button-like elements (not shadcn Button), extract to a module-level const:
// Outside component — for div/span that acts as button
const actionButtonBase = cn(
"inline-flex h-6 items-center gap-1 px-2.5 py-1",
"cursor-pointer rounded-lg text-xs leading-4",
"bg-background text-foreground border border-border",
"hover:bg-fill hover:text-foreground",
"active:bg-fill-secondary",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
)
// For shadcn Button — use variant/size props, className for minor tweaks only
<Button variant={isActive ? "default" : "outline"} size="sm">Add dark: variants for all hardcoded light-specific colors.
| Light | Dark | Use case |
|---|---|---|
bg-white | dark:bg-card | surface / card bg |
bg-orange-50 | dark:bg-orange-500/20 | warning/warningLight bg |
text-orange-500 | dark:text-orange-400 | warning text |
[&_p]:!text-orange-500 | dark:[&_p]:!text-orange-400 | warning p override |
bg-green-50 | dark:bg-green-500/20 | success bg |
text-green-500 | dark:text-green-400 | success text |
bg-blue-50 | dark:bg-blue-500/20 | info/secondary bg |
text-blue-500 | dark:text-blue-400 | info/secondary text |
bg-[#ebf2fe] | dark:bg-blue-500/10 | finished badge bg (hardcoded) |
bg-[#f5f6f7] | dark:bg-white/10 | suspended badge bg (hardcoded) |
text-gray-400 | dark:text-gray-500 | muted icon/label |
bg-gray-50 | dark:bg-white/5 | subtle list item bg |
!bg-white | dark:!bg-card | antd button/surface bg |
bg-black/[0.09] | dark:bg-white/[0.09] | fill[1] dark overlay |
bg-black/[0.13] | dark:bg-white/[0.13] | fill[2] darker overlay |
Semantic Tailwind variables (bg-background, border-border, text-foreground, bg-muted,
bg-fill, bg-fill-secondary) are already dark-mode aware — no dark: prefix needed.
After migration, verify dark mode styles are complete:
Scan for hardcoded light colors without dark:
bg-white, bg-gray-50, bg-orange-50, bg-green-50, bg-blue-50, bg-amber-50text-gray-400, text-gray-500, text-orange-500, text-green-500, text-blue-500bg-[#...], text-[#...] (hex values)border-gray-200, border-gray-300dark: variant.Verify semantic tokens
bg-background, text-foreground, border-border, bg-muted, bg-fill — these auto-adapt.bg-white), always pair with dark:bg-card or equivalent.Interactive elements
hover:, active: states using light-only colors need dark: variants.hover:bg-gray-100 → add dark:hover:bg-white/10.Manual verification
Search pattern for audit
# Find light-only color classes — verify each has a dark: variant in same className
rg "bg-(white|gray-50|orange-50|green-50|blue-50|amber-50)" --type tsx path/to/component
rg "text-(gray-[34]00|orange-500|green-500|blue-500)" --type tsx path/to/componentas anyWhen passing props to child components, fix type errors by using proper interfaces instead of
as any or eslint-disable-next-line @typescript-eslint/no-explicit-any.
Export the child's props interface from the child component:
- interface PlaybackTabContentProps {
+ export interface PlaybackTabContentProps {Use that interface as the parent's prop type:
- playbackProps?: Record<string, unknown>
+ playbackProps?: PlaybackTabContentPropsRemove type assertions — spread directly without as any:
- <PlaybackTabContent {...(playbackProps as any)} />
+ <PlaybackTabContent {...playbackProps} />Simplify property access — no cast needed:
- (playbackProps as Record<string, unknown>)?.isFullscreen === true
+ playbackProps?.isFullscreen === trueReserve eslint-disable only for cases where proper typing is impractical (e.g. MobX store data
with loose typing in .map() callbacks). Prefer exporting interfaces and typing props correctly.
The pattern const x = thing || {} creates a new object reference every render, causing
ESLint to warn about unstable useCallback/useMemo deps. Wrap it in useMemo:
- const fileData = tool?.detail?.data || {}
+ const fileData = useMemo(() => tool?.detail?.data || {}, [tool?.detail?.data])@ts-ignore vs @ts-expect-error@ts-ignore — suppresses the error silently; ESLint bans it.@ts-expect-error — requires a real error to exist; errors if the line is clean.Migration rule:
// @ts-ignore with // @ts-expect-error and add a brief reason.- // @ts-ignore
+ // @ts-expect-error - result type does not expose data.file_id
const fileId = result?.data?.file_id || result?.currentFileIdany on JSX .map() callbacks over store data (last resort)When iterating over data from a MobX/Zustand store with loose typing, first try to add proper
types (e.g. define StoreItem interface, use as StoreItem where the parent builds the data).
Use eslint-disable only when proper typing is impractical (e.g. store shape is dynamic or
external).
{
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
}
{
items?.map((item: any) => (
<Child data={item.attrs as ExpectedType} key={getUniqueId(item.attrs as ExpectedType)} />
))
}⚠️ The
eslint-disable-next-linecomment must be on the line immediately before the{items?.map(expression — not before the wrapping{condition && (wrapper.
When multiple elements share the same base styles, extract into a const outside the component:
// Before (in createStyles)
box: css`border-radius: 12px; display: inline-flex; padding: 4px 8px; ...`
// After (module-level const)
const boxBase = "rounded-[12px] inline-flex px-2 py-1 items-center text-xs gap-1 [&_p]:mb-0"
// Usage
<span className={cn(boxBase, "bg-amber-50 text-amber-500 dark:bg-amber-500/10 dark:text-amber-400")}>styles.xxx references replaced with Tailwind classesuseXxxStyles import and call removed from componentcreateStyles export removed from styles.ts (if no other consumers)keyframes import removed from styles.ts if unusedstyles.ts file deleted after confirming no other importscn() imported from @/opensource/lib/utils+ concatenation; use cn(...) insteadButton from @/opensource/components/shadcn-uiInput from @/opensource/components/shadcn-ui/inputMagicScrollBar was replaced, ScrollArea uses viewportClassName and height-chain (min-h-0 + h/full) is verifieddark: variants; prefer semantic tokens (bg-background, text-foreground, etc.); manually verify in dark modeFlex, Space, Row, Col) replaced with div + Tailwindhover: / active: / focus-visible: / disabled:const (DRY)token.borderRadiusSM translated to rounded-[4px], not rounded-smuseMemo@ts-ignore replaced with @ts-expect-error + reason, or deleted if no error existsas any and eslint-disable for type errorsany in JSX map callbacks used only as last resort; prefer proper types when feasible! prefix on antd overrides applied correctly (visual props only, not layout utilities).magic-* class prefix, not .ant-*pnpm lint)41d7ef4
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.