Skip to main content

Styling React Select with Tailwind

A customised, multi-purpose select component


In my current job, we use React ARIA for many components. It's a great library from Adobe, and its primary author Devon Govett (also the author of Parcel.js), has done an excellent job bundling headless functionality and accessibility in an easy-to-use set of React hooks.

However, as we needed to select multiple values from hundreds of options, we started to look for something more suitable. After a short research, we ended up with React Select, a solid package for selecting values. It has almost 26k GitHub stars as of writing this.

Since its version 5.7.0, React Select has supported styling with classes, which is a perfect fit for our Tailwind setup! I learned some things I'd like to share. But before that, let's start with the result from our Storybook:

React Select is a battle-tested, multipurpose solution for selecting options. Our use case benefits from its grouping and multi-select features.

Now to the code! Let's begin with a barebones example and build from there:

import Select, { Props } from "react-select";

const sampleOptions = [
  {
    label: "Finland",
    options: [
      {
        label: "Great Hotel",
        value: "Great Hotel",
      },
      // More options
    ],
  },
  {
    label: "Sweden",
    options: [{ label: "Stockholm", value: "Stockholm" }],
  },
];

export const ReactSelect = ({
  value,
  options = sampleOptions,
  ...props
}: Props) => {
  return (
    <Select
      value={value}
      options={options}
      isMulti
      closeMenuOnSelect={false}
      hideSelectedOptions={false}
      {...props}
    />
  );
};

And here's how it looks:

Well, that looks already reasonably good! That's the beauty of React Select: it ships with sane defaults, and with minor tweaks, most users are probably okay with it. We, however, wanted to customize the component a bit more. Fortunately, React Select has a prop called unstyled to remove the styles from the component. When we apply that, this is what we get:

React Select with the unstyled prop

Seems like we have work to do! Let's list the things we want to achieve:

  • Add the styles that match our design system with Tailwind
  • Add custom icons

Let's roll up our sleeves and get styling.

Link to this headingStyling with Tailwind

When using React Select with Tailwind, we can use the classNames prop. Note that this differs from the regular className prop, which adds a class to the component's top-level container. Here's what the syntax looks like:

<Select
  classNames={{
    control: () => "border border-gray-300 rounded-md",
  }}
/>

React Select has some 20 internal parts you can style, and control is one of them. It's responsible for wrapping the input container. Let's see how our styles look now:

React Select with the control component styled

I'd call that progress! Now, if you were wondering why we had the callback function there, it's for state-based styling. That said, let's improve our styles by adding a focus state:

<Select
  classNames={{
    control: ({ isFocused }) =>
      clsx(
        "border rounded-md",
        isFocused ? "border-primary-500" : "border-gray-300",
      ),
  }}
/>

I also introduced clsx, a lightweight library to render classes conditionally. This is the result:

Using state variables with React Select

You might have noticed that weird blue outline in the input area. That's a result of using the Tailwind forms plugin. React Select input styles target the containing div for the input element, while the Tailwind forms plugin directly styles the input element. So what to do? I'm aware of three options:

  1. Use a custom component for the input element (we'll dive into the custom components when we add our icons)
  2. Use Tailwind's arbitrary variants to target the input within the container.
  3. Use React Select styles prop to override the styles.

I didn't try the first option, as it seemed overkill. The second option works just fine, but the selector I'd need to use felt unclear ([&_input:focus]:ring-0). That's why I chose the third approach, which I discovered in a helpful StackOverflow answer. It looks like this:

<Select
  styles={{
    input: (base) => ({
      ...base,
      "input:focus": {
        boxShadow: "none",
      },
    }),
  }}
/>

To my knowledge, this was the go-to approach for styling before the classNames was introduced. What happens here is that we take the input base styles, spread them to the input, and, finally, override the input focus styles with our custom styles that remove the box shadow that causes the outline issue.

That's it for styling! I'll share the complete code at the end of this article, but before that, let's tackle the last part: using custom components.

Link to this headingCustom components

To achieve the desired custom look of our component, we want to add icons from our UI library. We can do this with the components prop, replacing a React Select component with our custom component. Here's an example of how that works:

import Select, {
  components,
  DropdownIndicatorProps,
} from "react-select";
import { ChevronDownIcon } from "@/assets/icons";

const DropdownIndicator = (props: DropdownIndicatorProps) => {
  return (
    <components.DropdownIndicator {...props}>
      <ChevronDownIcon />
    </components.DropdownIndicator>
  );
};

const ReactSelect = () => (
  <Select
    components={{ DropdownIndicator }}
  />
)

Here's the result:

React Select with a custom dropdown icon through the components prop

Yes, it worked!

Link to this headingThe final result

Congratulations, you've learned how to build a custom dropdown from scratch! 🎉

However, if you want to know what kind of styling will result in the styles I had in the first example, I've got a simplified example for you:

const DropdownIndicator = (props: DropdownIndicatorProps) => {
  return (
    <components.DropdownIndicator {...props}>
      <ChevronDownIcon />
    </components.DropdownIndicator>
  );
};

const ClearIndicator = (props: ClearIndicatorProps) => {
  return (
    <components.ClearIndicator {...props}>
      <CloseIcon />
    </components.ClearIndicator>
  );
};

const MultiValueRemove = (props: MultiValueRemoveProps) => {
  return (
    <components.MultiValueRemove {...props}>
      <CloseIcon />
    </components.MultiValueRemove>
  );
};

const controlStyles = {
  base: "border rounded-lg bg-white hover:cursor-pointer",
  focus: "border-primary-600 ring-1 ring-primary-500",
  nonFocus: "border-gray-300 hover:border-gray-400",
};
const placeholderStyles = "text-gray-500 pl-1 py-0.5";
const selectInputStyles = "pl-1 py-0.5";
const valueContainerStyles = "p-1 gap-1";
const singleValueStyles = "leading-7 ml-1";
const multiValueStyles =
  "bg-gray-100 rounded items-center py-0.5 pl-2 pr-1 gap-1.5";
const multiValueLabelStyles = "leading-6 py-0.5";
const multiValueRemoveStyles =
  "border border-gray-200 bg-white hover:bg-red-50 hover:text-red-800 text-gray-500 hover:border-red-300 rounded-md";
const indicatorsContainerStyles = "p-1 gap-1";
const clearIndicatorStyles =
  "text-gray-500 p-1 rounded-md hover:bg-red-50 hover:text-red-800";
const indicatorSeparatorStyles = "bg-gray-300";
const dropdownIndicatorStyles =
  "p-1 hover:bg-gray-100 text-gray-500 rounded-md hover:text-black";
const menuStyles = "p-1 mt-2 border border-gray-200 bg-white rounded-lg";
const groupHeadingStyles = "ml-3 mt-2 mb-1 text-gray-500 text-sm";
const optionStyles = {
  base: "hover:cursor-pointer px-3 py-2 rounded",
  focus: "bg-gray-100 active:bg-gray-200",
  selected: "after:content-['✔'] after:ml-2 after:text-green-500 text-gray-500",
};
const noOptionsMessageStyles =
  "text-gray-500 p-2 bg-gray-50 border border-dashed border-gray-200 rounded-sm";

const ReactSelect = (props) => (
  <Select
    isMulti
    closeMenuOnSelect={false}
    hideSelectedOptions={false}
    unstyled
    styles={{
      input: (base) => ({
        ...base,
        "input:focus": {
          boxShadow: "none",
        },
      }),
      // On mobile, the label will truncate automatically, so we want to
      // override that behaviour.
      multiValueLabel: (base) => ({
        ...base,
        whiteSpace: "normal",
        overflow: "visible",
      }),
      control: (base) => ({
        ...base,
        transition: "none",
      }),
    }}
    components={{ DropdownIndicator, ClearIndicator, MultiValueRemove }}
    classNames={{
      control: ({ isFocused }) =>
        clsx(
          isFocused ? controlStyles.focus : controlStyles.nonFocus,
          controlStyles.base,
        ),
      placeholder: () => placeholderStyles,
      input: () => selectInputStyles,
      valueContainer: () => valueContainerStyles,
      singleValue: () => singleValueStyles,
      multiValue: () => multiValueStyles,
      multiValueLabel: () => multiValueLabelStyles,
      multiValueRemove: () => multiValueRemoveStyles,
      indicatorsContainer: () => indicatorsContainerStyles,
      clearIndicator: () => clearIndicatorStyles,
      indicatorSeparator: () => indicatorSeparatorStyles,
      dropdownIndicator: () => dropdownIndicatorStyles,
      menu: () => menuStyles,
      groupHeading: () => groupHeadingStyles,
      option: ({ isFocused, isSelected }) =>
        clsx(
          isFocused && optionStyles.focus,
          isSelected && optionStyles.selected,
          optionStyles.base,
        ),
      noOptionsMessage: () => noOptionsMessageStyles,
    }}
    {...props}
  />
)

Enjoy styling your next select component!

Get in touch

I'm not currently looking for freelancer work, but if you want to have a chat, feel free to contact me.

Contact