Skip to content

One Card Component, Two Grids

Adityo Guni Waluyo

Copying Tailwind classes from the corporate cards was not enough: the list-item identity and its keyboard handlers had to move too.

TL;DR

Copying Tailwind classes between cards failed because the divider depended on the li-in-ul structure, not styling alone. Converting the office card to a list item meant adding the button role, keyboard handlers, and proper ul wrappers. Extracting an OfficeCard component with two props made the 2+3 layout trivial, though a leftover ul rendered the list twice until deleted.

That morning I opened office-network.tsx with one simple goal: make the office list at the bottom look exactly like the corporate network cards above it. My approach was the classic one. Select every Tailwind class from the card that already looked right, move it to the office card, paste, done.

It was not done.

Copied the Classes, Not the Structure

The first symptom showed up in the divider lines between cards. On the corporate network cards, each line sits neatly on the left edge of every card. On my pasted office cards, it never appeared. My first guess was a typo in a class name. I compared them character by character; identical.

Then I inspected the DOM and the real problem surfaced. In its original home, that divider line belongs to a bare list-item element carrying the reference-divider class, and the technique grows out of the list-plus-item pairing itself. My office card? Still a plain div inside a div grid. Same classes, different house.

At that point it clicked: copying classes will never be enough when the element identity is what differs. What needs to move is not just the styling but the structure.

Becoming a List Item While Staying Clickable

Turning the card into a list item has a first consequence you notice immediately: this element is not allowed to be an orphan. According to MDN, an li must be a child of an unordered list, an ordered list, or a menu [1]. So the grid wrapper has to change into a ul too, not just the card.

The second consequence is subtler. This office card is interactive: click it and the map above focuses the matching office location. So it carries role="button", tabIndex=0, and keyboard handlers. That is the documented contract; give a non-button element the button role and it must be focusable and handle Enter and Space through its own handlers [2]. If you tab to a card, press Enter, and the map does not focus, the handlers got lost along the way. All of that already existed on the old card, and when the structure moved, every piece had to come along.

Extraction Made the 2+3 Layout Trivial

Writing the same keyboard handlers plus list structure on several cards means copying boilerplate over and over. My fix: extract one card into its own component named OfficeCard. Same idea as the React docs on reuse: components exist so pieces can be reused and composed, not copy-pasted [3]. It takes exactly two props: office for the data and onFocus to forward the click to the map.

function OfficeCard({
  office,
  onFocus,
}: {
  office: OfficeLocation;
  onFocus: (name: string) => void;
}) {
  return (
    <li
      role="button"
      tabIndex={0}
      onClick={() => onFocus(office.name)}
      onKeyDown={(e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          onFocus(office.name);
        }
      }}
      className="reference-divider ..."
    >
      {/* icon tile, name, type, address, contact */}
    </li>
  );
}

// Top row: two cards, centered
<ul className="grid gap-md sm:grid-cols-2">
  {content.offices.slice(3).map((office) => (
    <OfficeCard key={office.name} office={office} onFocus={focusOffice} />
  ))}
</ul>

// Bottom row: three cards
<ul className="mt-md grid gap-md sm:grid-cols-2 lg:grid-cols-3">
  {content.offices.slice(0, 3).map((office) => (
    <OfficeCard key={office.name} office={office} onFocus={focusOffice} />
  ))}
</ul>

With the card standalone, the layout that used to require thinking became trivial. The requirement: a top row with two branch offices, a bottom row with the three main offices. The answer is just two ul elements with different column compositions, both mapping to the same component. The top row takes slice(3) and renders two columns; the bottom row takes slice(0, 3) and becomes a three-column grid on wide screens. Adding offices later means shifting data, not touching the component or the grids.

One small detail came along for the ride: the icon tile uses size-10 with a translucent accent background and an inset ring, and stays aria-hidden because it is decorative.

The hover lift uses hover:-translate-y-1 paired with motion-reduce:transform-none, so users who ask for reduced motion never get the animation.

The Regression: the List Rendered Twice

This is the part that still annoys me. After splitting into two grids I refreshed the page, and the office list appeared twice. First guess: a React state bug. I checked everywhere; state was fine.

The cause was far dumber: the old single-grid ul was still there. It sat idle below the new grid pair, rendering the exact same list. One extra commit spent on nothing but deleting a few leftover JSX lines from the previous attempt.

The working rule I keep now: when you want to imitate another component's look, inspect its actual DOM structure first, not just its classes. And when the pattern turns out to be needed in more than one place, extract a component from the start. Two small habits that save one very confusing debugging session.

Sources

Related articles