All Black Lives Matter

Beautiful web forms on the Jamstack

With Tailwind CSS

Dec. 26th, 2020

@tailwindcss/forms

Introduction

Form elements are not always the easiest to style. @tailwindcss/forms convieniently provides a basic reset for form styles that makes form elements easy to override with Tailwind CSS utility classes. @tailwindcss/forms is designed for Tailwind CSS v2.0 and replaces tailwindcss-custom-forms which was designed to work with Tailwind CSS v1.0.

Getting Started

Starting with @tailwindcss/forms is fairly routine. Install the plugin:

zsh
# Using npm
npm install @tailwindcss/forms

# Using Yarn
yarn add @tailwindcss/forms

Then add the plugin to your tailwind.config.js file:

JS
module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/forms'),
    // ...
  ],
}

Usage

I found that the easiest way to get started is to look at the provided live demo examples. For this post, we will recreate the contact form found on the homepage of jillian.dev. We will start by using dev tools to copy the underline-style form example and modify the fields as needed (also adjusting the HTML to be valid JSX/TSX):

TSX
<form name="contact">
  <div className="grid grid-cols-1 gap-6">
    <label className="block">
      <span className="text-gray-700">Name</span>
      <input type="text" className="mt-0 block w-full px-0.5 border-0 border-b-2 border-gray-200 focus:ring-0 focus:border-black" placeholder="">
    </label>
    <label className="block">
      <span className="text-gray-700">Email</span>
      <input type="email" className="mt-0 block w-full px-0.5 border-0 border-b-2 border-gray-200 focus:ring-0 focus:border-black" placeholder="">
    </label>
    <label className="block">
      <span className="text-gray-700">Message</span>
      <textarea className="mt-0 block w-full px-0.5 border-0 border-b-2 border-gray-200 focus:ring-0 focus:border-black" rows={4}></textarea>
    </label>
  </div>
</div>

Which should display like this:

With a nice reset in place, now we can just add or remove any of the Tailwind CSS utility classes that we want in order to style the form to our liking:

TSX
<form name="contact">
  <div className="grid grid-cols-1 gap-4 mt-8">
    <label className="block">
      <span className="text-sm tracking-wider text-gray-600">Name</span>
      <div className="relative">
        <input
          className="block w-full pl-3 pr-10 mt-1 text-lg text-gray-600 bg-gray-200 border-0 border-l-4 border-purple-300 rounded-lg shadow-md focus:ring-0 focus:border-fuchsia-500"
          aria-required="true"
          placeholder=""
          type="text"
          name="name"
        />
      </div>
    </label>
    <label className="block">
      <span className="text-sm tracking-wider text-gray-600">Email</span>
      <div className="relative">
        <input
          className="block w-full pl-3 pr-10 mt-1 text-lg text-gray-600 bg-gray-200 border-0 border-l-4 border-purple-300 rounded-lg shadow-md focus:ring-0 focus:border-fuchsia-500"
          aria-required="true"
          type="email"
          name="email"
        />
      </div>
    </label>
    <label className="block">
      <span className="text-sm tracking-wider text-gray-600">Message</span>
      <div className="relative">
        <textarea
          className="block w-full pl-3 pr-10 mt-1 text-lg text-gray-600 bg-gray-200 border-0 border-l-4 border-purple-300 rounded-lg shadow-md focus:ring-0 focus:border-fuchsia-500"
          aria-required="true"
          rows={4}
          name="message"
        />
      </div>
    </label>
    <button
      className="outline-none focus:outline-none hover:outline-none active:outline-none overflow-hidden text-center tracking-wide transition block active:shadow-sm duration-200 ring-1 ring-offset-1 focus:ring-1 focus:ring-offset-1 hover:ring-1 hover:ring-offset-1 active:ring-1 active:ring-offset-1 px-6 py-2 rounded-full shadow-lg bg-purple-300 text-purple-900 font-medium ring-offset-purple-300 ring-purple-200 ring-opacity-75 focus:ring-offset-purple-700 focus:ring-purple-200 focus:ring-opacity-75 hover:ring-offset-purple-700 hover:ring-purple-200 hover:ring-opacity-75 active:ring-offset-purple-700 active:ring-purple-200 active:ring-opacity-75 transform hover:-translate-y-0.5 focus:-translate-y-0.5 active:translate-y-0.5 ease-in-out"
      title="Submit"
      type="submit"
    >
      Submit
    </button>
  </div>
</form>

Note that on lines 6, 18, and 29 I preemptively changed the div's to display: relative; since we will be using those elements to position icons for visual feedback during form field validation in the next post. Of course I also added a button since that is an important part of any form 😊. And there we have it, a gorgeously styled and accessible contact form:

Lets refactor this a bit to deal with our unwieldy Tailwind CSS class names so that our ContactForm component should look as follows:

TSX
import React, { ReactNode } from 'react'

import { formatClassList } from '../../utils/utils'

import Button from '../../components/button'


const FIELD_BASE: string = `
  bg-gray-200
  block
  border-0
  border-l-4
  focus:ring-0
  mt-1
  pl-3
  pr-10
  rounded-lg
  shadow-md
  text-gray-600
  text-lg
  w-full
`

const FIELD: string = `
  ${FIELD_BASE}
  border-purple-300
  focus:border-fuchsia-500
`

const GRID: string = `
  gap-4
  grid
  grid-cols-1
  mt-8
`

const LABEL: string = `
  text-gray-600
  text-sm
  tracking-wider
`

const ContactForm = ({}: ReactNode) => {
  const formattedField: string = formatClassList(FIELD)
  const formattedLabel: string = formatClassList(LABEL)
  const formattedGrid: string = formatClassList(GRID)

  return (
    <form name="contact">
      <div className={formattedGrid}>
        <label className="block">
          <span className={formattedLabel}>Name</span>
          <div className="relative">
            <input
              className={formattedField}
              aria-required="true"
              placeholder=""
              type="text"
              name="name"
            />
          </div>
        </label>
        <label className="block">
          <span className={formattedLabel}>Email</span>
          <div className="relative">
            <input
              className={formattedField}
              aria-required="true"
              type="email"
              name="email"
            />
          </div>
        </label>
        <label className="block">
          <span className={formattedLabel}>Message</span>
          <div className="relative">
            <textarea
              className={formattedField}
              aria-required="true"
              rows={4}
              name="message"
            />
          </div>
        </label>
        <Button
          action="primary"
          className="mt-4"
          title="Submit"
          type="submit"
          disabled={false}
        >
          Send your message
        </Button>
      </div>
    </form>
  )
}

export default ContactForm

For further understanding and an in-depth look that my approach to keeping components tidy while working with Tailwind CSS please see Tidy React-Typescript component design with Tailwind CSS.

Above I have also abstracted the button into its own component, which keeps things DRY and further cleans up the TSX in the contact form component.

Note the Button import. As a standard practice, I keep an index.ts file inside of all component subdirectories (including contact-form) which cleans up imports throughout the app.

./src/components/button/index.ts
TS
export { default } from './button'

Now would be a prudent opportunity to break parts of our form component into smaller subcomponents before we start adding a bunch of form validation logic. Otherwise our form component will become difficult to maintain. Including our button component, which we aren't going to cover in this post, we can abstract two more subcomponents from this form (within reason1): TextInput and TextArea.

./text-input.tsx
TS
import React from 'react'

import { formatClassList } from '../../utils/utils'


export type TextInputProps = {
  label: string
}

const FIELD_BASE: string = `
  bg-gray-200
  block
  border-0
  border-l-4
  focus:ring-0
  mt-1
  pl-3
  pr-10
  rounded-lg
  shadow-md
  text-gray-600
  text-lg
  w-full
`

const FIELD: string = `
  ${FIELD_BASE}
  border-purple-300
  focus:border-fuchsia-500
`

const LABEL: string = `
  text-gray-600
  text-sm
  tracking-wider
`

const TextInput = ({
  label
}: TextInputProps) => {
  label = label.toLowerCase()

  const formattedField: string = formatClassList(FIELD)
  const formattedLabel: string = formatClassList(LABEL)

  return (
    <label className="block">
      <span className={formattedLabel}>{label.charAt(0).toUpperCase() + label.slice(1)}</span>
      <div className="relative">
        <input
          className={formattedField}
          aria-required="true"
          placeholder=""
          type="text"
          name={label}
        />
      </div>
    </label>
  )
}

export default TextInput
./text-area.tsx
TS
import React from 'react'

import { formatClassList } from '../../utils/utils'


export type TextAreaProps = {
  label: string
}

const FIELD_BASE: string = `
  bg-gray-200
  block
  border-0
  border-l-4
  focus:ring-0
  mt-1
  pl-3
  pr-10
  rounded-lg
  shadow-md
  text-gray-600
  text-lg
  w-full
`

const FIELD: string = `
  ${FIELD_BASE}
  border-purple-300
  focus:border-fuchsia-500
`

const LABEL: string = `
  text-gray-600
  text-sm
  tracking-wider
`

const TextInput = ({
  label
}: TextAreaProps) => {
  label = label.toLowerCase()

  const formattedField: string = formatClassList(FIELD)
  const formattedLabel: string = formatClassList(LABEL)

  return (
    <label className="block">
      <span className={formattedLabel}>{label.charAt(0).toUpperCase() + label.slice(1)}</span>
      <div className="relative">
        <textarea
          className={formattedField}
          aria-required="true"
          rows={4}
          name={label}
        />
      </div>
    </label>
  )
}

export default TextInput

And for completeness, lets turn our grid into a component as well.

./grid.tsx
TS
import React, { ReactNode } from 'react'

import { formatClassList } from '../../utils/utils'


export type GridProps = {
  children: ReactNode
}

const GRID: string = `
  gap-4
  grid
  grid-cols-1
  mt-8
`

const Grid = ({children}: GridProps) => {
  const formattedGrid: string = formatClassList(GRID)

  return(
    <div className={formattedGrid}>{children}</div>
  )
}

export default Grid

So our form component is now in good shape for managing the added complexity of React Hook Form.

TS
import React, { ReactNode } from 'react'

import { formatClassList } from '../../utils/utils'

import Button from '../../components/button'

import Grid from './grid'
import TextArea from './text-area'
import TextInput from './text-input'


const ContactForm = ({}: ReactNode) => {
  return (
    <form name="contact">
      <Grid>
        <TextInput
          label="name"
        />
        <TextInput
          label="email"
        />
        <TextArea
          label="message"
        />"
        <Button
          action="primary"
          className="mt-4"
          title="Submit"
          type="submit"
          disabled={false}
        >
          Send your message
        </Button>
      </Grid>
    </form>
  )
}

export default ContactForm

Next up, we will dive into adding React Hook Form client-side form field validation to this form.

1I'm not a fan of over-abstraction, ie. componentizing too much bloats a codebase and makes it just as difficult to follow as it would be if one hadn't broken it into components at all. Balance is important here and takes good judgement. Before breaking a piece into a component ask yourself if it will be beneficial to do so.

Tags

Tailwind CSS

React

TypeScript

Jamstack