No Time Dad

A blog about web development written by a busy dad

TailwindUI Table With CSS

I’ve always felt that the secret to being proficient with tailwindcss is to also be proficient with CSS. Which really isn’t a secret I guess. You need a base level of CSS knowledge before you can do anything with tailwindcss. It’s not like bootstrap where have you pre-made components you can just plug-in. That is, unless you’re using TailwindUI.

I’ve written previously about creating a simple table with tailwindcss, but the table was pretty boring. It does work well as a starting point, though. I thought it would be fun to try to create a more advanced table, but this time using vanilla css instead of tailwind to style the table.

The available components over at TailwindUI as masterfully designed, and I’ll be using one of their table components as the inspiration here. Their version of the table is shown below. My version of the table isn’t going to match theirs exactly, but it’ll be close. A live demo of the table I’ll be creating can be found on the examples page.

From TailwindUI: tailwindui table

Getting started

What’s the best way to get started when creating a custom element like this? Well, I like to create a basic skeleton of what the table element will look like. I know that I’m creating a table element, so I’ll add the opening and closing <table></table> tags first. I also know that table elements can have a head and a body, <thead> and <tbody> respectively, so I’ll add those as children of the <table> element.

<table>
  <thead>
    ...
  </thead>
  <tbody>
    ...
  </tbody>
</table>

It’s worth noting that I wrote that table elements can have a head and a body. But, they don’t have to. I could start adding table row <tr> elements as children of the table element and it would be valid html. The reason I like to add thead and tbody tags is because it makes the code easier to read. When a table row <tr> element is in the thead element it’s clear that that row is the header row. Using thead and tbody also makes styling the head and the body of the table easier.

Once I have a rough skeleton structure of the element defined I can decide which part of it I want to focus on first. Almost always I take a top down approach, so I’ll start with the table element itself.

The table element

Creating and styling elements is an iterative process. I’ll have a general idea of how I want something to look, but I try to let new ideas and styles for the element evolve as I work on it. I’ll always have to go back and change or add certain properties as the requirements for the change or no longer make sense for the element. This table is no exception. But, it’s a little easier since I’m replicating an existing design in this example.

Basline table properties

There are some baseline css properties I like to apply to table elements, which are shown below. These styles aren’t necessarily defined in the tailwindui version of the table, but I think they make the table look better and make it easier to fit on a page.

table {
  border-spacing: 0;
  border-collapse: collapse;
  min-width: 100%;
  overflow: hidden;
}

The border-spacing and border-collapse properties ensure that there isn’t any extra space between the columns. It’s not as noticable when the table background-color is white, but for any non-white color I might see space between the columns. I think it looks better when I remove the space between columns in table elements.

The table element is likely going to be a child in a parent element. I’ve found that it’s easier to control the width of the table using the parent element instead of directly on the table element itself. To make this easier, I use min-width: 100%; to ensure that the table fills all of its available space.

I’ve said it before, but tables aren’t great on mobile devices. Responsive tables are hard to design because knowing the exact shape of the data displayed in the table isn’t always possible. In a lot of cases the data will be dynamic. In most cases I accept the fact that tables aren’t a great experience on mobile devices. Which is where the overflow: hidden; above comes from. Admittedly it’s a cop-out, but it hides columns as the screen gets smaller. Minimal squishing of data in the table.

TailwindUI specific properties

With the baseline properties added, I can take a closer look at the TailwindUI specific properties for the table element. Looking at their table I can see right away that it has a border all the way around, rounded corners, and a box-shadow that gives it the classic card look.

:root {
  /* Custom color palette from coolors.co */
  --davys-grey: #495057ff;
  --cultured-2: #e9ecefff;
  --onyx: #343a40ff;
}

table {
  color: var(--onyx);
  border: 1px solid var(--cultured-2);
  border-radius: 0.5rem;
  /* Borrowed from tailwindcss */
  box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);

  /* Existing baseline styles */
  border-spacing: 0;
  border-collapse: collapse;
  min-width: 100%;
  overflow: hidden;
}

Adding in the header thead and some header columns, the table now looks something like the below, with the corresponding html markup beneath it.

NameTitleStatusRole
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Title</th>
      <th>Status</th>
      <th>Role</th>
      <th></th>
    </tr>
  </thead>
  <tbody></tbody>
</table>

Table header element

The tailwindui version of this table features a slightly darker background header as compared to the table body. The column headers are uppercase and their color is lighter than most of the table body text. I decided I didn’t love the uppercase headers or the lighter color so I made my version of the table header mostly unchanged. I did apply the text-alight: left property, which pulls the column headers to the left instead of centered over the data.

The next thing to do is to add some padding to the header columns. I did this by targeting the table header th selector and adding 1rem of padding all around it. I wanted the cells in the table to have the same amount of padding as the headers, so I also applied the padding selector to the td element that I’ll be adding later. My CSS file now looks as shown below. The html at this point remains unchanged from above.

/* Selector from previous section */
table {
  color: var(--onyx);
  border: 1px solid var(--cultured-2);
  border-radius: 0.5rem;
  border-spacing: 0;
  min-width: 100%;
  overflow: hidden;
  border-collapse: collapse;
  box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}

/* New selector */
thead {
  color: var(--davys-grey);
  background-color: var(--cultured-2);
  text-align: left;
}

/* New selector */
th,
td {
  padding: 1rem;
}
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Title</th>
      <th>Status</th>
      <th>Role</th>
      <th></th>
    </tr>
  </thead>
  <tbody></tbody>
</table>
NameTitleStatusRole

Table body element

The table body itself in the TailwindUI example is not overly complicated. But there are a couple things worth noting. The first is the background-color. If a value for this property is not set, the table will inherit the value of the parent element. Which might be unexpected. So, just to be safe I always explicitly set a background-color. The table body inheriting the background-color is not specific to the TailwindUI example, it’s default behavior for table elements.

tbody {
  background-color: white;
}

Something that is specific to the TailwindUI example is the border that separates each row in the table body. There are more than a few ways to add this bottom-border, but I like to use the :not pseudo-class in conjunction with the :last-child pseudo class. It looks a little magical, but it’s saying “apply this style to all table rows except the last one”. The reason for skipping the last row is that it’ll already have a border from the table element itself and I don’t want it to double.

tbody tr:not(:last-child) {
  border-bottom: 1px solid var(--cultured-2);
}

The updated css looks as follows:

table {
  color: var(--onyx);
  border: 1px solid var(--cultured-2);
  border-radius: 0.5rem;
  border-spacing: 0;
  min-width: 100%;
  overflow: hidden;
  border-collapse: collapse;
  box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}

thead {
  color: var(--davys-grey);
  background-color: var(--cultured-2);
  text-align: left;
}

th,
td {
  padding: 1rem;
}

/* New selector */
tbody tr:not(:last-child) {
  border-bottom: 1px solid var(--cultured-2);
}

/* New selector */
tbody {
  background-color: white;
}

And I updated the html to include some data to fill out the table.

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Title</th>
      <th>Status</th>
      <th>Role</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Susie Jones</td>
      <td>Account Manager</td>
      <td>Active</td>
      <td>Admin</td>
      <td><a href="#edit">Edit</a></td>
    </tr>
    <tr>
      <td>Jim Smith</td>
      <td>Lead Gen Associate</td>
      <td>Active</td>
      <td>User</td>
      <td><a href="#edit">Edit</a></td>
    </tr>
  </tbody>
</table>
NameTitleStatusRole
Susie JonesAccount ManagerActiveAdminEdit
Jim SmithLead Gen AssociateActiveUserEdit

When working with data in table, especially in the early design phase, I always try to limit the rows to one or two. Honestly, even two rows can be annoying when working on table styles. The problem is that if I have multiple rows I’ll have to copy the changes down to each row as I’m working. This gets tedious. It isn’t much of a problem if the data is dynamic and the table rows are populated in a loop, but when manually adding rows it can be rough. So I recommend one or two rows in a table at a time.

Column specifc styles

At this point I have a nice looking table. But, the table doesn’t really look like the TailwindUI version. I’ll narrow in on each cell in the row to get them looking like the example. The first will be the cell that container the user image, name, and email address.

One thing I noticed early on with the TailwindUI version of the table is that there’s a few different text styles in the table. Including lighter header text, bolder row text, and lighter row text. I tried to keep my version of the table simple, so I created two utility selectors to help with bold and light text in the table body. Those look as shown below.

.cell__text {
  font-weight: 500;
}

.cell__subtext {
  opacity: 0.7;
}

My first custom selector declaration! Up until this point I’ve been using the element itself like table, thead, etc. The reason for this is because this table is a simple example and just for fun. If this table was going into a production site I might create custom selectors for the elements instead of relying on their explicit names and structure. Creating custom selectors makes the css less brittle, but it’s also more time consuming. Naming things is hard, after all.

Table data

The name cell is composed of a circle avatar image that is horizontally center aligned with the user’s name above their email address. The user’s name is going to use the cell__text selector mentioned above, and the email will use cell__subtext. This entire cell will be a flexbox container, which makes alignment and spacing easy via align-items and gap properties. The trick is to treat the user name and email address as one element. This is done by wrapping both of them in a single div.

<!-- Flexbox container -->
<td class="name_cell">
  <!-- Avatar image -->
  <img
    class="name_cell__image"
    src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60"
    alt=""
  />
  <!-- Create single element for easier alignment with the avatar -->
  <div>
    <div class="cell__text">Susie Jones</div>
    <div class="cell__subtext">susie.jones@example.com</div>
  </div>
</td>

The Title cell is much easier. The user’s job will be above the department, which naturally happens in html. Flexbox or grid are not required. The only custom selector needed here is for the text, just like I did with the name cell.

<td>
  <div class="cell__text">Account Manager</div>
  <div class="cell__subtext">Projects</div>
</td>

The next cell is in the Status column. This cell is a little unique in that it introduces some color to the table. My approach for this was to create a status selector with baseline styles, then create element modifier selectors.

.status {
  font-size: 14px;
  padding: 0.25rem 0.5rem;
  border-radius: 0.5rem;
  font-weight: 500;
}

.status__active {
  background-color: lightgreen;
}

.status__inactive {
  background-color: lightcoral;
}
<td>
  <!-- Using span makes the width match the text length -->
  <span class="status status--active">Active</span>
</td>

After the Status column is the Role column, which features a single word with lighter text. For this I used the cell__subtext selector. I know it’s not really subtext, but the style matches so I went with it. I could create a new selector to avoid confusion, but I think it works fine for now.

And lastly I come to the Edit text, which doesn’t have a column header. This is just a link, so I’ll a generic anchor selector with a :hover style. It’s likely that if this table were on a page it’d use the same anchor style the rest of the page is using. Otherwise, I’d want to create a custom selector for this link.

a {
  color: inherit;
  text-decoration: none;
}

a:hover {
  opacity: 0.7;
}

Demo & final code

Live demo

/* styles.css */
:root {
  --davys-grey: #495057ff;
  --cultured-2: #e9ecefff;
  --onyx: #343a40ff;
}

body {
  max-width: 1100px;
  margin: 0 auto;
  padding: 2rem 0;
  font-family: "Roboto", sans-serif;
  background-color: var(--cultured);
}

a {
  color: inherit;
  text-decoration: none;
}

a:hover {
  opacity: 0.7;
}

table {
  color: var(--onyx);
  border: 1px solid var(--cultured-2);
  border-radius: 0.5rem;
  border-spacing: 0;
  min-width: 100%;
  overflow: hidden;
  border-collapse: collapse;
  box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}

thead {
  color: var(--davys-grey);
  background-color: var(--cultured-2);
  text-align: left;
}

th,
td {
  padding: 1rem;
}

tbody tr:not(:last-child) {
  border-bottom: 1px solid var(--cultured-2);
}

tbody {
  background-color: white;
}

.cell__text {
  font-weight: 500;
}

.cell__subtext {
  opacity: 0.7;
}

/* Cell specific styles */
.name_cell {
  display: flex;
  align-items: center;
  gap: 20px;
}

.name_cell__image {
  height: 46px;
  width: 46px;
  border-radius: 100%;
}

.status {
  font-size: 14px;
  padding: 0.25rem 0.5rem;
  border-radius: 0.5rem;
  font-weight: 500;
}

.status--active {
  background-color: lightgreen;
}

.status--inactive {
  background-color: lightcoral;
}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="styles.css" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap"
      rel="stylesheet"
    />
    <title>CSS Tailwind Style Table | No Time Dad</title>
  </head>
  <body>
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Title</th>
          <th>Status</th>
          <th>Role</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td class="name_cell">
            <img
              class="name_cell__image"
              src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60"
              alt=""
            />
            <div>
              <div class="cell__text">Susie Jones</div>
              <div class="cell__subtext">susie.jones@example.com</div>
            </div>
          </td>
          <td>
            <div class="cell__text">Account Manager</div>
            <div class="cell__subtext">Projects</div>
          </td>
          <td><span class="status status--active">Active</span></td>
          <td><span class="cell__subtext">Admin</span></td>
          <td><a href="#edit">Edit</a></td>
        </tr>
        <tr>
          <td class="name_cell">
            <img
              class="name_cell__image"
              src="https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60"
              alt=""
            />
            <div>
              <div class="cell__text">Jim Smith</div>
              <div class="cell__subtext">jim.smith@example.com</div>
            </div>
          </td>
          <td>
            <div class="cell__text">Lead Gen Associate</div>
            <div class="cell__subtext">Sales</div>
          </td>
          <td><span class="status status--active">Active</span></td>
          <td><span class="cell__subtext">User</span></td>
          <td><a href="#edit">Edit</a></td>
        </tr>
        <tr>
          <td class="name_cell">
            <img
              class="name_cell__image"
              src="https://images.unsplash.com/photo-1566492031773-4f4e44671857?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=4&w=256&h=256&q=60"
              alt=""
            />
            <div>
              <div class="cell__text">Alex Gilman</div>
              <div class="cell__subtext">alex.gilman@example.com</div>
            </div>
          </td>
          <td>
            <div class="cell__text">Application Engineer</div>
            <div class="cell__subtext">Development</div>
          </td>
          <td><span class="status status--inactive">Inactive</span></td>
          <td><span class="cell__subtext">Owner</span></td>
          <td><a href="#edit">Edit</a></td>
        </tr>
      </tbody>
    </table>
  </body>
</html>