PlumaProvider

Configure the provider that supplies themes, icons, routing, and app-level Pluma behavior.


PlumaProvider provides context to all Pluma components. More specifically, it:

  • Provides a theme context
  • Adds the theme's CSS variables
  • Imports and adds icon sprites
  • Provides additional global or component-specific flags

It is required to render PlumaProvider at the root of your app.

Theming

A theme object provides all the tokens and styles for a theme. It can be passed to PlumaProvider as the theme prop:

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

export function App() {
	return <PlumaProvider theme={lightTheme}>...</PlumaProvider>;
}

Full theme objects come from Pluma — import lightTheme or darkTheme from @customerio/pluma-components/css. To change some tokens in part of your app, create a partial theme.

PartialPlumaTheme is the shape of a theme that sets only some of the tokens. Besides createPartialPlumaTheme, usePlumaTheme() in React, or @consume(PlumaThemeContext) in Ember, returns one when a nested PlumaProvider has merged its theme over an ancestor's. It's a theme prop value like any other, so you can pass it straight into another PlumaProvider — which is what the microfrontend guidance below asks you to do. That result already includes the full theme it was merged over, so passing it on does not nest one partial theme inside another.

PlumaProvider will render a div element and attach the theme's class name to it, which adds all the theme's CSS variables to that container.

If you need to add additional classes to that element, add them to PlumaProvider, which will forward them to the div:

<PlumaProvider theme={lightTheme} className="my-app-root-class">
	...
</PlumaProvider>

If you don't need to render the theme container - for example, when using Pluma Components inside a microfrontend where the root app already has a theme - you can prevent PlumaProvider from rendering it with the shouldRenderContainer argument:

<PlumaProvider theme={inheritedTheme} shouldRenderContainer={false}>
	...
</PlumaProvider>

Note that it is still recommended to provide a theme, even to a PlumaProvider within a nested microfrontend. You should retrieve the theme from the root app and pass it into the microfrontends, in case any code or component needs to access the theme's variables via context.

Partial themes

A partial theme sets only some tokens and takes the rest from the theme it is nested in. Create one with createPartialPlumaTheme from @customerio/pluma-components/css/partial-theme, in a vanilla-extract .css.ts file. It runs in your vanilla-extract build, so it is available to React apps with that build only; Ember apps can't call it.

// brand-theme.css.ts
import { createPartialPlumaTheme } from '@customerio/pluma-components/css/partial-theme';

export const brandTheme = createPartialPlumaTheme({
	name: 'brand',
	colorScheme: 'light',
	tokens: {
		'font-family': { sans: '"Inter", sans-serif' },
		radius: { '4': '6px' },
	},
});

Pass it as theme to a PlumaProvider nested inside one that already has a full theme:

import { lightTheme } from '@customerio/pluma-components/css';
import { brandTheme } from './brand-theme.css';

<PlumaProvider theme={lightTheme}>
	<PlumaProvider theme={brandTheme}>...</PlumaProvider>
</PlumaProvider>;

Nest a partial theme directly inside a Provider with a full theme. Don't nest a partial theme inside another partial theme; to combine changes, put them all in one partial theme.

The partial theme changes every token it sets and every token that aliases one of them, so setting radius.4 also changes radius.button-medium. Menus, popovers and tooltips rendered in a portal from inside the nested Provider get the partial theme too.

The partial theme's colorScheme must match the scheme of the Provider it is nested in. To change color tokens, create one partial theme per scheme and pick the one for the current scheme:

const colorScheme = usePlumaColorScheme();

<PlumaProvider theme={colorScheme === 'dark' ? brandDarkTheme : brandLightTheme}>...</PlumaProvider>;

Brand colors

A theme is not the lever for brand color. To paint a region in your own brand, set the color.scope-accent-*, color.scope-action-* and color.scope-control-solid-* custom properties on a wrapper element. That reaches every Pluma component inside the wrapper — Button, Link, a checked Checkbox, a focused Select, all of them — with nothing to set per component. The action rungs paint the primary Button and anything else that carries a label on the brand fill; control-solid-bg and control-solid-mark paint a checked Checkbox, a Radio dot, a Toggle that is on and a Slider's filled track. A brand that moves the action fill and leaves the control fill out gets a brand-colored Button next to teal controls. getVarName turns a token into the property name to assign:

import { themeVars } from '@customerio/pluma-components/css/theme-tokens';
import { getVarName } from '@customerio/pluma-components/css/utils';

<div
	style={{
		[getVarName(themeVars.color['scope-accent-solid-bg'])]: '#0f7d8c',
		[getVarName(themeVars.color['scope-accent-text'])]: '#0a5b66',
		[getVarName(themeVars.color['scope-accent-border'])]: '#0f7d8c',
	}}
>
	...
</div>

In a stylesheet there is nothing to unwrap, so write the emitted property names directly:

.brand-region {
	--pluma-color-scope-accent-solid-bg: #0f7d8c;
	--pluma-color-scope-accent-text: #0a5b66;
	--pluma-color-scope-accent-border: #0f7d8c;
}

color.scope-action-* carries the same rungs as color.scope-accent-*: text, border, mark, solid-bg, solid-text, solid-mark, subtle-bg, subtle-bgMix, subtle-border, subtle-text and subtle-mark. color.scope-control-solid-* carries bg and mark. Set only the ones your brand moves; anything you leave out keeps the theme's value. subtle-bg and subtle-bgMix are a pair, authored per theme, so a brand that supports both color schemes sets both — see Interaction states.

Skins

A skin changes component styles, such as corner radius and padding, across your app. It is not a theme: a theme sets token values, and a skin sets component variables. If a skin also changes tokens, such as the font family, put those in a partial theme.

A skin is a vanilla-extract .css.ts file. Scope its rules under an attribute on <html>, put them in layers.skins, and set a component's privateVars on the class from its classNames that assigns them:

import { globalStyle } from '@vanilla-extract/css';
import { layers } from '@customerio/pluma-components/css';
import { PlumaDropdownMenu, PlumaPopover } from '@customerio/pluma-components/css/components';

globalStyle(`[data-skin='compact'] .${PlumaDropdownMenu.classNames.dropdownMenuContent}`, {
	'@layer': {
		[layers.skins]: {
			vars: {
				[PlumaDropdownMenu.privateVars.dropdownMenuContentBorderRadius]: '4px',
				[PlumaDropdownMenu.privateVars.dropdownMenuContentPadding]: '2px',
			},
		},
	},
});

globalStyle(`[data-skin='compact'] .${PlumaPopover.classNames.popoverContent}`, {
	'@layer': {
		[layers.skins]: {
			vars: {
				[PlumaPopover.privateVars.popoverContentBorderRadius]: '4px',
			},
		},
	},
});

A private variable only takes effect on the class that assigns it. DropdownMenu assigns its radius and padding on the menu content, so the skin above targets that class. Because the names are imported, a renamed variable fails your typecheck instead of silently doing nothing.

Put the attribute on <html> so menus, popovers and tooltips rendered in a portal are skinned too.

A public variable you set on a component, and any CSS in layers.app, still win over a skin.

Color scheme

In addition to the explicit theme prop, PlumaProvider accepts a colorScheme prop that picks between light and dark tokens at runtime. It accepts:

  • 'light' (default) — apply the light tokens.
  • 'dark' — apply the dark tokens.
  • 'auto' — follow the user's system preference via prefers-color-scheme. The Provider listens for changes and updates dynamically.

To use colorScheme you also pass themes — an object with light and/or dark theme objects. The Provider picks the matching one based on the resolved scheme:

import { lightTheme, darkTheme } from '@customerio/pluma-components/css';

export function App() {
	return (
		<PlumaProvider
			colorScheme="auto"
			themes={{ light: lightTheme, dark: darkTheme }}
		>
			...
		</PlumaProvider>
	);
}
<PlumaProvider
	@colorScheme='auto'
	@themes={{hash light=this.lightTheme dark=this.darkTheme}}
>
	...
</PlumaProvider>

themes is consumer-supplied so callers that always pass their own theme don't pull the canonical light/dark theme CSS into their bundle. Import the canonical themes from @customerio/pluma-components/css if you want the standard light/dark behavior.

The keys describe the resolved color scheme, not a fixed theme identity. A consumer can put an alternate dark theme in the existing dark slot without expanding the persisted 'auto' | 'light' | 'dark' preference contract:

import { darkTheme, lightTheme } from '@customerio/pluma-components/css';

<PlumaProvider colorScheme="auto" themes={{ light: lightTheme, dark: darkTheme }}>
	...
</PlumaProvider>;

When the theme prop is provided, colorScheme and themes are ignored — theme always wins as an explicit override.

Reading the current scheme

Three contexts are exposed for descendants:

  • PlumaThemeContext (usePlumaTheme() in React, @consume(PlumaThemeContext) in Ember) — the effective theme object.
  • PlumaColorSchemeContext (usePlumaColorScheme() / @consume(PlumaColorSchemeContext)) — the resolved scheme, always 'light' or 'dark'. When colorScheme is 'auto', this is the system-resolved value. Use this for conditional logic that needs a concrete scheme.
  • PlumaColorSchemePreferenceContext (usePlumaColorSchemePreference() / @consume(PlumaColorSchemePreferenceContext)) — the unresolved preference ('auto' | 'light' | 'dark'). Used by components like Image that need to decide whether to swap variants via prefers-color-scheme media queries (auto) or by filtering upfront (explicit).

Nested Providers

themes propagates through context, so a nested PlumaProvider can specify only colorScheme and resolve against an ancestor's themes:

<PlumaProvider colorScheme="auto" themes={{ light: lightTheme, dark: darkTheme }}>
	{/* follows the user's system preference */}
	<Outlet />

	<PlumaProvider colorScheme="dark">
		{/* forced to dark, resolved against the ancestor's `themes.dark` */}
		<DarkOnlyArea />
	</PlumaProvider>
</PlumaProvider>

When a nested PlumaProvider has colorScheme (and themes are in scope, own or inherited), the matching variant wins over a theme inherited from PlumaThemeContext. To extend the inherited themes — overriding e.g. only dark — pass a partial themes object: it's merged over the inherited one, so the unaltered variants stay accessible.

Icons

If you're planning to use the Icon component, you'll need to pass in an IconManager instance. The IconManager class comes from the @customerio/pluma-icons package.

import { lightTheme } from '@customerio/pluma-components/css';
import { IconManager } from '@customerio/pluma-icons';

// Instantiate the IconManager
const iconManager = new IconManager();

export function App() {
	return <PlumaProvider theme={lightTheme} iconManager={iconManager}>...</PlumaProvider>;
}

An IconManager is responsible for importing the icon SVG sprite sheet, and attaching it to the document body. When PlumaProvider receives an IconManager, PlumaProvider will instruct IconManager to load and attach the sprites when the app first renders.

The sprites only get loaded and attached when PlumaProvider renders, so it's safe to instantiate the IconManager instance outside of components or effects. Additionally, a single IconManager instance will only attach sprites once, even if called multiple times.

In microfrontends, only the root app needs to provide an IconManager. The sprites are attached to the document body, and are then available globally, to all embedded microfrontends.

Similar to the theme object, it is recommended to pass in the IconManager instance from the root app's context into the microfrontend PlumaProvider, in case any components need to reference the IconManager.

Client side routing

Pluma includes link components such as PlainLink and Link, which may perform navigation events. By default, those components render plain a tags, which would use the browser's native navigation, which cause a full page reload.

In a single page application, there is typically a routing library which takes over linking and issues navigation events internally, without refreshing the whole page. While Pluma itself is agnostic towards what an app's routing setup is, it allows customizing the link component, so that consuming apps may still navigate correctly.

A custom component can be defined on PlumaProvider through the linkComponent prop.

There are a few basic requirements the component must adhere to in order to work correctly:

  • it must implement the LinkComponent type
    • in React: import type { LinkComponent } from '@customerio/pluma-components/react';
    • in Ember: import type { PlumaLinkComponent, PlumaLinkComponentSignature } from '@customerio/pluma-components/ember';
  • in React, the component must use forwardRef
  • by implementing the type, the component must accept at least these two arguments:
    • href - a string, the target URL for the router navigation
    • replace - a boolean, which indicates that the transition should replaceState rather than pushState
  • it's recommended the component render a regular a tag, and not a Box component, as Pluma already wraps the component in Boxes internally

In React with React Router, this setup might look like:


import { forwardRef } from "react";
import { Link as ReactRouterLink, Outlet } from 'react-router-dom';
import { PlumaProvider } from '@customerio/pluma-components/react';
import type { LinkComponent } from '@customerio/pluma-components/react';

const LinkDefinition = forwardRef(function(props, ref) {
	const { href, replace, ...restProps } = props;
	return <ReactRouterLink to={href} replace={replace} {...restProps} ref={ref} />;
}) as LinkComponent;

export function App() {
	return (
		<PlumaProvider linkComponent={LinkDefinition}>
			<Outlet />
		</PlumaProvider>
	);
}

In Ember, it might look like:

// app/components/link-definition.ts
import Component from '@glimmer/component';
import { service } from '@ember/service';
import RouterService from '@ember/routing/router-service';
import type { PlumaLinkComponentSignature } from '@customerio/pluma-components/ember';

export default class LinkDefinition extends Component<PlumaLinkComponentSignature> {
	@service('router') router!: RouterService;

	onClick = (event: MouseEvent) => {
		event.preventDefault();
		const targetHref = event.currentTarget.href.replace(window.location.origin, '');
		this.router.transitionTo(targetHref);
	}
}
{{!-- app/components/link-definition.hbs  --}}
<a ...attributes href={{@href}} {{on "click" this.onClick}}>{{yield}}</a>
// app/controllers/application.ts
import Controller from '@ember/controller';
import LinkDefinition from 'my-app/components/link-definition';

export default class ApplicationController extends Controller {
	LinkDefinition = LinkDefinition;
}
{{!-- app/templates/application.hbs  --}}
<PlumaProvider @linkComponent={{this.LinkDefinition}}>
	{{outlet}}
</PlumaProvider>

Note: In Ember apps, we typically use the LinkTo component for router links. That component accepts arguments like @model and @route instead of a @href string, which is different from what we require for Pluma's link component.

Our recommendation is to use an addon like ember-link or a custom helper to generate the href string. Invoking a PlumaLink component might then look like:

<PlumaLink
	@href={{route-href
		route="campaigns.campaign"
		model=this.model.id
	}}
>
	View campaign
</PlumaLink>

While the helper might be:

import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import type RouterService from '@ember/routing/router-service';

export default class extends Helper {
	@service router!: RouterService;

	compute(_, { route, model }) {
		return this.router.urlFor(route, model);
	}
}

Component config

Certain components may expose a set of default or configuration props that can be set from the PlumaProvider level. For example, the Snackbar component's close timeout can be modified like this.

PlumaProvider accepts a componentConfig prop, which is an object with component names for keys. Please refer to the individual component docs for more information on what configuration is available.

API

Which color scheme to apply.

  • 'light' (default): apply the light tokens.
  • 'dark': apply the dark tokens.
  • 'auto': detect the user's system preference via @media (prefers-color-scheme: dark) and apply light or dark tokens accordingly. The Provider listens for changes and updates dynamically.

Ignored when theme is provided — theme always wins.

componentConfigDeepPartial<{ PlumaModal?: (BaseModalProviderProps & { animationTransitionDuration?: number | undefined; }) | undefined; PlumaDrawer?: (BaseDrawerProviderProps & { animationTransitionDuration?: number | undefined; }) | undefined; PlumaPopover?: { floatingPluginOptions?: { click?: { enabled?: boolean | undefined; event?: "click" | "mousedown" | undefined; toggle?: boolean | undefined; ignoreMouse?: boolean | undefined; keyboardHandlers?: boolean | undefined; } | undefined; hover?: { enabled?: boolean | undefined; mouseOnly?: boolean | undefined; delay?: number | Partial<{ open: number; close: number; }> | undefined; restMs?: number | undefined; move?: boolean | undefined; handleClose?: any; } | undefined; focus?: { enabled?: boolean | undefined; visibleOnly?: boolean | undefined; } | undefined; dismiss?: { enabled?: boolean | undefined; escapeKey?: boolean | undefined; referencePress?: boolean | undefined; referencePressEvent?: "click" | "mousedown" | "pointerdown" | undefined; outsidePress?: boolean | ((event: MouseEvent) => boolean) | undefined; outsidePressEvent?: "click" | "mousedown" | "pointerdown" | undefined; ancestorScroll?: boolean | undefined; bubbles?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined; } | undefined; capture?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined; } | undefined; } | undefined; role?: { enabled?: boolean | undefined; role?: "dialog" | "label" | "menu" | "select" | "alertdialog" | "combobox" | "grid" | "listbox" | "tooltip" | "tree" | undefined; } | undefined; } | undefined; animationTransitionDuration?: number | undefined; } | undefined; PlumaDropdownMenu?: { floatingPluginOptions?: { click?: { enabled?: boolean | undefined; event?: "click" | "mousedown" | undefined; toggle?: boolean | undefined; ignoreMouse?: boolean | undefined; keyboardHandlers?: boolean | undefined; } | undefined; hover?: { enabled?: boolean | undefined; mouseOnly?: boolean | undefined; delay?: number | Partial<{ open: number; close: number; }> | undefined; restMs?: number | undefined; move?: boolean | undefined; handleClose?: any; } | undefined; focus?: { enabled?: boolean | undefined; visibleOnly?: boolean | undefined; } | undefined; dismiss?: { enabled?: boolean | undefined; escapeKey?: boolean | undefined; referencePress?: boolean | undefined; referencePressEvent?: "click" | "mousedown" | "pointerdown" | undefined; outsidePress?: boolean | ((event: MouseEvent) => boolean) | undefined; outsidePressEvent?: "click" | "mousedown" | "pointerdown" | undefined; ancestorScroll?: boolean | undefined; bubbles?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined; } | undefined; capture?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined; } | undefined; } | undefined; role?: { enabled?: boolean | undefined; role?: "dialog" | "label" | "menu" | "select" | "alertdialog" | "combobox" | "grid" | "listbox" | "tooltip" | "tree" | undefined; } | undefined; } | undefined; animationTransitionDuration?: number | undefined; } | undefined; PlumaSelect?: { menuAnimationTransitionDuration?: number | undefined; } | undefined; PlumaSnackbar?: { snackbarApi?: IPlumaSnackbarApi | undefined; autoCloseTimeout?: number | undefined; animationTransitionDuration?: number | undefined; } | undefined; PlumaTooltip?: { floatingPluginOptions?: { hover?: { enabled?: boolean | undefined; mouseOnly?: boolean | undefined; delay?: number | Partial<{ open: number; close: number; }> | undefined; restMs?: number | undefined; move?: boolean | undefined; handleClose?: any; } | undefined; focus?: { enabled?: boolean | undefined; visibleOnly?: boolean | undefined; } | undefined; dismiss?: { enabled?: boolean | undefined; escapeKey?: boolean | undefined; referencePress?: boolean | undefined; referencePressEvent?: "click" | "mousedown" | "pointerdown" | undefined; outsidePress?: boolean | ((event: MouseEvent) => boolean) | undefined; outsidePressEvent?: "click" | "mousedown" | "pointerdown" | undefined; ancestorScroll?: boolean | undefined; bubbles?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined; } | undefined; capture?: boolean | { escapeKey?: boolean | undefined; outsidePress?: boolean | undefined; } | undefined; } | undefined; role?: { enabled?: boolean | undefined; role?: "dialog" | "label" | "menu" | "select" | "alertdialog" | "combobox" | "grid" | "listbox" | "tooltip" | "tree" | undefined; } | undefined; } | undefined; animationTransitionDuration?: number | undefined; } | undefined; PlumaIcon?: { iconNameOverrides?: Partial<Record<NonLegacyIconName, NonLegacyIconName>> | undefined; } | undefined; PlumaCodeBlock?: (Pick<BaseCodeBlockProps, "mode" | "lightTheme" | "darkTheme"> & { shouldMatchColorScheme?: boolean | undefined; }) | undefined; }>

Component-specific default props or configuration to apply universally

An instance of a GlobalThemeManager, which manages global theme state and variables

By default, global variables will live in a <style> element just under the PlumaProvider. If a different container is specified, the the <style> element will be portaled into that container instead.

An instance of an IconManager, which attaches the icon sprites

Whether the ScrollbarProvider should be disabled, preventing it from measuring scrollbar widths.

The component that will be used for Link and PlainLink

Where to portal components like Tooltip into

Whether an "isolation: isolate" CSS class should be applied to the theme container

Whether the provider should override global variables if provided theme version is newer than the current global theme version.

Whether the theme container div (with theme class name) should be rendered

The theme to apply to the provider

The root element to set theme attributes on. Can be a CSS selector string, an HTMLElement, or a function returning an HTMLElement. Defaults to document.documentElement.

The themes to apply when theme is not provided. The Provider picks themes.light or themes.dark based on the resolved colorScheme and passes it through the global theme manager (which sets the root data attributes and, when configured, applies global CSS variables).

Pass these explicitly so consumers that always provide their own theme don't pull the canonical light/dark theme CSS into their bundle. Import the canonical themes from @customerio/pluma-components/css if you want the standard light/dark behavior.

Whether to load baseline styles, which include global styles for the body element.

Whether to load global reset styles (box-sizing, font inheritance, etc.).

Whether the theme variables should be set globally on :root instead of as a class name on a div container.