Edit This Page

Why make this?

  • I needed a better way to provide styling hooks for complex components.
  • I love CSS's pseudo-selectors (i.e. :hover, :disabled).
  • React components are much more than DOM primitives, so why do we limit ourselves to CSS's default set of pseudo-selectors?

Why use this?

Imagine you're building a Combobox that can asynchronously fetch its options.

It has many specific internal states (such as busy, expanded, etc.), each of which will need a different look and feel.

Here's how a thoughtful author of Combobox might provide style hooks to users:

const expanded = this.state.expanded;
const busy = this.state.options === undefined;
const error = this.state.options === null;

<div className={classnames(
  classes.base,
  this.props.className,

  expanded && classes.expanded,
  expanded && this.props.expandedClassName,

  busy && classes.busy,
  busy && this.props.busyClassName,

  error && classes.error,
  error && this.props.errorClassName,
)} />

This is a good start, but some people might prefer to use inline styles, so let's add those as well:

<div
  className={classnames(...)}

  style={mergeStyles(
    this.props.style,    
    expanded && this.props.expandedStyle,
    busy && this.props.busyStyle,
    error && this.props.errorStyle,
  )}
/>

Whew! It's not pretty for us, but now the users of Combobox have a convenient, declarative way to control how it looks, using inline styles or CSS!

<Combobox
  className={'MyCombobox'}
  expandedClassName={'MyCombobox_expanded'}
  busyStyle={{
    opacity: 0.5,
    cursor: 'no-drag',
  }}
  errorStyle={{
    color: 'red',
  }}
/>

This is the current "state of the art", but few third-party component authors provide flexibility at this level from within React.

Rather, it's much easier to just provide users with a few less files or encourage them to directly write their own CSS selectors using a set of proprietary -- if conventional -- CSS classes.

A novel, yet familiar approach

Seamstress provides a :pseudo-selector-like syntax to apply styles conditionally based on the internal state of the component:

<Combobox styles={{
  ':expanded': {
    border: '1px solid black',
  },

  ':busy': {
    opacity: 0.5,
    cursor: 'no-drag',
  },

  ':error': {
    color: 'red',
  },
}} />

You are not limited to using inline styles; CSS classes can be specified as strings:

import classes from './MyCombobox.css';

<Combobox styles={{
  ':base': classes.base,
  ':expanded': classes.expanded,
}} />

How is this implemented?

Inside our Combobox implementation, we move all of the styling logic outside render(), and access the applicable style props with computedStyles.

Rather than writing lots of boilerplate logic, we just declare what the "style state" of our component is, and Seamstress takes care of the rest:

@Seamstress.createDecorator({
  styles: {
    ':base': classes.base,
    ':expanded': classes.expanded,
    ':busy': classes.busy,
    ':error': classes.error,
  },
  getStyleState: function ({props, context, state}) {
    return {
      /* :base is a special style state that is unconditionally true */
      expanded: state.expanded,
      busy: state.options === undefined,
      error: state.options === null,
    };
  },
})
class Combobox extends React.Component {
  render () {
    const computedStyles = this.getComputedStyles();
    <div {...computedStyles.root} />
  }
}

Under the hood, Seamstress uses the result of getStyleState() to determine which :pseudo-selector styles should be applied. Adding a new state state is as simple as adding a field to this function's return value.

The Pit of Success

Suppose someone is using your Combobox for the first time, and they misspell one of the pseudo-selectors:

<Combobox style={{
  ':expand': {
    opacity: 1,
  }
}} />

Component authors can take advantage of a special config option, styleStateTypes, which declares the values that getStyleState() returns.

This pattern should look familiar to those who've used propTypes before:

@Seamstress.createDecorator({
  styleStateTypes: {
    expanded: React.PropTypes.bool.isRequired,
    busy: React.PropTypes.bool.isRequired,
    error: React.PropTypes.bool.isRequired,
  },
  // ...
})
class Combobox extends React.Component {
  // ...
}

Now, specifying a state that doesn't correspond to an entry in styleStateTypes leads to this friendly message:

Warning: Failed propType: `:expand` is not a valid style-state of `Combobox`.

Valid style-states are: [`:expanded`, `:busy`, `:error`].

Check the render method of `MyApp`.

Furthermore, component authors also see warnings if their getStyleState() returns an incorrect or missing type.

Sub-components

In addition to style-states (which correspond to :pseudo-selectors), component authors can specify sub components, which are analogous to the ::pseudo-elements of CSS.

Suppose our Combobox has a little arrow indicator similar to a standard DOM select.

Naturally, users will want to change how this looks as well. Here's how that would look using Seamstress:

<Combobox style={{
  // Let's say you don't want to see the indicator:
  '::indicator': {
    display: 'none',
  },
}} />

Any ::sub-components specified on the styles prop are automatically added to computedStyles:

@Seamstress.createDecorator({
  // ...
})
class Combobox extends React.Component {
  render () {
    const computedStyles = this.getComputedStyles();
    <div {...computedStyles.root}>
      <div {...computedStyles.indicator} />
    </div>
  }
}

Users can even combine :pseudo-selectors with ::sub-components:

<Combobox style={{
  ':expanded::indicator': {
    transform: 'rotate(90deg)',
  },
}} />

Now we're talking!

What about Theming/Skinning?

Passing custom styles to every component is not ideal when you use the same style every time. This is the common use-case for "skinning" a third-party component to be used with your project.

What we really want is a version of Combobox that uses our styles by default.

import Combobox from 'fictional-third-party-combobox';

const MY_STYLES = {
  ':base': {
    fontSize: '24px',
  },
  ':expanded': {
    opacity: 0.5,
  },
  '::indicator': {
    display: 'none',
  }
};

const MyCombobox = Combobox.extendStyles(MY_STYLES);