Skip to content

Instantly share code, notes, and snippets.

@zaydek
Last active May 13, 2020 08:58
Show Gist options
  • Save zaydek/3cb316dd8d205630f7942eb6769e2f49 to your computer and use it in GitHub Desktop.
Save zaydek/3cb316dd8d205630f7942eb6769e2f49 to your computer and use it in GitHub Desktop.

What if Tailwind classes were parsed?

This spec explores what a structured Tailwind CSS resolver could work based on informed experience.

Preface

A couple months ago, I authored something I called stylex.js — the idea was to provide React a parser/resolver for inline styles using a declarative API. Eventually, I stopped working on this project for favor of Tailwind CSS. Despite enjoying Tailwind CSS, I have incredibly fond memories of working in stylex.js because it afforded me the ability to be declarative and conditional with design.

Here is in essence how stylex.js worked. Given the following code:

// Renders a gray box.
const Box = stylex.Styleable(props => (
  <div style={stylex.parse("wh:160 b:gray-200 br:8")} {...props} />
))

// Renders a red box. Note that RedBox reuses Box.
const RedBox = stylex.Styleable(props => (
  <Box style={stylex.parse("b:red")} {...props} />
))

// Renders a box, red box, and a rounded red box.
const App = props => (
  <div style={stylex.parse("flex -r :center h:max")}>
    <Box />
    <div style={stylex.parse("w:16")} />
    <RedBox />
    <div style={stylex.parse("w:16")} />
		// Note that this s a rounded red box. The only change is that
		// stylex.parse("br:max"), enabling a red box to become a rounded box
    <RedBox style={stylex.parse("br:max")} />
  </div>
)

Would render this UI:

The first working demo of stylex.js

This is a simple example, but the system can be adapted for any matter of increasingly complex components. For example, say you have a basic <TextInput> component that knows nothing about its ultimate appearance. You can then layer components on top of <StyledTextInput> so that you can incrementally adopt function and appearance, with a clear separation of concerns per component.

To reiterate this point, you can work up the abstraction chain like this:

  • Author a <TextInput> component
  • Author a <StyledTextInput> parent component
  • Author a <LibraryTextInput> parent, parent component

How stylex.js could be adapted for Tailwind CSS

For the sake of this spec, let’s call this concept Tailwind Components, or tc for short. In Tailwind Components, there are essentially three moving parts:

  • The parser
  • The resolver
  • The API

Parser

The parser needs parse class strings and and map Tailwind prefixes to resolver functions. Note that the value component of a class, such as -6 for px-6 is not important. The only thing the parser cares about is what prefixes do I care about? and what are the types / number ranges for a given prefix? Value-specific logic is forwarded to the resolver.

What this means in practice is teaching the parser that m- maps to margin, mx-, maps to marginLeft and marginRight, and so on. This needs to be done for all of the canonical classes Tailwind CSS supports. For unsupported classes, classes could be consumed at run-time (for development) and compile-time (for production) to do on-the-fly parser-compilation. However, this may very well break down if classes do not have 1:1 mapping with properties, so simply mapping to canonical classes solves for the 95% use-case. There are other ways for solving for the 5%, but that comes later.

Resolver

The resolver resolve pre-parsed class strings to a data structure, where classes can be extended, not simply concatenated. Extended means classes work together to compose a data structure — this is different than string concatenation. The logic here is essentially equivalent to composing a style object.

Take for example the following:

// -> { width: 600px; }
const style1 = {
	width: 600,
}
// -> { width: 600px; height: 400px; }
const style2 = {
	...style1,
	height: 400,
}
// -> { width: 640px; height: 400px; }
const style3 = {
	...style2,
	width: 640,
}

You can see how CSS properties can be easily replaced or added by using a map. This is in effect how the resolver works — parsed classes converge to a single data structure, e.g. a map. With that data structure, theoretically you can easily go from data structure to back to a class string or inline styles.

Take for example the following pseudo code: instead of mx-6 + mr-3 === mr-6 mr-3 (which is confusing, because in CSS mr-3 takes precedence because it was defined before in the original CSS), you get ml-6 mr-3. And whether that be returned as a class string or as an inline style, e.g. { marginLeft: 6rem, marginRight: 3rem }, both are logically equivalent in the eyes of the resolver.

API

Finally, the last step is the actual API that the end-developer consumes in order to use Tailwind Components.

Here are all of the general APIs that could be adapted:

  • <tc.div className="...">
  • <div {...tc("...")}>
  • <div className={tc("...")}>
  • <div style={tc("...", { inline: true })}>

It’s important to understand that these are conceptually equivalent APIs. The other aspect about the API design which is important to appreciate is that conditional logic and expressions could be valid arguments. For example, tc("<base classes>", condition && "<conditional classes>"). This means expressions can be easily extracted out of the tc invocation and stored as variables in advance.

For example:

const Component = isActive => (
	<div className={tc("a b c"), isActive && "d"}>
		{/* ... */}
	</div>
)

This more clearly demonstrates (and documents) how components can be easily adapted for interactive use-cases and so on.

Finally, there is one more API to cover, and that is how and when a component is allowed to be composed using Tailwind Components. Most of the time, you want class composition, but sometimes you don’t. And in order to opt-in to class composition, you need to somehow communicate that to Tailwind Components.

In stylex.js, the pattern I used to enable class composition for a given component was a higher-order wrapper component: Styleable.

// `Styleable` is a higher-order component that extends a
// component’s styles.
const _Styleable = render => ({ style: extendedStyles, ...extendedProps }) => {
	const element = render(extendedProps)
	invariant(
		nullable.isNonNullableObject(element) && element.$$typeof === Symbol.for("react.element"),
		"stylex: `Styleable` expected a JSX component.",
	)
	const { props: { style, ...props } } = element
	const newRender = React.cloneElement(
		element,
		{
			style: {
				...style,          // Assigned styles.
				...extendedStyles, // Extended styles.
			},
			...props,
		},
	)
	return newRender
}

By wrapping a component, the same as you would React.memo, a component then knows how and when to opt-in to class composition. Furthermore, if you want to explicitly document that a component cannot be composed, you could use another higher-order component, Unstyleable.

Such an example of why you might use Unstyleable is to document to yourself or others that a given component is ‘final’. This is particularly useful for top-level components, such as <Nav>, where no further composition is desirable.

In working with stylex.js, I discovered that even Unstyleable components still need to compose margins, such as margin-top, margin-left, etc., because margins are context-specific. This is the only exception.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment