Interaction states

Which helper to use for hover, pressed, disabled and scope backgrounds when you write your own styles for a Pluma component or a one-off element.


Use these helpers in your own vanilla-extract styles, so a custom element answers hover, pressed and disabled the way Pluma components do.

There are no hover or pressed color tokens, because a state depends on the element. A shift is computed from the element's own color. A layer is mixed into the element's own background, so what it looks like depends on that background. A subtle fill can't be one token either: it is a plain color or a mix, depending on the theme. The helpers compute the right value for the element and the active theme.

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

For the props every component accepts, see Style utilities. For loading, empty and error screens, see States.

Choosing a helper

Pick the rule by what the element paints.

  • A colored fill, such as a primary button or a checked toggle: shiftHover(fill) and shiftPressed(fill), on the property the fill is on.

  • Anything neutral, such as a transparent menu item, a surface-colored button face or a tinted row: layerHover() and layerPressed(), or layerHover(bg) and layerPressed(bg) when the element has a background of its own, on backgroundColor.

  • Text, borders and icons: shiftHoverStroke(color), on the property the color is on.

  • Disabled: spread disabledStyles into the style of the control's outermost element.

For a scope's background at rest, such as a callout, see Fill. The rest of the helpers are in Less common cases.

A colored fill

A shift moves the lightness of the color you pass and keeps its hue: down in the light theme, up in the dark theme. Pressed moves further than hover.

Pass the element's resting fill and set the result on the same property.

import { style } from '@vanilla-extract/css';
import { themeVars } from '@customerio/pluma-components/css/theme-tokens';
import { shiftHover, shiftPressed } from '@customerio/pluma-components/css/utils';

const fill = themeVars.color['scope-action-solid-bg'];

export const solidButton = style({
	backgroundColor: fill,

	selectors: {
		'&:hover': { backgroundColor: shiftHover(fill) },
		'&:active': { backgroundColor: shiftPressed(fill) },
	},
});

Anything neutral

layerHover and layerPressed add a semi-transparent layer to the element's background. Pressed is stronger than hover.

For an element with no background of its own, such as a menu item, a table row or a tertiary button, call layerHover() and layerPressed() with no argument. Whatever is behind the element shows through the layer.

For an element with a background of its own, pass that background as bg: the same value you set at rest, or for a Pluma component, its background from the component's resolvedVars. The layer is mixed into it.

import { style } from '@vanilla-extract/css';
import { themeVars } from '@customerio/pluma-components/css/theme-tokens';
import { layerHover, layerPressed } from '@customerio/pluma-components/css/utils';

export const menuItem = style({
	selectors: {
		'&:hover': { backgroundColor: layerHover() },
		'&:active': { backgroundColor: layerPressed() },
	},
});

const face = themeVars.color['scope-control-bg'];

export const faceButton = style({
	backgroundColor: face,

	selectors: {
		'&:hover': { backgroundColor: layerHover(face) },
		'&:active': { backgroundColor: layerPressed(face) },
	},
});

If the background changes with a state, such as a striped or selected row, pass the background for that state.

Text, borders and icons

shiftHoverStroke shifts a text, border or icon color on hover. It takes a bigger step than shiftHover, so the change shows on a thin stroke.

import { style } from '@vanilla-extract/css';
import { themeVars } from '@customerio/pluma-components/css/theme-tokens';
import { shiftHoverStroke } from '@customerio/pluma-components/css/utils';

const color = themeVars.color['scope-action-text'];

export const link = style({
	color,

	selectors: {
		'&:hover': { color: shiftHoverStroke(color) },
	},
});

Disabled

disabledStyles dims the control as a whole and keeps its colors. It also sets a not-allowed cursor and handles forced-colors mode.

Spread it on the control's outermost element, and reset the control's hover and pressed styles on the same selector.

import { style } from '@vanilla-extract/css';
import { disabledStyles, layerHover } from '@customerio/pluma-components/css/utils';

export const control = style({
	selectors: {
		'&:hover': { backgroundColor: layerHover() },
		'&[aria-disabled="true"]': { ...disabledStyles, backgroundColor: 'transparent' },
	},
});

The dimming already covers everything inside the control, so don't dim its children again. For content that describes a disabled control without being part of it, such as a field label, helper text or a standalone icon, use themeVars.color['text-disabled'] or themeVars.color['mark-disabled'].

For the dimming alone, set opacity to themeVars.opacity['disabled'].

Fill

Each theme defines a scope's subtle fill either as a plain color or as a mix of the scope's color into the surface, so no single token holds it. fillScope(scope, 'subtle') returns whichever the active theme defines, and fillScope(scope, 'solid') returns the scope's saturated background.

Set the result on backgroundColor. Don't read themeVars.color['scope-<name>-subtle-bg'] directly: a theme that uses a mix leaves it unset.

import { style } from '@vanilla-extract/css';
import { fillScope } from '@customerio/pluma-components/css/utils';

export const callout = style({
	backgroundColor: fillScope('accent', 'subtle'),
});

fillScope takes every semantic name, every palette hue and control. focus has no background; read themeVars.color['scope-focus-border'] instead.

Less common cases

A tinted layer

Both arguments of layerHover and layerPressed are optional, and tint is the second. Pass a color as tint to make the layer from that color instead of the default neutral layer: the scope's text color, not its solid fill. On an element without a background of its own, that is layerHover('transparent', tint).

import { style } from '@vanilla-extract/css';
import { themeVars } from '@customerio/pluma-components/css/theme-tokens';
import { layerHover, layerPressed } from '@customerio/pluma-components/css/utils';

const tint = themeVars.color['scope-destructive-text'];

export const deleteItem = style({
	color: tint,

	selectors: {
		'&:hover': { backgroundColor: layerHover('transparent', tint) },
		'&:active': { backgroundColor: layerPressed('transparent', tint) },
	},
});

A region in a hue

Assign a scope's text color to themeVars.colorMix['overlay-color'] on an element, and every neutral layer inside it, including those in Pluma components, is made from that color.

This replaces overriding a v4 *-hover color token; see Migrating to v5.

import { style } from '@vanilla-extract/css';
import { themeVars } from '@customerio/pluma-components/css/theme-tokens';

export const accentRegion = style({
	vars: {
		[themeVars.colorMix['overlay-color']]: themeVars.color['scope-accent-text'],
	},
});

The inverse surface

layerInverseVars flips the layer for an element that paints the inverse surface. Spread it into the vars of an element that paints color.surface-inverse or color.surface-inverse-raised, and every neutral layer inside it flips, including those in Pluma components.

Keep using layerHover() and layerPressed() inside it. Tooltip, Snackbar and the inverted KeyboardShortcut already set it.

import { style } from '@vanilla-extract/css';
import { themeVars } from '@customerio/pluma-components/css/theme-tokens';
import { layerInverseVars } from '@customerio/pluma-components/css/utils';

export const inversePanel = style({
	backgroundColor: themeVars.color['surface-inverse'],
	color: themeVars.color['text-inverse'],
	vars: { ...layerInverseVars },
});

Other fills

fillMix(color, percentage?, into?) mixes a color into another. By default it mixes the way subtle fills are made, so use it for a fill that isn't a scope. fillMix(color, '40') is a stronger one.

To make the mixed fills in a region stronger or weaker, assign a plain number, such as '40' rather than '40%', to themeVars.colorMix['fillSubtle-percentage'] on the element.

This changes every fillMix that uses the default percentage. It changes fillScope's subtle fills only in themes that define them as a mix.

import { style } from '@vanilla-extract/css';
import { themeVars } from '@customerio/pluma-components/css/theme-tokens';

export const loudBanner = style({
	vars: { [themeVars.colorMix['fillSubtle-percentage']]: '40' },
});

For every color token, see Color tokens.