1.1.1 • Published 4 months ago

tailwind-compose v1.1.1

Weekly downloads
-
License
MIT
Repository
github
Last release
4 months ago

Motivation

Like many, the first time I saw Tailwind CSS I thought, "Absolutely not". I maintained this view for a while before finally using it in earnest during a large build project. Now I get it. I can admit that Tailwind CSS is good. But I still have issues with the DX.

Even with the smallest of components, I find long lists of class names difficult to manage and quickly understand. After factoring in prop-based style variations, my code always becomes a string-interpolated mess, and I feel stressed. Maybe that's just how my brain works. Or doesn't work in this case.

Of course, there are already solutions to this out there, and tailwind-compose is just my take on it. Taking what I love about the well-established styled API, it aims to be a tiny drop-in tool for making your brain and your components feel okay.

Maybe it's the right solution for you too, or maybe it's not, and that's okay.

Contents

Example

import React from 'react';
import { compose } from 'tailwind-compose';

// Create a <Wrapper> React component that implements the following
// Tailwind CSS classes and renders as a <section> html element
const Wrapper = compose.section(() => [
  'bg-orange-100',
  'p-16',
]);

// Create a <Title> React component that implements the following
// Tailwind CSS classes and renders as a <h1> html element
const Title = compose.h1(() => [
  'text-center',
  'text-2xl',
  'text-rose-400',
  'font-serif',
  'font-bold',
  'my-4',
]);

// Use them like regular React components – except they're styled!
function Application() {
  return (
    <Wrapper>
      <Title>Hello World, this is my first composed component!</Title>
    </Wrapper>
  );
}

This is what you'll see in your browser:

Tailwind Compose example usage

Open in CodeSandbox

Conditional Styles / Component Variants

When creating your composed components, you may need to apply certain classes only when the component has a specific prop state. To achieve this, the composer callback accepts a function as its only argument (named here conditional) that takes both a class and a callback that must return a value that evaluates to either true or false based on the component's available props.

If the expression is true, the class is added; if it is false, it is not. Simple!

See the conditional API Reference for more details.

const Button = compose.button((conditional) => [
  'bg-red-500',
  'text-red-50',
  conditional('text-xl', ({ $isLarge }) => $isLarge),
]);

<Button />
// outputs <button class="bg-red-500 text-red-50">

<Button $isLarge />
// outputs <button class="bg-red-500 text-red-50 text-xl">

Alternatively, you can use the shorthand Tuple syntax and omit the call to the conditional function.

const Button = compose.button(() => [
  'bg-red-500',
  'text-red-50',
  ['text-xl', ({ $isLarge }) => $isLarge],
]);

The conditional function can also instead take an array of classes, which can be combined with other conditional definitions to build up a series of variant states that your component can take on.

const Button = compose.button((conditional) => [
  conditional(
    [
      'bg-red-500',
      'text-red-50',
    ],
    ({ $isSecondary }) => !$isSecondary
  ),
  conditional(
    [
      'bg-blue-500',
      'text-blue-50',
    ],
    ({ $isSecondary }) => $isSecondary
  ),
]);

<Button />
// outputs <button class="bg-red-500 text-red-50">

<Button $isSecondary />
// outputs <button class="bg-blue-500 text-blue-50">

Transient Props

To prevent props that are only meant to be consumed by your composed components from being passed to the underlying React node or rendered to the DOM element, you can prefix them with a dollar sign ($), turning them into a transient prop.

In the above example, $isSecondary is not rendered to the final DOM element.

Extending Components

As you start to build out your component library, you may wish to use a component but slightly change or build upon its styling. While you could use conditional classes and props to do this, depending on how many alterations you make, this could quickly become unmaintainable.

To simplify the process, you can just extend an existing composed component and supply it with a list of additional classes you wish to attach, resulting in a new component that is the best of both worlds.

const Button = compose.button(() => [
  'bg-red-500',
  'text-red-50',
]);
// outputs <button class="bg-red-500 text-red-50">

const BorderedButton = compose(Button, () => [
  'border',
  'border-red-700'
]);
// outputs <button class="bg-red-500 text-red-50 border border-red-700">

This also works for custom components, as long as they pass the className prop to a DOM element.

function CustomButton({ children, className }) {
  return <button className={className}>{children}</button>;
}

const ComposedCustomButton = composed(CustomButton, () => [
  'bg-red-500',
  'text-red-50',
]);

<ComposedCustomButton />
// outputs <button class="bg-red-500 text-red-50">

You can also pass extra classes to individual component instances at runtime via the className prop.

<Button className='border border-red-700' />
// outputs <button class="bg-red-500 text-red-50 border border-red-700">

Using as

All composed components are polymorphic, meaning you are able to alter the way they render after they have been created by using the as prop. This keeps all the styling that has been applied to a component but just switches out what is ultimately being rendered (be it a different HTML element or a different custom component).

const Button = compose.button(() => [ ... ]);

// This component will render as a `div` element instead of a `button`
<Button as='div' />

Using attrs

Occasionally, you may know ahead of time if your component will always use the same static prop values, such as an input element having a set type property. By using the attrs method, you can implicitly set any static prop values that should be passed down to every instance of your component.

Furthermore, you can also use the attrs method to attach default values for your dynamic transient props.

const TextField = compose.input.attrs({ $hasIcon: false, type: 'text' })(() => [ ... ]);

// This will render with the `type` attribute implicitly set
// from the original declaration
<TextField />

// You can also locally override any attributes that are defined above
<TextField $hasIcon={true} type='email' />
// For extended components, you can define attributes in the same way
const EmailField = styled(TextField, () => [ ... ]).attrs({ type: 'email' });

The attrs method also accepts a callback function that receives the props that the composed component will receive. The return value of this function will be merged into the resulting props.

const Button = compose.button.attrs((props) => ({ $size: props.$size }))(() => [ ... ]);

tailwind-compose Without Tailwind CSS

Despite the name, tailwind-compose can be used entirely without Tailwind CSS. At its core, tailwind-compose is just a string concatenation library meaning, you can use any utility-first CSS framework of choice or even just your own classes!

import styles from './styles.css';

const Button = compose.button(() => [
  styles.button,
  'our-custom-class',
  'whatever-you-want-to-concatenate'
]);

<Button />
// outputs <button class="button_1234567890 our-custom-class whatever-you-want-to-concatenate">

No React? No Problem!

While primarily designed to be used within React-based projects, tailwind-compose also exports its underlying string concatenation functionality as a standalone method. This means you can bring tailwind-compose into projects of all flavours and retain all of its great features.

import { classnames } from 'tailwind-compose';

const button = classnames((conditional) => [
  'bg-red-500',
  'text-red-50',
  conditional('text-xl', ({ isLarge }) => isLarge),
]);

button();
// outputs "bg-red-500 text-red-50"

button({ isLarge: true });
// outputs "bg-red-500 text-red-50 text-xl"

Execution Hooks

Inspired by Joe Bell's work on cva.

tailwind-compose allows you to perform additional custom logic to the concatenated class string by defining a global event handler for the onDone event. This can be particularly useful if you want to combine tailwind-compose with a utility function such as tailwind-merge.

Example with tailwind-merge

// tailwind-compose.config.ts

import { DefineConfigOptions, defineConfig } from 'tailwind-compose';
import { twMerge } from 'tailwind-merge';

const config: DefineConfigOptions = {
  hooks: {
    onDone: (className) => twMerge(className),
  },
};

export const { classname, compose } = defineConfig(config);
// components/button.ts

import { classnames, compose } from '../tailwind-compose.config';

const button = classnames(() => [
  'bg-orange-100',
  'bg-red-500',
]);

const Button = compose.button(() => [
  'bg-orange-100',
  'bg-red-500',
]);

button();
// outputs "bg-red-500"

<Button />
// outputs <button class="bg-red-500">

TypeScript Support

tailwind-compose has first-class type definition support, making it super easy to get started with your TypeScript project.

Any composed component that you create will infer the underlying base HTML element and attach the appropriate attribute types to it. This means your IDE's Intellisense can autocomplete all available props against your component, ensuring you or your peers are never in the dark.

const MyInput = compose.input(() => [ ... ]);

// Returns the following TypeScript type:
// ComposedComponent<
//    React.ClassAttributes<HTMLInputElement> & React.InputElementAttributes<HTMLInputElement> &
//    Props
// >

If you want to ensure that any custom props are type-safe, you can pass your Type Assertions in the following way:

interface ButtonProps {
  $size: 'small' | 'large';
}

const Button = compose.button<ButtonProps>(() => [ ... ]);
// OR
const Button = compose<'button', ButtonProps>('button', () => [ ... ]);

// Oops! This will throw a type error because the `size` prop has not been defined
<Button />

// Life in beautiful type-safe harmony
<Button $size='small' />

Additionally, extended components have their types transferred to your newly created composed variant.

interface ExampleComponentProps {
  className?: string;
  icon: 'tick' | 'cross';
  large: boolean;
}

function ExampleComponent({ className, icon, large }: ExampleProps) {
  return (
    <div className={className}>
      <Icon icon={icon} large={large} />
    </div>
  );
}

const ComposedExampleComponent = composed(ExampleComponent, () => [ ... ]);

// Returns the following TypeScript type:
// ComposedComponent<ExampleComponentProps>

Tailwind CSS Intellisense

You can enable autocompletion for tailwind-compose using the following steps below:

Visual Studio Code

  1. Install the "Tailwind CSS IntelliSense" Visual Studio Code extension.
  2. Add the following to your settings.json:
{
  "tailwindCSS.experimental.classRegex": [
    [
      "(?:classnames|compose).+\\[((?:.|\n)*?)\\]\\)",
      "[\"'`](.*?)[\"'`]"
    ]
  ],
}

Neovim

  1. Install the Tailwind CSS Language Server extension.
  2. Add the following configuration:
lspconfig.tailwindcss.setup({
  settings = {
    tailwindCSS = {
      experimental = {
        classRegex = {
          {
            "(?:classnames|compose).+\\[((?:.|\n)*?)\\]\\)",
            "[\"'`](.*?)[\"'`]"
          },
        },
      },
    },
  },
})

WebStorm

  1. Check the version. Available for WebStorm 2023.1 and later.
  2. Open the settings and go to Languages and Frameworks | Style Sheets | Tailwind CSS.
  3. Add the following to your Tailwind CSS configuration:
{
  "experimental": {
    "classRegex": [
      "(?:classnames|compose).+\\[((?:.|\n)*?)\\]\\)",
      "[\"'`](.*?)[\"'`]"
    ]
  }
}

API Reference

compose

/**
 * Create a composed component from a variable target
 * @param   {string | ComposedComponent | React.ComponentType} element
 * @param   {composer} composer
 * @returns {ComposedComponent}
 */
const Component = compose(element, composer);
/**
 * Create a composed component from a set element
 * @param   {composer} composer
 * @returns {ComposedComponent}
 */
const Component = compose.div(composer);

compose.attrs

/**
 * Create a composed component from a variable target w/ defined static attributes
 * @param   {object | attrsFactory} attributes
 * @param   {string | ComposedComponent | React.ComponentType} element
 * @param   {composer} composer
 * @returns {ComposedComponent}
 */
const Component = compose(element, composer).attrs(attributes);
/**
 * Create a composed component from a set element w/ defined static attributes
 * @param   {object | attrsFactory} attributes
 * @param   {composer} composer
 * @returns {ComposedComponent}
 */
const Component = compose.div.attrs(attributes)(composer);

compose.toClass

const Component = compose(element, composer);
// OR
const Component = compose.div(composer);

/**
 * Create a composed classlist from an existing composed component
 * @param   {object=} props
 * @returns {string}
 */
const classlist = Component.toClass(props);

classnames

/**
 * Create a composed classlist
 * @param   {composer} composer
 * @returns {string}
 */
const Component = classnames(composer);

defineConfig

/**
 * Generate a set of `tailwind-compose` functions with custom configurations
 * @param   {object} config
 * @param   {object} config.hooks.onDone
 * @returns {object}
 */
const { classnames, compose } = defineConfig(config);

composer

/**
 * Callback to create a composed classlist
 * @callback composer
 * @param    {conditional=} conditional
 * @returns  {string[]}
 */
const composer = (conditional) => classes;

attrsFactory

/**
 * Callback to create an attributes object
 * @callback attrsFactory
 * @param    {object=} props
 * @returns  {object}
 */
const attrsFactory = (props) => props;

conditional

/**
 * Syntactic sugar to compose a conditional classlist tuple
 * @param    {string | string[]} target
 * @param    {condition} condition
 * @returns  {[string | string[], condition]}
 */
const conditional = conditional(target, condition);

condition

/**
 * Callback to evaluate whether a given condition is truthy
 * @callback condition
 * @param    {object=} props
 * @returns  {boolean}
 */
const condition = (props) => boolean;

Browser Support

tailwind-compose should work in all major modern browsers out-of-the-box (Chrome, Edge, Firefox, Safari).

To add support for browsers IE 11 and older, ensure you add polyfills for the following features:

Badge

Sing loud and proud! Let the world know that you're using tailwind-compose.

styled with: tailwind-compose

[![styled with: tailwind-compose](https://img.shields.io/badge/styled%20with-%F0%9F%8F%97%EF%B8%8F%20tailwind--compose-blue?style=flat-square)](https://github.com/eels/tailwind-compose)

Contributing

Thanks for taking the time to contribute! Before you get started, please take a moment to read through our contributing guide. The two focus areas for tailwind-compose right now are increasing performance and fixing potential bugs.

However, all issues and PRs are welcome!

Alternative Packages

This package was primarily designed to meet my own personal goals for working with Tailwind CSS and may not be the right solution for you and your project. If that is the case, here's a short list of alternative packages to consider that may better suit your needs if tailwind-compose is not for you.

License

MIT - see the LICENSE.md file for details.

Acknowledgments

  • Originally inspired by parts of the styled-components API
  • With additional optimisation inspiration from the 1KB alternative, goober
1.1.1

4 months ago

1.1.0

8 months ago

1.0.0

10 months ago

0.8.0

10 months ago

0.7.1

2 years ago

0.7.0

2 years ago

0.6.0

2 years ago

0.5.0

2 years ago

0.4.1

2 years ago

0.4.0

2 years ago

0.5.2

2 years ago

0.4.3

2 years ago

0.5.1

2 years ago

0.4.2

2 years ago

0.3.0

2 years ago

0.2.0

2 years ago

0.1.0

2 years ago