# Implementing an expandable row in an HTML table using React

Recently I worked on redesigning the mobile view of a table on an open-source website I maintain. This table had its own mobile component (`MyTableMobile.tsx`) that displayed each row as a standalone card which displayed properties top to bottom like this:

```plaintext
| - - - - - - - - - - - - -|
| Id: 123                  |
| Account Name: TestUser   |
| Date Created: 12/30/2025 |
| Last Login: 8/20/2026    |
| ...                      |
| - - - - - - - - - - - - -|
```

With 6 or 7 properties per row, this made navigation through records on mobile quite an annoying experience as it required lots of scrolling. Additionally, the table needed a separate component (`MyTableMobile.tsx`) just for the mobile view, and therefore utilized the dreaded *"render both components, but only show* `MyTableMobile.tsx` *on mobile breakpoints"* front-end pattern, which caused code duplication and confusion for developers who maintained the project.

## Mobile viewports make tables tricky

When building a data table in modern front-end, the mobile view often causes complications: not all columns fit on smaller breakpoints, and by default this either causes off-screen columns or a horizontally-scrollable table. Neither are good UI. In some projects I've reached for the crutch of transforming the UI into a card system on mobile, but you need a good design before implementing, otherwise it ends up becoming a worse user experience than a horizontally-scrollable table.

## Expandable rows is an elegant solution

Instead of trying to cram all your data into a small viewport, or even worse, make the data stack too tall, one solution that I've implemented in recent projects is to only show 1-2 key columns on mobile, hide the rest, and add a mobile-only chevron expander as the first column. When the user clicks the chevron, its icon changes direction and an expanded row is shown to the user with all the other columns and their values. Here's a quick demo:

<iframe height="400" style="width:100%" src="https://codepen.io/editor/aldrichdev/embed/01a10d4a-631d-7181-867c-0aec6de3074b?default-tab=result" frameborder="no">
  See the Pen <a href="https://codepen.io/editor/aldrichdev/pen/01a10d4a-631d-7181-867c-0aec6de3074b">
  Expandable table row in mobile React</a> by Paul Aldrich (<a href="https://codepen.io/aldrichdev">@aldrichdev</a>)
  on <a href="https://codepen.io">CodePen</a>.
</iframe>

## Diving into the code

All code for this feature was written without AI agents. ✍️

I used Tailwind and Lucide React icons for this demo, but otherwise it's a typical React project. Here's a look at the basic JSX structure for the table itself:

```html
      <table className="w-full text-left table-fixed lg:table-auto">
        <thead>
          <tr>
            <th className="p-4 text-base font-bold w-12 lg:hidden" />
            <th className="p-4 text-base font-bold hidden lg:table-cell">Id</th>
            <th className="p-4 text-base font-bold">Name</th>
            <th className="p-4 text-base font-bold">Email</th>
            <th className="p-4 text-base font-bold hidden lg:table-cell">
              Date Created
            </th>
            <th className="p-4 text-base font-bold hidden lg:table-cell">
              Last Login
            </th>
            <th className="p-4 text-base font-bold hidden lg:table-cell">
              Actions
            </th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <TableRow user={user} />
          ))}
        </tbody>
      </table>
```

A few things to note here:

*   `table-fixed` is an important piece. Without it, column headers would shift to the right every time the row is expanded. However, we don't need it on desktop since there isn't a chevron, so `table-auto` takes over there.
    
*   An empty `<th>` element for the chevron, as it needs no column heading; it also has a fixed width (`w-12`) so it doesn't grow when the row is expanded, which also caused the text shift.
    
*   Give `hidden lg:table-cell` to any `<th>` columns you don't want shown on mobile. In my case, I chose to show this mobile view on tablet as well to avoid horizontal scrolling on those breakpoints.
    

Next we'll see what `TableRow` looks like. I implemented a separate React component for this so it can have its own state indicating whether it's expanded:

```typescript
const TableRow = ({ user }) => {
  const [rowExpanded, setRowExpanded] = React.useState();

  if (!user) {
    return null;
  }

  const handleMobileChevronClick = () => {
    setRowExpanded(!rowExpanded);
  };

  return (
    <>
      <tr>
        <td className="p-4 cursor-pointer lg:hidden">
          {rowExpanded ? (
            <ChevronDown onClick={handleMobileChevronClick} />
          ) : (
            <ChevronRight onClick={handleMobileChevronClick} />
          )}
        </td>
        <td className="p-4 hidden lg:table-cell">{user.id}</td>
        <td className="p-4">{user.name}</td>
        <td className="p-4">{user.email}</td>
        <td className="p-4 hidden lg:table-cell">{user.dateCreated}</td>
        <td className="p-4 hidden lg:table-cell">{user.lastLogin}</td>
        <td className="p-4 hidden lg:flex lg:gap-2">
          <button className="cursor-pointer">
            <Info />
          </button>
          <button className="cursor-pointer">
            <UserRoundPen />
          </button>
          <button className="cursor-pointer">
            <LockKeyhole />
          </button>
        </td>
      </tr>
      {rowExpanded && (
        <tr className="bg-gray-200 text-left lg:hidden">
          ... Your row content here ...
        </tr>
      )}
    </>
  );
};

export default TableRow;
```

We'll go over what that `... Your row content here ...` part looks like in the next section.

## Breaking away from the HTML table structure

I used the HTML `<table>` element for this because it's better for accessibility. The tricky part with implementing the expandable row was breaking away from the HTML table's structure. Initially, I noticed the inner row's columns were constrained to the widths of the parent row's columns, which was annoying. The solve was to leave the inner row's `<tr>` element's `display` property alone (let it be a `table-row`), put a single `td` child within it, and finally put a grid within the `td`, like this:

```javascript
      {rowExpanded && (
        <>
          <tr className="bg-gray-200 text-left lg:hidden">
            <td colSpan={3}>
              <div className="grid grid-cols-2 gap-2 p-2">
                <div className="flex flex-wrap content-start gap-2">
                  <strong className="basis-full text-sm grow-0">Id</strong>
                  <p className="basis-full font-mono text-sm">{user.id}</p>
                </div>
                ... rest of expand-only columns here ...
              </div>
            </td>
          </tr>
        </>
```

`colSpan={3}` makes the cell full width of the 3 columns that are shown on mobile: the chevron column, Name and Email. Within that, a 2-column grid is used for showing the data in the inner row. `content-start` on each column header & value group is important as it prevents column headers from growing randomly if a value is 2 lines or longer.

See the full code sample [on Codepen](https://codepen.io/editor/aldrichdev/pen/01a10d4a-631d-7181-867c-0aec6de3074b)! 🧑‍💻

## Conclusion

The expandable table row design concept is pretty genius and it keeps working well even as you add more columns. Thought this would be a good writeup and it felt good to write code without AI again. I also haven't made a Codepen in forever so that was cool. One thing I want to call out is the "Show Info" button of the pen, it's a legacy button that opens a modal which I plan to refactor, but I would recommend rendering that info directly in the inner row itself if possible.
