Breaking changes

Breaking changes planned for the next major version of Pluma.


This page records breaking changes that no codemod can find for you, so they need a human read before the next major lands. The full record of what v5 changes, and the codemods that cover most of it, is in Migrating to v5.

Summary

table
ChangeAffectedCodemod
Badge gains a variant propAny component that wraps Badge and declares its own variant prop—
pipelinesTheme is removedAny app passing pipelinesTheme to PlumaProvider—
Component tokens are removedAny app setting a --pluma-<component>-* custom property, or reading theme.componentTokens—
Selection controls leave the action fillAny app painting its own checked, on or selected control with color.scope-action-solid-*—
State classes are removedAny app styling or querying a Button, Link, PlainButton, TextField, Select, Tabs tab, Table header cell, Calendar day or DatePicker preset by its state class—
SideNav state classes are removedAny app targeting pluma-c-side-nav-collapsed or pluma-c-side-nav-expanded—

State classes are removed

Button, Link, PlainButton, TextField, Select, Tabs, TableTh, Calendar and DatePicker now style their runtime state from attributes, and their state classes are deprecated. The classes are still emitted on the same elements, but they no longer carry styles of their own, and the next major removes them. This is what replaces each class:

table
ComponentElementDeprecated classAttribute
Buttonbuttonpluma-c-button-is-loadingaria-busy="true"
Buttonbuttonpluma-c-button-is-activedata-active="true"
Linklinkpluma-c-link-is-loadingaria-busy="true"
PlainButtonbuttonpluma-c-plain-button-is-loadingaria-busy="true"
TextFieldinput wrapperpluma-c-text-field-input-wrapper-invaliddata-invalid="true"
TextFieldinput wrapperpluma-c-text-field-input-wrapper-is-activedata-active="true"
Selectselect buttonpluma-c-select-button-is-invalidaria-invalid="true"
Selectselect buttonpluma-c-select-button-is-placeholderdata-placeholder="true"
Tabstabpluma-c-tabs-tab-is-activearia-selected="true" (aria-current="page" inside TabNav)
Tabstab wrapperpluma-c-tabs-tab-wrapper-is-active:has(> [aria-selected='true']) (:has(> [aria-current='page']) inside TabNav)
TableThheader cellpluma-c-table-th-is-activedata-active="true"
Calendarday buttonpluma-c-calendar-day-is-selecteddata-selected="true"
Calendarday buttonpluma-c-calendar-day-is-focuseddata-focused="true"
Calendarday buttonpluma-c-calendar-day-is-disabledaria-disabled="true"
Calendarday buttonpluma-c-calendar-day-is-selection-startdata-range="start" or data-range="single"
Calendarday buttonpluma-c-calendar-day-is-selection-enddata-range="end" or data-range="single"
Calendarday buttonpluma-c-calendar-day-is-range-selectiondata-range
DatePickerpreset buttonpluma-c-date-picker-preset-is-activearia-current="true"
DatePickerpreset buttonpluma-c-date-picker-preset-is-disabledaria-disabled="true"

The classNames exports of Link, PlainButton, TextField, Select, Tabs, Table, Calendar and DatePicker carry the same classes as linkIsLoading, plainButtonIsLoading, textFieldInputWrapperInvalid, textFieldInputWrapperIsActive, selectButtonInvalid, selectButtonPlaceholder, tabsTabActive, tabsTabWrapperActive, tableThIsActive, calendarDayIsSelected, calendarDayIsFocused, calendarDayIsDisabled, calendarDayIsSelectionStart, calendarDayIsSelectionEnd, calendarDayIsRangeSelection, datePickerPresetIsActive and datePickerPresetIsDisabled, and those entries go too. Search for both the class strings and those names: no codemod can find a selector built at runtime.

SideNav state classes are removed

The SideNav root stops setting pluma-c-side-nav-collapsed and pluma-c-side-nav-expanded, and PlumaSideNav.classNames drops sideNavCollapsed and sideNavExpanded.

No codemod covers this. Class names live in stylesheets and string selectors, so search for both names by hand.

Selection controls leave the action fill

color.scope-action-solid-* is now only the fill of a primary action, such as the primary Button. It carries a label, so it is set for that label's contrast at rest, hover and pressed. The chosen state of a form control -- a checked Checkbox, a Radio dot, a Toggle that is on, a Slider's filled track -- reads the new color.scope-control-solid-bg, with -mark for a checkmark or dot. A chosen state that carries a label, like the selected SegmentedControl segment, stays on action-solid. That fill is set to hold 3:1 against the planes it sits on.

action-solid-bg is teal 800 in both themes. control-solid-bg is teal 700 in both themes: teal 800 falls to about 2.1:1 against the dark planes, and one step for the controls keeps them the same teal in light and dark. In light that makes a checked control a step lighter than it was, and a step lighter than the primary Button.

An app that brands a region by setting color.scope-action-solid-* on a wrapper now reaches only the primary Button and the selected SegmentedControl segment there. Set color.scope-control-solid-bg and -mark too, or checked Checkboxes, Radios, Toggles and Sliders stay teal inside the brand region.

An app that paints its own selection control with color.scope-action-solid-* still builds, but in dark its "on" state loses its 3:1 edge against the page. Move it to color.scope-control-solid-*. No codemod can tell a button-like use from a selection-like one, so this needs a human read.

Component tokens are removed

Every --pluma-<component>-* custom property is gone — 457 of them, from --pluma-accordion-bgColor to --pluma-tooltipContent-textColor. They were an intermediate layer: each one was a single alias onto a semantic token, compiled onto the theme container. Components now read the semantic token where they paint.

The layer also could not do the job it looked like it did. A var() inside a custom property is substituted where that property is declared, so --pluma-checkbox-bgColor-checked: var(--pluma-color-scope-accent-solid-bg) was flattened on the theme container, and a scope override set on a wrapper never reached the Checkbox.

Nothing replaces the names one-for-one, so there is no codemod. Reach for one of two things instead:

  • To restyle one component, set its own --pluma-c-* override variable. Every existing one keeps its name, and nineteen components gained one for each color they used to expose through a component token or read straight from the theme — PlumaCheckbox.vars.checkboxBackgroundColor still has that exact key, it just points at the component's own variable now, so setting it recolors that Checkbox and nothing else.
  • To recolor a whole subtree, set the semantic token on a wrapper — color.scope-accent-*, color.surface-*, color.text-*. This now works, which is the point of the change.

This closes the limitation the pipelinesTheme entry below used to carry. A wrapper-level color.scope-accent-* override now reaches every Pluma component inside it, rather than only the ones that happened to read the property directly.

/* v4 — flattened on the theme container, so a wrapper override never landed */
--pluma-checkbox-bgColor-checked: rebeccapurple;

/* v5 — the component's own override variable */
--pluma-c-checkbox-checked-background-color: rebeccapurple;

PlumaComponentTokens, the componentTokens field on a theme, and the componentOverrides theme option are removed with it. A theme object is now its tokens, tokensMeta, styles, colorScheme, name and version. @customerio/pluma-css's dist/tokens.css drops the matching 457 bare properties (--checkbox-bgColor-checked and friends).

pipelinesTheme is removed

The pipelines brand overlay and its pipelinesTheme export are gone from @customerio/pluma-components/css. v5 ships lightTheme and darkTheme; darkerTheme was already removed in pluma-components 4.0.0.

Unlike defaultTheme, it has no successor, so the theme-tokens-rename codemod leaves it alone: there is no theme to rewrite it to. An app still importing it gets a build error naming the missing export.

// v4
import { pipelinesTheme } from '@customerio/pluma-components/css';

<PlumaProvider theme={pipelinesTheme}>{children}</PlumaProvider>;

The overlay pointed the accent and action scopes at an aqua ramp of its own and touched nothing below the semantic layer. An app that wants that accent back sets the color.scope-accent-* and color.scope-action-* custom properties on a wrapper element, either as themeVars.color keys or as the --pluma-color-scope-accent-* names directly. That reaches every Pluma component inside the wrapper, with nothing to set per component.

Badge gains a variant prop

Badge takes variant in v5 (solid, subtle, outline), typed as BadgeVariant. A wrapper that already owns a prop by that name and builds its props from Badge's now declares variant twice:

import type { BadgeProps } from '@customerio/pluma-components/react';

type VariantLabelVariant = 'added' | 'removed';

// `Omit` drops `children` and `color`, but not `variant` — so the wrapper's
// `variant` and Badge's `variant` intersect, and the prop resolves to `never`.
type VariantLabelProps = Omit<BadgeProps, 'children' | 'color'> & { variant: VariantLabelVariant };

An intersection of two unions with no members in common is never, and a props type with a never member cannot be spread or used as a component's props. The errors do not point at the wrapper. In one app they surfaced three files away as 'VariantLabel' cannot be used as a JSX component and TS2700: Rest types may only be created from object types, neither of which names variant or Badge.

Add 'variant' to the Omit, or rename the wrapper's prop:

type VariantLabelProps = Omit<BadgeProps, 'children' | 'color' | 'variant'> & { variant: VariantLabelVariant };

No codemod can find this. A codemod sees the call sites of a prop, not the type arithmetic three files upstream of them, and the wrapper's own source is valid TypeScript either way.

Adding a prop is a breaking change for wrappers

This is not specific to Badge. Any new prop on a widely wrapped component breaks every consumer that already owns a prop by that name and derives its props with Omit or an intersection. The consumer's code did not change, the name did not change, and the compiler reports the failure somewhere else.

When you wrap a Pluma component and add props of your own, list every prop you are replacing in the Omit, including the ones the base component does not have yet. When you add a prop to a Pluma component, expect it to land as a major.