name: migrate-canvas-styled-to-tailwind description: Migrate an apollo-react canvas component from Emotion styled-components to apollo-wind Tailwind classes following the BaseNode reference migration patterns
Migrate Canvas Styled Components to Tailwind
Overview
Migrate canvas components in packages/apollo-react/src/canvas/ from Emotion (@emotion/styled, @emotion/react) to Tailwind classes provided by apollo-wind. The goal is zero runtime CSS-in-JS: every component should use static Tailwind class strings (or CSS custom properties for dynamic dimensions), with cn() from apollo-wind used only where class-level overrides are needed (e.g. conditional border colors that must beat a base class).
When to Use
- Migrating any
*.styles.tsfile underpackages/apollo-react/src/canvas/ - Component currently imports from
@emotion/styledor@emotion/react - Component uses
styled.*or thecsshelper
Reference Files (live in repo)
These files are the canonical examples of the completed migration. Read them before starting:
packages/apollo-react/src/canvas/components/BaseNode/BaseNodeContainer.tsx— Pattern C (conditional classes withcn())packages/apollo-react/src/canvas/components/BaseNode/BaseNodeInnerShape.tsx— Pattern A (inline static class string + CSS custom properties)packages/apollo-react/src/canvas/components/BaseNode/BaseNodeBadgeSlot.tsx— Pattern D (inline styles for positional offsets)packages/apollo-react/src/canvas/components/BaseNode/NodeLabel.tsx— Decomposed sub-components (Header, SubHeader, EditableLabel, EmptyLabelPlaceholder) usingcx()for non-conflicting conditional classespackages/apollo-react/src/canvas/components/BaseNode/BaseNodeMissingManifest.tsx— Simple composition of the above primitivespackages/apollo-react/src/canvas/components/BaseNode/BaseNode.tsx— Parent component: CSS custom property setup viauseMemo, wiring to sub-componentspackages/apollo-react/src/canvas/constants.ts— Extracted magic numberspackages/apollo-wind/src/styles/tailwind.utilities.css— Custom Tailwind@keyframesand@utilitydefinitions (seeanimate-glow)
Step-by-Step Process
Step 1: Audit the Styles File
Read the *.styles.ts file and catalog every styled component. For each one, identify:
- Static styles — CSS that never changes (borders, flex layout, font sizes)
- Prop-driven styles — CSS that varies by prop (shape, status, size)
- Dynamic numeric values — Pixel values computed from props (widths, heights, offsets)
- Animations / keyframes — Any
@emotion/reactkeyframe usage
Create a migration plan showing which pattern (see Step 2) applies to each styled component. Get user approval before proceeding.
Step 2: Choose the Right Pattern for Each Styled Component
Pattern A: Static Tailwind Classes (preferred)
Use when all styles are known at build time. Inline the class string directly in the JSX — a string literal is the simplest and most readable form.
Before (Emotion):
const BaseIconWrapper = styled.div<{ shape?: string }>`
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas-background-secondary);
color: var(--canvas-foreground);
border-radius: ${({ shape }) => shape === 'circle' ? '50%' : '8px'};
`;
After (Tailwind):
export const BaseInnerShape = memo(({ children }: Props) => (
<div
className="
flex items-center justify-center overflow-hidden bg-surface text-foreground
w-(--inner-size) h-(--inner-size) rounded-(--inner-radius)
[&>svg]:w-(--icon-size) [&>svg]:h-(--icon-size)
"
>
{children}
</div>
));
Note: the shape-dependent border-radius moved to a CSS custom property (--inner-radius) computed by the parent. This lets the child's className be fully static.
Pattern B: CSS Custom Properties for Dynamic Dimensions
Use when numeric dimensions are computed from props (node width/height, scale factors). Set --custom-props via style on a single parent wrapper, then reference them with static Tailwind classes on children using w-(--varname) or [border-radius:var(--varname)] syntax.
Before (Emotion):
// Styled component with complex dimension calculations in template literal
const BaseContainer = styled.div<{ shape?: string; width?: number; height?: number }>`
width: ${({ shape, width }) => {
const defaultWidth = shape === 'rectangle' ? 288 : 96;
return width ?? defaultWidth;
}}px;
height: ${({ height }) => height ?? 96}px;
border-radius: ${({ shape }) => shape === 'circle' ? '50%' : '16px'};
`;
After (Tailwind + CSS vars):
// Parent computes vars once in useMemo
const nodeVars = useMemo((): React.CSSProperties => ({
'--node-w': `${containerWidth}px`,
'--node-h': numH ? `${numH}px` : 'auto',
'--node-radius': shape === 'circle' ? '50%' : `${radius}px`,
} as React.CSSProperties), [containerWidth, numH, shape]);
// Wrapper applies vars; all children use static classes
<div style={nodeVars}>
<div className="w-(--node-w) h-(--node-h) [border-radius:var(--node-radius)]">
...
</div>
</div>
Key points:
- Compute CSS variable values in
useMemowith relevant deps. - Cast to
React.CSSProperties(TS doesn't know about custom properties). - Children's
classNamestrings are static — they never change, which is the perf win.
Pattern C: Conditional Classes with cn()
Use when Tailwind classes must override each other (e.g. border-brand must beat the base border-border). Import cn from @uipath/apollo-wind. Wrap in useMemo to avoid recomputation.
Before (Emotion):
const BaseContainer = styled.div<{ selected?: boolean; executionStatus?: string }>`
border: 1.5px solid var(--canvas-border-de-emp);
${({ executionStatus }) => getExecutionStatusBorder(executionStatus)}
${({ selected }) => selected && css`
border-color: var(--canvas-primary);
outline: 4px solid var(--canvas-secondary-pressed);
`}
`;
After (Tailwind + cn):
import { cn } from '@uipath/apollo-wind';
const className = useMemo(
() => cn(
'border-2 border-border bg-surface-overlay', // base
getStatusBorder(activeStatus), // override
isSelected && 'border-brand', // override
interactionState === 'disabled' && 'opacity-50 cursor-not-allowed',
),
[activeStatus, isSelected, interactionState]
);
Use cn() only when you need class-level override semantics (conflicting utilities on the same CSS property). For classes that don't conflict, plain string concatenation or the local cx() utility from ../../utils/CssUtil is sufficient and faster.
Pattern D: Minimal Inline Styles for Truly Dynamic One-Off Values
Use for values that are both dynamic and don't warrant a CSS custom property (e.g. a single background color from a prop, or positional offsets from a constant).
Before (Emotion):
const BaseBadgeSlot = styled.div<{ position: string; shape?: string }>`
position: absolute;
width: 20px;
height: 20px;
${({ position, shape }) => {
const offset = shape === 'circle' ? '12px' : '6px';
switch (position) {
case 'top-left': return `top: ${offset}; left: ${offset};`;
...
}
}}
`;
After:
export const BaseBadgeSlot = memo(({ position, shape, children }: Props) => {
const offset = shape === 'circle' ? NODE_BADGE_INSET_CIRCLE : NODE_BADGE_INSET_SQUARE;
const style: React.CSSProperties = { width: NODE_BADGE_SIZE, height: NODE_BADGE_SIZE };
switch (position) {
case 'top-left': style.top = offset; style.left = offset; break;
...
}
return (
<div className="absolute flex items-center justify-center bg-transparent" style={style}>
{children}
</div>
);
});
Pass undefined (not {}) for style when there's no dynamic value, so React skips the style attribute entirely.
Step 3: Extract Magic Numbers to Constants
Move all pixel literals, grid multiples, and ratio calculations to packages/apollo-react/src/canvas/constants.ts. Name them descriptively and document the design reference.
Before (buried in styled component):
const GRID_UNIT = 16;
const NODE_HEIGHT_DEFAULT = GRID_UNIT * 6; // 96px
After (in constants.ts):
export const GRID_SPACING = 16;
export const NODE_HEIGHT_DEFAULT = GRID_SPACING * 6; // 96px
export const NODE_CONTAINER_RADIUS_RATIO = 32 / DEFAULT_NODE_SIZE; // ~0.333
Step 4: Decompose the Monolithic Styles File
Replace the single *.styles.ts with focused component files. Each new file should:
- Export one component (or a small cohesive group)
- Own its own
interfacefor props - Use
memowhere the component is a leaf / pure-render - Live alongside the parent component (e.g.
BaseNodeContainer.tsxnext toBaseNode.tsx)
Naming convention: {ParentComponent}{Role}.tsx (e.g. BaseNodeContainer.tsx, BaseNodeInnerShape.tsx, BaseNodeBadgeSlot.tsx).
Step 5: Convert Styled Components One by One
For each styled component:
- Create the replacement file with the appropriate pattern from Step 2.
- Update imports in the parent component to use the new file instead of
*.styles.ts. - Move prop logic out of CSS — conditional styles that were in template literals become:
- Ternary expressions in
classNamefor simple cases cn()calls for override semantics- Switch/map helper functions that return full literal class strings for multi-value mappings (see
getStatusBorder()inBaseNodeContainer.tsx)
- Ternary expressions in
- Remove emotion imports (
css,styled,keyframes) from the parent. - Delete the styled component from the styles file once its replacement is wired in.
Step 6: Handle Animations and Keyframes
If the styles file uses @emotion/react keyframes:
- Define the
@keyframesrule inpackages/apollo-wind/src/styles/tailwind.utilities.css. - Create a
@utilitythat applies the animation (seeanimate-glowin that file). - Use the utility as a Tailwind class in the component.
Important: lightningcss bug. Avoid color-mix() or var() inside @keyframes blocks — lightningcss drops them silently. Instead, compute the mixed value in the @utility body using an internal --_private-var and reference that var in the keyframe:
@keyframes apollo-glow {
0% { box-shadow: 0 0 0 0 var(--_glow-shadow, currentColor); }
70% { box-shadow: 0 0 0 10px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
@utility animate-glow {
--_glow-shadow: color-mix(
in srgb,
var(--glow-color, currentColor) var(--glow-strength, 40%),
transparent
);
will-change: box-shadow;
animation: apollo-glow 2s infinite;
}
Consumers parameterize via [--glow-color:var(--error)] arbitrary value classes.
Step 7: Update Tests
- Mock
cnfrom@uipath/apollo-windin unit tests (simple concatenation is fine):vi.mock('@uipath/apollo-wind', () => ({ cn: (...args: unknown[]) => args.flat(Infinity).filter((v): v is string => typeof v === 'string' && v.length > 0).join(' '), })); - Update any test assertions that relied on emotion-generated class names or inline style objects.
- Add
data-testidanddata-*attributes on containers for test queries (e.g.data-execution-status,data-interaction-state).
Step 8: Delete the Styles File
Once every styled component has been replaced and the parent no longer imports from *.styles.ts, delete the file.
Step 9: Verify
pnpm build
pnpm test
pnpm lint
Check Storybook visually for regressions if stories exist for the component.
Tailwind Class Reference (apollo-wind)
Common Mappings
| Emotion pattern | Tailwind equivalent |
|---|---|
display: flex | flex |
align-items: center | items-center |
justify-content: center | justify-center |
position: relative | relative |
position: absolute | absolute |
cursor: pointer | cursor-pointer |
border: 2px solid var(--canvas-border) | border-2 border-border |
border-radius: 50% | rounded-full |
border-radius: 16px | rounded-2xl |
font-size: 13px | text-sm |
font-size: 11px | text-xs |
font-weight: 600 | font-semibold |
opacity: 0.5 | opacity-50 |
overflow: hidden | overflow-hidden |
white-space: nowrap; text-overflow: ellipsis | whitespace-nowrap text-ellipsis |
-webkit-line-clamp: 3 | line-clamp-3 |
word-break: break-word | wrap-break-word |
| Dynamic width via CSS var | w-(--varname) |
| Dynamic height via CSS var | h-(--varname) |
| Arbitrary CSS property via var | [border-radius:var(--varname)] |
Child selector svg { width: X } | [&>svg]:w-(--icon-size) |
Color Token Mappings
| Emotion CSS variable | Tailwind class |
|---|---|
var(--canvas-foreground) | text-foreground |
var(--canvas-foreground-de-emp) | text-foreground-muted |
var(--canvas-background) | bg-surface-overlay |
var(--canvas-background-secondary) | bg-surface |
var(--canvas-border-de-emp) | border-border |
var(--canvas-primary) | border-brand |
var(--canvas-error-icon) | border-error or text-error |
var(--canvas-success-icon) | border-success |
var(--canvas-warning-icon) | border-warning |
var(--canvas-info) | border-info |
Tailwind v4 Gotchas
- All class names must be literal strings. Tailwind v4 statically scans source files. Never interpolate class names:
`border-${color}`won't work. Use a switch/map that returns full literal strings. color-mix()withvar()inside@keyframesis dropped by lightningcss. Compute it outside the keyframe in a@utilitybody.field-sizing: content— usefield-sizing-content(custom utility if needed).
Anti-Patterns to Avoid
| Don't | Do instead |
|---|---|
Keep @emotion/styled alongside Tailwind in the same component | Fully migrate the component; no hybrid state |
Use cn() everywhere | Use cn() only when classes conflict/override; use static strings or cx() otherwise |
Put useMemo around static class strings | Inline static strings directly in JSX |
| Create inline style objects on every render | Use CSS custom properties on a parent + static Tailwind classes on children |
Add new *.styles.ts files | Colocate styles as Tailwind classes in the component file |
Use Tailwind @apply in CSS files | Use Tailwind classes directly in JSX |
Checklist
Before marking migration complete:
-
*.styles.tsfile deleted - No
@emotion/styledor@emotion/reactimports remain in the component tree - All magic numbers moved to
constants.ts - New sub-components use
memowhere appropriate - Tests updated and passing
- Storybook stories render correctly (visual check)
- Build passes (
pnpm build) - Lint passes (
pnpm lint)