How to migrate an app from Pluma v4 to v5 — the semantic color layer, its codemods, and the recommended order.
This guide walks through migrating an app from Pluma v4 to v5. The major replaces the design tokens with a semantic color layer and renames the component values that mirrored the old tokens. Every change ships with a codemod. The canonical record of what changed lives in the package changelogs.
| Change | Affected | Codemod |
|---|---|---|
| Theme tokens renamed | themeVars.color.*, themeVars.palette.*, --pluma-color-* / --pluma-palette-* CSS variables, defaultTheme | theme-tokens-rename |
| Text, Icon, Box and Coin color values | color on Text, Icon and everything forwarding their props; backgroundColor on Box and every component forwarding Box props; borderColor on Coin | text-icon-color-rename |
| Palette hue renames | grey, raspberry, clementine, plum, purple values on Badge, Coin, StatusIndicator, PageHeaderIconTile, TimelineItem, ConditionViewerItem; Badge accent / neutral-outline / accent-outline | color-variant-rename |
See the Pluma CLI codemods for running instructions. Finish the v3 migration first; the v5 codemods take code written against the 4.x vocabulary as their input.
The flat color.* token list is replaced by a semantic color layer: surface planes (surface-base, surface-content, surface-block, surface-floating, surface-recessed, surface-inverse), neutral text and mark roles (text-primary, text-secondary, text-disabled, mark-primary, mark-secondary), and per-scope colors (scope-<name>-text, -border, -mark, -solid-*, -subtle-*) for every semantic name and palette hue. Hover, pressed and disabled are no longer separate colors: shift an element's own color with shiftHover() / shiftPressed() (shiftHoverStroke() for text and borders), set layerHover(bg) / layerPressed(bg) as the background of anything neutral, passing the element's own background, and dim disabled controls with opacity.disabled. Interaction states is the reference for these — which helper to reach for, and how to re-tint an element's layer now that there is no *-hover token to override. The palette is rebuilt around 16 hue families on a shared strength scale.
Every removed themeVars key has a closest equivalent in the new layer, and two keys kept their name but changed meaning: color.surface-base is now the page plane behind content (the old white content background is color.surface-content), and the palette.<hue>-<step> steps sit on a new scale. The legacy defaultTheme export is gone; lightTheme is its successor. The pipelinesTheme brand overlay is gone too, and it has no successor: 5.0 ships lightTheme and darkTheme, and an app that needs a brand accent overrides the color.scope-accent-* custom properties on a wrapper element, which reaches every Pluma component inside it. See Breaking changes for the detail. The @customerio/pluma-js, @customerio/pluma-sass and @customerio/pluma-tailwind packages are removed; token values come from @customerio/tokens/js. @customerio/pluma-css stays, but every custom property it declares was renamed, so the codemod rewrites its bare --color-* names alongside the --pluma- ones; a stylesheet still on its pre-1.0.0 names runs the css-token-rename codemod first.
How a subtle fill is authored is each theme's own choice. Light ships a finished color, color.scope-<name>-subtle-bg; dark ships the opaque color the fill is mixed from, color.scope-<name>-subtle-bgMix, and the mix happens at the element that paints it. Every theme ships both ids, and the half it did not author ships as the CSS keyword initial, so one declaration reads both and the active theme decides which answers: fillScope('neutral', 'subtle') in JS, or var(--pluma-color-scope-neutral-subtle-bg, color-mix(in oklab, var(--pluma-color-scope-neutral-subtle-bgMix) calc(var(--pluma-colorMix-fillSubtle-percentage) * 1%), var(--pluma-colorMix-fillSubtle-withColor))) in a stylesheet. fillScope and fillMix come from @customerio/pluma-components/css/utils; set themeVars.colorMix['fillSubtle-percentage'] on an element to change how strong its mixed fills are. See Interaction states for when to reach for each. The codemod writes the recipe for you wherever it can see the whole read.
The five neutral border tokens become three rungs, and which one a thing takes is a question about the thing: does it have a face, is it a bare line, or is it a small clickable box? color.scope-neutral-subtle-border is the quiet edge, the hairline that closes something that already has a face: Panel, Card, Popover and Menu edges, dividers, the Table grid, fields and the secondary Button. It replaces border-base and border-dropdown. color.scope-neutral-border is the structural stroke, a line with nothing behind it: an outline Badge, a Coin ring, a dashed outline, a connector, a guide line. It replaces border-bold and border-active. color.scope-control-border is the small-control edge: Checkbox and Radio, where the outline is the whole shape and has to carry it alone, so it is pinned heavier than either neutral rung in both themes. Every hue and semantic scope carries the same border and subtle-border pair (color.scope-critical-border, color.scope-accent-subtle-border, and so on). Faced controls -- fields, Select triggers, the secondary Button -- rest on color.scope-control-subtle-bg behind a color.scope-control-subtle-border edge; the face aliases color.scope-control-bg, and the edge aliases the neutral quiet edge in light and sits one step firmer (gray 900) in dark. A control in its chosen state -- a checked Checkbox, a Radio dot, a Toggle that is on, a Slider's filled track -- paints color.scope-control-solid-bg, with -mark for a checkmark or dot. color.scope-action-solid-* is the fill of a primary action with a label, such as the primary Button or the selected SegmentedControl segment; it is teal 800 and the control fill teal 700 in both themes, so the label stays at AA through hover and pressed.
Run the theme-tokens-rename codemod over your .ts/.tsx/.gts sources and stylesheets. It rewrites exact renames silently, rewrites judgement-call renames and flags them, and reports tokens with no equivalent. Review the flagged sites: the notes say which helper or alternative token to reach for.
The neutral color values on Text and Icon collapse to primary and secondary (base, bold and active become primary; subtle, minimal and placeholder become secondary; Icon's button and navigation become primary). Box backgroundColor and Coin borderColor take a surface plane or a scope with a -subtle twin (base → content, subtle and minimal → overlay-tint, active → neutral-subtle, navigation → base, invert → inverse, <name>-minimal → <name>-subtle, <name>-bold → <name>); Box bold and Coin borderColor="invert" have no replacement and are removed. overlay-tint is a faint translucent tint that marks a region off from the surface it sits on, and the codemod reports each one: switch it to neutral-subtle where the element is a filled chip. On Avatar, Badge, Coin, StatusIndicator and Tag, whose backgroundColor fills a chip, subtle and minimal become neutral-subtle. recessed is for a sunken region such as a drop zone or a well, so no 4.x value maps to it.
Run the text-icon-color-rename codemod. It covers every component that forwards Text, Icon or Box props in both frameworks.
The public hue names grey, raspberry, clementine, plum and purple are now gray, pink, orange, fuchsia and violet on Badge, PageHeaderLabel, Coin (colorVariant), StatusIndicator, PageHeaderIconTile, TimelineItem (color, trackColor), ConditionViewerItem (iconColorVariant) and sequential() from the data-viz entry. Badge gains a variant prop and drops the accent, neutral-outline and accent-outline colors in favour of variant="solid" color="accent", variant="outline" color="neutral" and variant="outline" color="accent".
Run the color-variant-rename codemod. If you are also running the 3.0.0 label-to-badge codemod, run it first: its output uses the 3.0 values this codemod migrates.
Components that wrap Badge and declare their own variant prop (Omit<BadgeProps, 'color'> & { variant: MyVariant }) now collide with Badge's variant; add 'variant' to the Omit or rename the wrapper's prop. @customerio/tokens is a dependency of @customerio/pluma-components in v5, so it installs with it; the removed @customerio/pluma-js package's tokens object is @customerio/tokens/js, keyed by the new names.
themeVars and --pluma-* reads onto the semantic color layer and defaultTheme onto lightTheme (run the theme-tokens-rename codemod, then work through its review notes)primary/secondary text and icon colors, surface planes and scopes for backgroundColor and borderColor (run the text-icon-color-rename codemod)grey/raspberry/clementine/plum/purple to gray/pink/orange/fuchsia/violet, and Badge's removed colors to variant + color (run the color-variant-rename codemod)The three codemods have no ordering constraints among themselves. Run each with --dry first, review the flagged sites, then apply and commit before the next one — together with the .pluma/ directory they write at the repository root, which holds the codemod-ledger.json run ledger and the codemod-report.json findings report. That run ledger records what each run migrated, so on a branch that keeps merging in code written against 4.x, running the codemods again migrates only the new code and leaves what they migrated before alone. Code written against 5.0 by hand reads like new code, so record it with --baseline before the next run.