<div class="view-selector">
  <button id="viewAsTable" data-view-type="table">View as table</button>
  <button id="viewAsGrid" data-view-type="grid">View as grid</button>
</div>

<div class="user-datalist-wrapper"></div>

<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
  <symbol id="arrow-right" fill="none" viewBox="0 0 24 24" strokewidth="1.5" stroke="currentColor">
    <path strokelinecap="round" strokelinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"></path>
  </symbol>
  <symbol id="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokewidth="1.5">
    <path d="M4.5 12.75L10.5 18.75L19.5 5.25" stroke-linecap="round" stroke-linejoin="round"></path>
  </symbol>
</svg>

<div class="prose">
  <p>This is an experiment where I attempt to switch tabular data views from a grid view to a table view with the power of style container queries. Unfortunatly, it needs a setTimeout of 0 JS workaround to make it work.</p>
  <p>For this experiment, I compare 3 different approaches:</p>
  <ol>
    <li><a href="https://codepen.io/mrtnvh/pen/KKxeLrX">Style container queries + HTML table (Prefered solution -> Semantic markup + CSS for visual layout)</a></li>
    <li><a href="https://codepen.io/mrtnvh/pen/yLxmEQM">Style container queries + HTML divs + CSS Grid</a></li>
    <li><a href="https://codepen.io/mrtnvh/pen/dyqxBVx">Media queries + HTML table</a></li>
  </ol>
  </p>
</div>
@layer variables, root, userdatalist, utilities;

@import "https://unpkg.com/open-props";
@import "https://unpkg.com/modern-css-reset@1.4.0/dist/reset.min.css";

@layer userdatalist {
  .user-datalist-type {
    container-type: normal;

    --user-datalist-view: grid; /* grid | table */
  }

  .user-datalist {
    outline: 1px solid var(--stone-4);
    inline-size: 100%;

    @container style(--user-datalist-view: grid) {
      display: block;
      outline-color: transparent;
    }
  }

  .user-datalist-head {
    text-align: left;

    @container style(--user-datalist-view: grid) {
      clip: rect(0 0 0 0);
      clip-path: inset(50%);
      height: 1px;
      overflow: hidden;
      position: absolute;
      white-space: nowrap;
      width: 1px;
    }

    th {
      padding-block: calc(var(--spacer) / 4);
    }
  }

  .user-datalist-body {
    @container style(--user-datalist-view: grid) {
      display: grid;
      grid-template-columns: repeat(
        auto-fill,
        minmax(calc(var(--spacer) * 13), 1fr)
      );
      gap: var(--spacer);
    }
  }

  .user-dataitem {
    padding: 0;
    vertical-align: middle;
    background-color: var(--stone-0);
    border: 1px solid transparent;

    @container style(--user-datalist-view: table) {
      display: table-row;
    }

    @container style(--user-datalist-view: grid) {
      display: grid;
      grid-template:
        "full-name thumbnail" auto
        "date-of-birth thumbnail" auto
        "phone thumbnail" auto
        "email actions" auto / auto calc(var(--spacer) * 3);
      padding-inline-start: var(--spacer);
      border-color: var(--stone-4);
    }

    &:nth-child(odd) {
      @container style(--user-datalist-view: table) {
        background-color: var(--stone-2);
      }
    }

    &:is(:focus-within, :has(td:hover)) {
      background-color: var(--indigo-1);
      cursor: pointer;
    }

    &:is(:focus-within) {
      outline: var(--outline-size) solid var(--outline-color);
      outline-offset: calc(var(--outline-size) * -1);
    }

    & :where(> *) {
      padding: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      line-height: var(--font-lineheight-1);

      @container style(--user-datalist-view: table) {
        padding: calc(var(--spacer) / 8);
        vertical-align: middle;
        cursor: pointer;
        white-space: nowrap;

        &:first-child {
          padding-inline-start: 0;
          padding-block: 0;
        }

        &:last-child {
          text-align: end;
          padding-inline-end: calc(var(--spacer) / 1.5);
        }
      }
    }
  }

  :where(.user-datalist
      td:not(.user-dataitem__thumbnail, .user-dataitem__actions)) {
    @container style(--user-datalist-view: grid) {
      margin-block: calc(var(--size-1) / 2);
    }
  }

  .user-dataitem__thumbnail {
    grid-area: thumbnail;

    @container style(--user-datalist-view: grid) {
      background-color: var(--stone-3);
    }
  }

  .user-dataitem__image {
    inline-size: 100%;

    @container style(--user-datalist-view: table) {
      max-inline-size: none;
      inline-size: calc(var(--spacer) * 2);
      block-size: calc(var(--spacer) * 2);
    }
  }

  .user-dataitem__full-name {
    grid-area: full-name;

    @container style(--user-datalist-view: grid) {
      margin-block-start: var(--spacer);
      font-size: var(--size-5);
      font-weight: 900;
      line-height: var(--font-lineheight-0);
    }
  }

  .user-dataitem__email {
    grid-area: email;
    margin-block-end: var(--spacer);
  }

  .user-dataitem__date-of-birth {
    grid-area: date-of-birth;

    @container style(--user-datalist-view: grid) {
      font-size: var(--font-size-0);
      margin-block-end: calc(var(--size-1));
      color: var(--stone-8);
    }
  }

  .user-dataitem__phone {
    grid-area: phone;
  }

  .user-dataitem__actions {
    text-align: center;

    @container style(--user-datalist-view: grid) {
      grid-area: actions;
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      background-color: var(--stone-3);

      &:has(tr:hover) {
        background-color: var(--indigo-3);
      }
    }
  }

  .user-dataitem__action--detail {
    display: flex;
    flex-direction: column;
    justify-content: center;
    outline: none;

    @container style(--user-datalist-view: grid) {
      align-items: end;
    }
  }
}

@layer variables {
  /* Variables */
  :root {
    --brand-primary: var(--indigo-8);

    --spacer: clamp(1.5rem, 3vw, 2rem);
    --container-spacer: clamp(1rem, 6vw, 4.5rem);
    --shadow-strength: 2.5%;

    --outline-size: 0.125rem;
    --outline-color: var(--indigo-6);
    --outline-offset: 0.25rem;
  }
}

@layer root {
  :root {
    font-size: clamp(15px, 1.5vw, 18px);
  }

  body {
    font-family: Inter, Roboto, "Helvetica Neue", "Arial Nova", "Nimbus Sans",
      Arial, sans-serif;
    font-size: 1rem;

    background-color: var(--stone-1);
    color: var(--stone-10);
    padding: var(--size-6);

    display: flex;
    flex-direction: column;
    gap: var(--size-6);
  }

  .prose > * + * {
    margin-block: var(--size-3) !important;
  }

  a {
    color: var(--stone-10);
    text-decoration: underline;
  }

  svg {
    width: 1.5em;
    height: 1.5em;
  }

  /* 
 * Outline color 
 */
  a,
  button {
    &:focus-visible {
      outline-color: var(--outline-color);
      outline-offset: var(--outline-offset);
    }
  }

  table {
    border-collapse: collapse;
  }
}

@layer utilities {
  .visually-hidden:not(:focus):not(:active) {
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    height: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
    width: 1px;
  }
}
View Compiled
document.documentElement.addEventListener("click", (e) => {
  if (e.target.dataset.viewType) {
    $userDataListType = document.querySelector(".user-datalist-type");

    // Hack to make it work. Make the container invisible and show it again. Currently works with timeout 0.
    $userDataListType.style.display = "none";
    $userDataListType.style.setProperty(
      "--user-datalist-view",
      e.target.dataset.viewType
    );
    setTimeout(() => {
      $userDataListType.style.display = "block";
    }, 0);
  }
});

function formatFullName(user) {
  return `${user.name.first} ${user.name.last}`;
}

const getUserDatalistTemplate = (users) => `<div class="user-datalist-type">
  <table class="user-datalist">
    <thead class="user-datalist-head">
      <tr>
        <th><span class="visually-hidden">Picture</span></th>
        <th>Name</th>
        <th>Email</th>
        <th>Date of birth</th>
        <th>Phone</th>
        <th><span class="visually-hidden">Actions</span></th>
      </tr>
    </thead>
    <tbody class="user-datalist-body">
      ${users
        ?.map((user) => {
          const fullName = formatFullName(user);
          const dateOfBirth = new Date(user.dob.date);
          const detailUrl = `/users/${user.login.uuid}`;
          return `<tr key="${
            user.login.uuid
          }" class="user-dataitem" data-href="${detailUrl}">
            <td class="user-dataitem__thumbnail">
              <img class="user-dataitem__image" src="${
                user.picture.thumbnail
              }" alt="Picture of ${fullName}" />
            </td>
            <td class="user-dataitem__full-name">${fullName}</td>
            <td class="user-dataitem__email">${user.email}</td>
            <td class="user-dataitem__date-of-birth">${dateOfBirth.toLocaleDateString()}</td>
            <td class="user-dataitem__phone">${user.phone}</td>
            <td class="user-dataitem__actions">
              <a class="user-dataitem__action--detail" href="${detailUrl}">
                <svg>
                  <use xlink:href="#arrow-right" />
                </svg>
                <span class="visually-hidden">More details about ${fullName}</span>
              </a>
            </td>
          </tr>`;
        })
        .join("")}
    </tbody>
  </table>
</div>`;

fetch("https://randomuser.me/api?results=10")
  .then((response) => response.json())
  .then(({ results }) => {
    const wrapper = document.querySelector(".user-datalist-wrapper");
    wrapper.innerHTML = getUserDatalistTemplate(results);
  });
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.