Skip to Content

ev

Creates a type-safe style function from a single configuration object.

const button = ev(config);

Usage

ev(config)

Use ev when a component has more than one visual axis — size, color, fill, state — and you want all of them declared in one place. It returns a function you call with variant values; the result goes straight to Emotion's css prop.

import { ev } from 'emotion-variants';

const button = ev({
  base: {
    padding: '10px 20px',
    borderRadius: 8,
    cursor: 'pointer',
  },
  variants: {
    size: {
      small: {
        fontSize: 12,
      },
      large: {
        fontSize: 16,
      },
    },
    color: {
      primary: {
        background: 'blue',
        color: 'white',
      },
      secondary: {
        background: 'gray',
        color: 'white',
      },
    },
  },
  defaultVariants: {
    size: 'small',
    color: 'primary',
  },
});

The configuration object is required. Pass ev({}) if you have nothing to declare yet.

Parameters

  • config.base (CSSObject): Styles applied on every call. Serialized once when ev() runs, not per call.
  • config.variants (Record<string, Record<string, CSSObject>>): The variant axes. Each axis maps a value to its styles. Because values are object keys, write Boolean and number values as keys — true, false, 1 — and they are typed back to boolean and number literals.
  • config.compoundVariants (Array<{ variants, style }>): Styles applied only when every listed variant value matches. A condition can also be an array of values, which matches any of them. Note the key is style, singular.
  • config.defaultVariants (Partial<Record<Axis, Value>>): Values used when the caller omits an axis.
  • config.slots (Record<string, CSSObject>): The parts of a multi-element component and their base styles. Replaces base, and keys every variant value and compound style by slot name. See Slots.
  • config.compoundSlots (Array<{ slots, variants?, style }>): One style applied to several slots, always or only when variants match. Requires slots.
  • config.extend (EvFunction | EvSlotsFunction): Another ev() result to build on. Its styles, variants, compound variants, and default variants are inherited. See extend.

Returns

(EvFunction<T>): A callable object. Call it to get styles, or read className(), classNames, and variants() off it. With config.slots it is an EvSlotsFunction<N, T> instead, described under Slots.

button(variants)

Call the variant function to get the styles for one combination. Pass the result to the css prop or to Emotion's css().

import type { ComponentPropsWithoutRef } from 'react';
import type { VariantProps } from 'emotion-variants';

type ButtonProps = ComponentPropsWithoutRef<'button'> & VariantProps<typeof button>;

function Button({ size, color, ...rest }: ButtonProps) {
  return <button css={button({ size, color })} {...rest} />;
}
<Button>Uses defaultVariants</Button>
<Button size="large" color="primary">Large primary</Button>

Values are resolved per key with ??, so an omitted or undefined value falls back to defaultVariants. That is what lets Button above forward its optional props straight through without guarding them. false and 0 are real values and are used as-is.

null also falls back at runtime, but EvVariants does not include it — under strictNullChecks, button({ size: null }) is a type error. Convert null to undefined before calling.

button({ size: undefined }); // identical to button()

An axis with neither a prop nor a default is skipped entirely.

Parameters

  • variants (EvVariants<T>, optional): Variant values. Every key is optional. With an ev from createEv, a value can also be responsive.

Returns

(SerializedStyles[]): Always an array, even when a single style matches. Styles are ordered base, then matching variants entries in the order the axes were declared, then matching compoundVariants in array order. Later entries win on CSS conflicts, and the order of keys you pass in makes no difference.

button.className(variants)

Use className() when you need a plain class string instead of the css prop — a non-React surface, a third-party component that only accepts className, or a test assertion. It merges base, the matching variants, and the matching compound variants into one class.

<button className={button.className({ size: 'large', color: 'primary' })}>Click me</button>;

// defaultVariants apply exactly as they do for button().
button.className();

Parameters

  • variants (EvVariants<T>, optional): Variant values. Every key is optional.

Returns

(string): One class name covering the whole combination.

button.classNames

A lookup table of the individual class names — one for base and one per variant value. Useful for composing with code that builds its own class lists, or for inspecting what was generated.

const button = ev({
  base: {
    padding: 10,
  },
  variants: {
    size: {
      small: {
        fontSize: 12,
      },
      large: {
        fontSize: 16,
      },
    },
    outlined: {
      true: {
        border: '1px solid',
      },
    },
    level: {
      1: {
        zIndex: 1,
      },
    },
  },
});

button.classNames.base; // 'css-abc123'
button.classNames.variants.size.small; // 'css-def456'

Keys use the string form of the value, so Boolean and number axes are read as true and 1:

button.classNames.variants.outlined.true;
button.classNames.variants.level[1];

Nothing is injected into the document until you first touch classNames, and the result is memoized — code that only uses the css prop pays nothing for it.

button.classNames === button.classNames; // true

classNames covers base and single variant values only. Compound variants are not represented here — there is no key for a combination. Use className() when compound styles matter.

Concatenating these classes is not the same as calling the function. Each is a separate rule of equal specificity, so when base and a variant set the same property the winner is whichever Emotion inserted first, not the order you list them in. Use className() whenever a variant has to beat base.

base is an empty string when the config has no base.

Returns

(EvClassNames<T>): { base, variants }, where variants[axis][value] is a class name.

button.variants()

Returns the axis names, in the order they were declared. Handy for building Storybook argTypes or a props table.

const button = ev({
  variants: {
    size: {
      small: {},
      large: {},
    },
    color: {
      primary: {},
      secondary: {},
    },
  },
});

button.variants(); // ['size', 'color']

A config without variants returns [].

Returns

(Array<VariantKeys<T>>): The axis names. The same array instance is returned on every call, so treat it as read-only.

Slots

Declare config.slots instead of config.base when a component is made of several elements. Variant values and compound style are then keyed by slot name, and the call returns one style function per slot.

const card = ev({
  slots: {
    root: {
      padding: 16,
    },
    title: {
      fontWeight: 600,
    },
  },
  variants: {
    size: {
      small: {},
      large: {
        root: {
          padding: 24,
        },
        title: {
          fontSize: 20,
        },
      },
    },
  },
  compoundSlots: [
    {
      slots: ['root', 'title'],
      style: {
        margin: 0,
      },
    },
  ],
  defaultVariants: {
    size: 'small',
  },
});

const { root, title } = card({
  size: 'large',
});

<section css={root()}>
  <h3 css={title()}>Title</h3>
</section>;

Each slot function takes the same variants as the call. A value is resolved per axis from the slot's props, then the call's props, then defaultVariants, and compound entries are matched for each slot against the values it resolved to.

Styles for a slot are ordered: its entry in slots, matching variants entries, matching compoundVariants, then matching compoundSlots.

The rest of the object differs from the plain form in two places:

  • card.className(variants) returns one class name per slot, as { root: string, title: string }.
  • There is no card.classNames.

card.variants() is unchanged.

config.base cannot be combined with config.slots, and only declared slot names are accepted wherever a slot is named. Both are type errors.

Returns

(EvSlotsFunction<N, T>): Calling it returns { [slot]: (slotVariants?) => SerializedStyles[] }.

See Slots for a walkthrough.

extend

Pass another ev() result as config.extend to build on it. The new function inherits the parent's styles, variants, compound variants, and default variants. The parent is not changed.

const button = ev({
  base: {
    padding: '10px 20px',
  },
  variants: {
    size: {
      small: {
        fontSize: 12,
      },
      large: {
        fontSize: 16,
      },
    },
  },
  defaultVariants: {
    size: 'small',
  },
});

const iconButton = ev({
  extend: button,
  base: {
    display: 'inline-flex',
  },
  variants: {
    size: {
      large: {
        width: 48,
      },
    },
    round: {
      true: {
        borderRadius: '50%',
      },
    },
  },
});

iconButton({
  size: 'large',
  round: true,
});
// padding, display, font-size, width, border-radius

The parent's styles come before the child's at every step, so the full order is:

  1. base, parent then child
  2. Matching variants entries, axis by axis; for each value, parent then child
  3. Matching compoundVariants entries, parent then child

Inherited axes keep their position and the child's new axes follow, which is also the order of iconButton.variants(). defaultVariants are merged per axis with the child's value winning. The child's compoundVariants and defaultVariants can refer to inherited axes and values.

Every compound variant comes after every variant style, so a parent's compound variant still wins over a style the child declares for the same variant value.

With slots, the parent and the child must both be slot functions. The child's slots are added to the parent's, and a child without slots keeps the parent's. compoundSlots are inherited along with compoundVariants.

extend throws when ev() runs in two cases:

  • The value is not an ev() result. If it did come from ev(), two copies of emotion-variants are probably installed.
  • A config with slots extends one without. This is a type error too, as is config.base on a child whose parent has slots.

See Extend for a walkthrough.

Try it out

Edit the variant values and watch the button change.

/** @jsxImportSource @emotion/react */
import type { ComponentPropsWithoutRef } from 'react';
import { ev, type VariantProps } from 'emotion-variants';

const button = ev({
  base: {
    border: 'none',
    borderRadius: 8,
    cursor: 'pointer',
    fontWeight: 600,
  },
  variants: {
    size: {
      small: {
        padding: '8px 16px',
        fontSize: 12,
      },
      large: {
        padding: '12px 24px',
        fontSize: 16,
      },
    },
    color: {
      primary: {
        background: '#d36ac2',
        color: 'white',
      },
      secondary: {
        background: '#475569',
        color: 'white',
      },
    },
  },
  compoundVariants: [
    {
      variants: {
        size: 'large',
        color: 'primary',
      },
      style: {
        boxShadow: '0 4px 12px rgba(211, 106, 194, 0.4)',
      },
    },
  ],
  defaultVariants: {
    size: 'small',
    color: 'primary',
  },
});

type ButtonProps = ComponentPropsWithoutRef<'button'> & VariantProps<typeof button>;

function Button({ size, color, ...rest }: ButtonProps) {
  return <button css={button({ size, color })} {...rest} />;
}

export default function App() {
  return (
    <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
      <Button>Default</Button>
      <Button size="large">Large primary</Button>
      <Button size="large" color="secondary">
        Large secondary
      </Button>
    </div>
  );
}

Types

See Types for EvConfig, EvFunction, EvVariants, and the rest of the exported type definitions.

Working with @emotion/css

className() and classNames go through @emotion/css, which keeps its own global cache separate from @emotion/react. That means it ignores CacheProvider options such as key and nonce, and server rendering needs @emotion/server to extract those styles.

Avoid putting css={button(...)} and className={button.className(...)} on the same element. The two caches do not share an insertion order, so overrides may not resolve the way you expect.

For server rendering this means two caches to extract, not one. Collecting only @emotion/react's cache ships markup whose className() and classNames rules are missing, so the first paint is unstyled until hydration.

Edit on GitHub

Last updated on

On this page