Small and reusable Tailwind components with React

lokman musliu
Lokman Musliu

August 4, 2022 · 6 min read · 5,067 views

React and TailwindCSS Blogpost Image

As you might have noticed already, we are big fans of TailwindCSS. We almost exclusively use it on production sites. That's why we dedicate another post to further contribute for the wonderful Tailwind community. Let's see how we can create small and reusable Tailwind components with React.

Tailwind CSS is renowned for its utility-first approach, offering a robust framework that enables developers to swiftly construct bespoke user interfaces. When integrated with React, this dynamic duo empowers developers to craft powerful and reusable components, streamlining the development process and ensuring consistency across the user interface.

Our design team embraces a component-driven design philosophy, utilizing tools like Figma to assemble the visual elements of our projects. To ensure a seamless transition from design to development, we engage in early collaboration with our design experts. This proactive communication allows us to identify and establish necessary component variants upfront, facilitating a smooth and efficient development workflow that aligns perfectly with the predetermined design specifications.

Installation

For this demo we will use Vite to scaffold a new React application. We start by running the following command in our terminal:

npm create vite@latest --template react
 
# npm 7+, extra double-dash is needed:
npm create vite@latest my-vue-app -- --template react

We continue with the installation process by following the framework guide on TailwindCSS documentation:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

This will scaffold two new files: tailwind.config.cjs and postcss.config.cjs

Our Tailwind config looks like this:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};

Finally, to load the CSS, we need to add the following lines to our index.css file:

@tailwind base;
@tailwind components;
@tailwind utilities;

When working with Tailwind components we highly recommend the @tailwindcss/forms as it provides a basic reset for form styles and makes styling a bit easier.

You can install it with:

npm install -D @tailwindcss/forms

And include it in the plugin array inside the Tailwind config.

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [
    require("@tailwindcss/forms"),
  ],
};

We are now ready to work on our components.

Directory Structure

For better file structure we recommend creating a components directory and splitting the components based on functionality ( form, ui, etc ).

For example:

  • src/components/form/Button.jsx

  • src/components/form/BaseInput.jsx

  • src/components/ui/Text.jsx

  • src/components/ui/Card.jsx

We cover creating a reusable button in an earlier tutorial.

Note that for all examples we use the clsx package for constructing className strings conditionally. Make sure you have it installed if you follow this tutorial.

Another note: We are aware that Typescript is better for these examples, but to keep this tutorial beginner friendly we will focus only on React. We might add Typescript examples in the future. Thanks!

Reusable Input Components

In form-heavy applications or sites, you almost always need reusable input components. They can be very simple or rather complex depending on the initial design.

When working with Tailwind and React we like to have a BaseInput component that can be easily extended and supports most of the common features such as:

  • Support for multiple states, for example, error state, valid state ( passes validation ), disabled, etc.

  • Support for multiple styling options, sometimes we might have inputs with different borders (rounded, square) depending on the use case.

  • Option to add a label with the correct id

We like to start with our Label component which we call BaseLabel, and it's the smallest component that only takes an id, className, and children.

Why a separate component? Well, you might use the same label for other form elements such as select, radio, checkboxes, etc.

This is our finished BaseLabel component:

import { clsx } from "clsx"

const BaseLabel = ({ id, className, children }) => {
  return (
    <label
      className={clsx('block mb-2 text-base font-medium text-gray-700', className)}
      htmlFor={id}
    >
      {children}
    </label>
  )
}

export default BaseLabel;

Now that we have our BaseLabel component we can conditionally include it in our BaseInput component if the user adds a label.

For our BaseInput we need the following props: id, label, type, error, required, disabled, valid, className, errorText, and rounded.

To make everything easy to handle, we always create a styles object with all the required classes. Thus, we can conditionally include them based on prop value.

In our example, we use React v18 so we can benefit from the new useId hook to automatically generate ids for inputs. If you are using an older React version you can always use a package like ReachUI Auto id.

Here is our finished BaseInput component:

import { useId } from 'react'
import { clsx } from 'clsx'
import BaseLabel from './BaseLabel'

const BaseInput = (props) => {
  
  const {
    label,
    type = 'text',
    error = false,
    required = false,
    disabled = false,
    valid = false,
    className = '',
    errorText = '',
    rounded = 'lg',
    ...rest
  } = props;

  const id = useId();

  const styles = {
    base: 'border-transparent flex-1 appearance-none border w-full py-2 px-4 bg-white text-gray-700  shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent',
    state: {
      normal: 'placeholder-gray-400 border-gray-300 focus:ring-purple-600',
      error: 'border-red-600 focus:ring-red-600',
      valid: 'border-green-600 focus:ring-green-600',
      disabled: 'cursor-not-allowed bg-gray-100 shadow-inner text-gray-400'
    },
    rounded: {
      none: null,
      sm: 'rounded-sm',
      md: 'rounded-md',
      lg: 'rounded-lg',
    }
  }

  return (
    <div className={clsx('relative', className)}>
      {label && (
        <BaseLabel id={id}>
          {label} {required && '*'}
        </BaseLabel>
      )}
      <input
        id={id}
        type={type}
        className={clsx([styles.base,
        rounded && styles.rounded[rounded],
        error ? styles.state.error : styles.state.normal,
        valid ? styles.state.valid : styles.state.normal,
        disabled && styles.state.disabled
        ])}
        disabled={disabled}
        required={required}
        {...rest}
      />
      {error && <p className="mt-2 text-sm text-red-600">{errorText}</p>}
    </div>
  )
}

export default BaseInput;

This is what the component looks like with some of the possible variations:

Input Fields and States Image

And below you have the example code used in the image above:

import BaseInput from './components/form/BaseInput'

function App() {

  return (
    <div className="bg-white min-h-screen">
      <div className="max-w-4xl mx-auto py-20">
        <div className="grid grid-cols-4 gap-8">
          <BaseInput label="Your name" required />
          <BaseInput
            label="Your name"
            placeholder="Error State"
            errorText="Field is required"
            error
            required
          />
          <BaseInput label="Your name" placeholder="Success State" valid />
          <BaseInput label="Your name" placeholder="Disabled" disabled />

          <BaseInput label="Your name" placeholder="Square" rounded="none" />
          <BaseInput label="Your name" placeholder="Small" rounded="sm" />
          <BaseInput label="Your name" placeholder="Medium" rounded="md" />
          <BaseInput label="Your name" placeholder="Large is default" />
        </div>
      </div>
    </div>
  )
}

export default App

With the BaseInput component, you can easily add more features such as prepend icons, append, different sizes based on padding, etc.

Reusable Text Component

When working with content-heavy marketing sites using Next.js or React, a Text component is something that we constantly use.

We can have different sizes, variants, or even different tags ( for ex: p, span, h2, h3, etc )

Let's construct the component:

import { clsx } from 'clsx';

const Text = ({ size = 'base', variant = 'gray', as = 'p', className, children }) => {

  const Tag = as;

  const sizes = {
    sm: 'font-medium text-sm leading-normal',
    base: 'font-medium text-base leading-normal',
    lg: 'font-semibold text-lg md:text-2xl leading-relaxed'
  }

  const variants = {
    gray: 'text-gray-600',
    white: 'text-white',
    dark: 'text-gray-900'
  }

  return (
    <Tag className={clsx(sizes[size], variants[variant], className)}>
      {children}
    </Tag>
  )
}

export default Text;

A simple example may be the following:

import Text from "./components/ui/Text"

function App() {

  return (
    <div className="bg-white min-h-screen">
      <div className="container mx-auto py-20">
        <div className="space-y-3">
          <Text size="sm">This is small text</Text>
          <Text>This is normal text</Text>
          <Text size="lg">This is large text</Text>
          <Text size="lg" variant="dark">This is large and dark text</Text>
        </div>
      </div>
    </div>
  )
}

export default App

Reusable Card Component

Cards are one of the most used UI components in applications ( at least in our experience ). We always try to make them as reusable as possible.

Here is an example Card component that we split into 4 parts: The Card wrapper, Card Header (with or without Title), Card Body, and Card Footer.

import { clsx } from 'clsx';

const Card = ({ className, children }) => {
  return (
    <div className={clsx('overflow-hidden bg-white rounded-lg shadow', className)}>
      {children}
    </div>
  )
}

const Header = ({ className, children }) => {
  return (
    <div className={clsx('px-4 py-5 bg-white border-b border-gray-200 sm:px-6', className)}>
      {children}
    </div>
  )
}

const HeaderTitle = ({ className, as = 'h3', children }) => {
  const Tag = as;

  return (
    <Tag className={clsx('text-lg font-medium leading-6 text-gray-900', className)}>
      {children}
    </Tag>
  )
}

const Body = ({ className, children }) => {
  return (
    <div className={clsx('px-4 py-5 sm:p-6', className)}>
      {children}
    </div>
  )
}

const Footer = ({ className, children }) => {

  return (
    <div className={clsx('bg-white border-t border-gray-200', className)}>
      {children}
    </div>
  )
}

Card.Header = Header;
Card.Header.Title = HeaderTitle;
Card.Body = Body;
Card.Footer = Footer;

export default Card;

We can have multiple variations of our Card component. Let's see an example:

import Card from "./components/ui/Card"

function App() {

  return (
    <div className="bg-gray-100 min-h-screen">
      <div className="container mx-auto py-20">
        <div className="grid grid-cols-4 gap-4">
          <Card>
            <Card.Header>
              <Card.Header.Title>Card Title</Card.Header.Title>
            </Card.Header>
            <Card.Body>
              This is the body
            </Card.Body>
            <Card.Footer>
              This is the footer
            </Card.Footer>
          </Card>
          
          <Card>
            <Card.Header>
              <Card.Header.Title>Card Title</Card.Header.Title>
            </Card.Header>
            <Card.Body>
              Card with body and header/title
            </Card.Body>
          </Card>
          
          <Card>
            <Card.Body>
              Card with body only
            </Card.Body>
          </Card>
          
          <Card>
            Card without body
          </Card>
        </div>
      </div>
    </div>
  )
}

export default App

The image below shows some the possible variations with this Card component:

Cards Image Explanation

With the current setup, we have the option to add className to every Card item. This allows us to further customize if needed, so we can keep everything small and composable.

You can also extend the component if you have multiple variations. For example, let's add the option to include different footer variants and sizes to the Card Footer.

const Footer = ({ variant = 'white', size = 'md', className, children }) => {

  const styles = {
    sizes: {
      sm: 'px-1 py-5 sm:px-2',
      md: 'px-4 py-5 sm:px-6',
      lg: 'px-4 py-8 sm:px-8',
    },
    variants: {
      white: 'bg-white border-t border-gray-200',
      dark: 'bg-gray-900 border-t border-gray-700',
      gray: 'bg-gray-50',
    }
  }


  return (
    <div className={clsx([
      styles.variants[variant],
      styles.sizes[size],
      className
    ])}>
      {children}
    </div>
  )
}

To keep things simple we will only display the modified code for the Card Footer. With small changes, we may have multiple footer variants.

In your code you can use them like so:

import Card from "./components/Card"

function App() {

  return (
    <div className="bg-gray-100 min-h-screen">
      <div className="container mx-auto py-20">
        <div className="grid grid-cols-4 gap-4">
          <Card>
            <Card.Body>
              Card with gray footer.
            </Card.Body>

            <Card.Footer variant="gray">
              This is the gray footer
            </Card.Footer>
          </Card>
          <Card>
            <Card.Body>
              Card with dark footer
            </Card.Body>

            <Card.Footer size="sm" variant="dark">
              <p className="text-white">
                This is the small dark footer
              </p>
            </Card.Footer>
          </Card>
        </div>
      </div>
    </div>
  )
}

export default App

Conclusion

With the card component, we wrap up our series of small and reusable Tailwind components. Throughout this series, we've delved into the intricacies of crafting the Input, Label, Text, and Card components. These foundational elements are integral to our process when we embark on creating marketing websites, applications, or any digital platform.

To get a hands-on experience with everything we've covered, be sure to visit our demo repository, which houses all the code samples from this tutorial. Dive into our demo repo and explore the possibilities these components unlock. Happy coding!


Bring Your Ideas to Life 🚀

If you need help with a Laravel project let's get in touch.

Lucky Media is proud to be recognized as a Top Next.js Development Agency

lokman musliu
Lokman Musliu

Founder and CEO of Lucky Media

Technologies:

React
TailwindCSS
Heading Pattern

Related Posts

Stay up to date

Be updated with all news, products and tips we share!