Layout blueprint

Table column collapse

A grouped table compresses without adding lines: secondary columns fade away while pull requests and points tuck beside each title. Widen it and the same values return to aligned columns.

Inspired by Alvish Baldha on X ↗ · Harsh Singh on X ↗

Playground

Playhead 0ms / 400ms

Build it

Choose a target and adapt the source to your interface.

674 lines
index.html
<div class="column-demo">
  <div class="column-controls">
    <button type="button" class="column-toggle">Narrow table</button
    ><span class="column-hint">Drag the right edge to resize</span>
  </div>
  <div class="column-stage">
    <section
      class="column-table"
      data-layout="wide"
      aria-label="Project issues"
    >
      <div class="column-toolbar">Issues <span>9</span></div>
      <section class="column-group" data-tone="review">
        <button
          type="button"
          class="column-group-toggle"
          aria-expanded="true"
          aria-controls="column-group-0"
        >
          <span class="column-status" aria-hidden="true"></span>In review<span
            class="column-count"
            >3</span
          ><span class="column-heading-fields" aria-hidden="true"
            ><span>↗</span><span>◇</span><span>△</span><span>▣</span></span
          ><span class="column-chevron" aria-hidden="true">⌄</span>
        </button>
        <div class="column-group-body" id="column-group-0" aria-hidden="false">
          <ul class="column-rows">
            <li class="column-row">
              <span class="column-cell column-title"
                >Cache recently viewed pages</span
              ><span
                class="column-cell column-pr"
                aria-label="Pull request #1184"
                >#1184</span
              ><span class="column-cell column-label">Performance</span
              ><span class="column-cell column-points">2 pts</span
              ><span class="column-cell column-date" aria-label="Due Oct 12"
                >Oct 12</span
              ><span
                class="column-cell column-owner"
                aria-label="Assigned to MC and JL"
                ><span class="column-avatar column-peer" aria-hidden="true"
                  >JL</span
                ><span class="column-avatar" aria-hidden="true">MC</span></span
              >
            </li>
            <li class="column-row">
              <span class="column-cell column-title"
                >Refine the location prompt</span
              ><span
                class="column-cell column-pr"
                aria-label="Pull request #1179"
                >#1179</span
              ><span class="column-cell column-label">Design</span
              ><span class="column-cell column-points">1 pt</span
              ><span class="column-cell column-date" aria-label="Due Oct 14"
                >Oct 14</span
              ><span
                class="column-cell column-owner"
                aria-label="Assigned to JL and AR"
                ><span class="column-avatar column-peer" aria-hidden="true"
                  >AR</span
                ><span class="column-avatar" aria-hidden="true">JL</span></span
              >
            </li>
            <li class="column-row">
              <span class="column-cell column-title"
                >Improve keyboard navigation</span
              ><span
                class="column-cell column-pr"
                aria-label="Pull request #1172"
                >#1172</span
              ><span class="column-cell column-label">Design</span
              ><span class="column-cell column-points">3 pts</span
              ><span class="column-cell column-date" aria-label="Due Oct 16"
                >Oct 16</span
              ><span
                class="column-cell column-owner"
                aria-label="Assigned to AR and MC"
                ><span class="column-avatar column-peer" aria-hidden="true"
                  >MC</span
                ><span class="column-avatar" aria-hidden="true">AR</span></span
              >
            </li>
          </ul>
        </div>
      </section>
      <section class="column-group" data-tone="progress">
        <button
          type="button"
          class="column-group-toggle"
          aria-expanded="true"
          aria-controls="column-group-1"
        >
          <span class="column-status" aria-hidden="true"></span>In progress<span
            class="column-count"
            >3</span
          ><span class="column-heading-fields" aria-hidden="true"
            ><span>↗</span><span>◇</span><span>△</span><span>▣</span></span
          ><span class="column-chevron" aria-hidden="true">⌄</span>
        </button>
        <div class="column-group-body" id="column-group-1" aria-hidden="false">
          <ul class="column-rows">
            <li class="column-row">
              <span class="column-cell column-title"
                >Sync changes across devices</span
              ><span
                class="column-cell column-pr"
                aria-label="Pull request #1168"
                >#1168</span
              ><span class="column-cell column-label">Bug</span
              ><span class="column-cell column-points">3 pts</span
              ><span class="column-cell column-date" aria-label="Due Oct 18"
                >Oct 18</span
              ><span
                class="column-cell column-owner"
                aria-label="Assigned to JL and MC"
                ><span class="column-avatar column-peer" aria-hidden="true"
                  >MC</span
                ><span class="column-avatar" aria-hidden="true">JL</span></span
              >
            </li>
            <li class="column-row">
              <span class="column-cell column-title"
                >Invite teammates with a link</span
              ><span
                class="column-cell column-pr"
                aria-label="Pull request #1161"
                >#1161</span
              ><span class="column-cell column-label">Feature</span
              ><span class="column-cell column-points">2 pts</span
              ><span class="column-cell column-date" aria-label="Due Oct 20"
                >Oct 20</span
              ><span
                class="column-cell column-owner"
                aria-label="Assigned to MC and AR"
                ><span class="column-avatar column-peer" aria-hidden="true"
                  >AR</span
                ><span class="column-avatar" aria-hidden="true">MC</span></span
              >
            </li>
            <li class="column-row">
              <span class="column-cell column-title"
                >Download pages over Wi-Fi</span
              ><span
                class="column-cell column-pr"
                aria-label="Pull request #1157"
                >#1157</span
              ><span class="column-cell column-label">Feature</span
              ><span class="column-cell column-points">2 pts</span
              ><span class="column-cell column-date" aria-label="Due Oct 22"
                >Oct 22</span
              ><span
                class="column-cell column-owner"
                aria-label="Assigned to AR and JL"
                ><span class="column-avatar column-peer" aria-hidden="true"
                  >JL</span
                ><span class="column-avatar" aria-hidden="true">AR</span></span
              >
            </li>
          </ul>
        </div>
      </section>
      <section class="column-group" data-tone="todo">
        <button
          type="button"
          class="column-group-toggle"
          aria-expanded="true"
          aria-controls="column-group-2"
        >
          <span class="column-status" aria-hidden="true"></span>Todo<span
            class="column-count"
            >3</span
          ><span class="column-heading-fields" aria-hidden="true"
            ><span>↗</span><span>◇</span><span>△</span><span>▣</span></span
          ><span class="column-chevron" aria-hidden="true">⌄</span>
        </button>
        <div class="column-group-body" id="column-group-2" aria-hidden="false">
          <ul class="column-rows">
            <li class="column-row">
              <span class="column-cell column-title"
                >Check contrast in dark mode</span
              ><span
                class="column-cell column-pr"
                aria-label="Pull request #1150"
                >#1150</span
              ><span class="column-cell column-label">Design</span
              ><span class="column-cell column-points">1 pt</span
              ><span class="column-cell column-date" aria-label="Due Oct 24"
                >Oct 24</span
              ><span
                class="column-cell column-owner"
                aria-label="Assigned to AR and JL"
                ><span class="column-avatar column-peer" aria-hidden="true"
                  >JL</span
                ><span class="column-avatar" aria-hidden="true">AR</span></span
              >
            </li>
            <li class="column-row">
              <span class="column-cell column-title"
                >Restore a saved workspace</span
              ><span
                class="column-cell column-pr"
                aria-label="Pull request #1146"
                >#1146</span
              ><span class="column-cell column-label">Sync</span
              ><span class="column-cell column-points">2 pts</span
              ><span class="column-cell column-date" aria-label="Due Oct 28"
                >Oct 28</span
              ><span
                class="column-cell column-owner"
                aria-label="Assigned to MC and AR"
                ><span class="column-avatar column-peer" aria-hidden="true"
                  >AR</span
                ><span class="column-avatar" aria-hidden="true">MC</span></span
              >
            </li>
            <li class="column-row">
              <span class="column-cell column-title"
                >Clean up archived projects</span
              ><span
                class="column-cell column-pr"
                aria-label="Pull request #1141"
                >#1141</span
              ><span class="column-cell column-label">Maintenance</span
              ><span class="column-cell column-points">1 pt</span
              ><span class="column-cell column-date" aria-label="Due Oct 30"
                >Oct 30</span
              ><span
                class="column-cell column-owner"
                aria-label="Assigned to JL and MC"
                ><span class="column-avatar column-peer" aria-hidden="true"
                  >MC</span
                ><span class="column-avatar" aria-hidden="true">JL</span></span
              >
            </li>
          </ul>
        </div>
      </section>
      <div
        class="column-resize"
        role="separator"
        tabindex="0"
        aria-label="Resize table"
        aria-orientation="vertical"
        aria-valuemin="320"
        aria-valuemax="740"
        aria-valuenow="740"
      ></div>
    </section>
  </div>
</div>

<script>
  const root = document.querySelector('.column-demo'),
    table = root.querySelector('.column-table'),
    toggle = root.querySelector('.column-toggle'),
    handle = root.querySelector('.column-resize');
  let width = 740,
    drag = null;
  const layout = (w) => (w < 540 ? 'compact' : w < 680 ? 'medium' : 'wide');
  function measureTitles() {
    root.querySelectorAll('.column-row').forEach((row) => {
      row.style.setProperty(
        '--title-width',
        row.querySelector('.column-title').scrollWidth + 'px',
      );
    });
  }
  function sync() {
    const actual = Math.min(width, root.clientWidth);
    table.dataset.layout = layout(actual);
    root
      .querySelectorAll('.column-label')
      .forEach((cell) =>
        cell.setAttribute('aria-hidden', String(layout(actual) !== 'wide')),
      );
    handle.setAttribute('aria-valuenow', String(Math.round(actual)));
    handle.setAttribute(
      'aria-valuemax',
      String(Math.min(740, root.clientWidth)),
    );
    handle.setAttribute(
      'aria-valuemin',
      String(Math.min(320, root.clientWidth)),
    );
    toggle.disabled = root.clientWidth <= 320;
    toggle.textContent =
      width < Math.min(540, root.clientWidth) ? 'Widen table' : 'Narrow table';
  }
  function resize(next) {
    width = Math.max(
      Math.min(320, root.clientWidth),
      Math.min(740, root.clientWidth, next),
    );
    table.style.setProperty('--table-width', width + 'px');
    sync();
  }
  toggle.addEventListener('click', () =>
    resize(
      width < Math.min(540, root.clientWidth)
        ? 740
        : Math.min(440, Math.max(320, root.clientWidth - 120)),
    ),
  );
  root.querySelectorAll('.column-group-toggle').forEach((button) =>
    button.addEventListener('click', () => {
      const expanded = button.getAttribute('aria-expanded') !== 'true';
      button.setAttribute('aria-expanded', String(expanded));
      document
        .getElementById(button.getAttribute('aria-controls'))
        .setAttribute('aria-hidden', String(!expanded));
    }),
  );
  handle.addEventListener('pointerdown', (event) => {
    if (event.button !== 0) return;
    const scale = table.getBoundingClientRect().width / table.offsetWidth;
    drag = { x: event.clientX, width: table.offsetWidth, scale };
    table.dataset.dragging = 'true';
    handle.setPointerCapture(event.pointerId);
    event.preventDefault();
  });
  handle.addEventListener('pointermove', (event) => {
    if (drag) resize(drag.width + (event.clientX - drag.x) / drag.scale);
  });
  function endDrag() {
    drag = null;
    table.dataset.dragging = 'false';
  }
  handle.addEventListener('pointerup', endDrag);
  handle.addEventListener('pointercancel', endDrag);
  handle.addEventListener('lostpointercapture', endDrag);
  handle.addEventListener('keydown', (event) => {
    if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
      event.preventDefault();
      resize(
        event.key === 'Home'
          ? 320
          : event.key === 'End'
            ? 740
            : width + (event.key === 'ArrowLeft' ? -20 : 20),
      );
    }
  });
  const observer = new ResizeObserver(() => {
    measureTitles();
    sync();
    if (!root.hasAttribute('data-ready')) {
      void table.offsetWidth;
      root.dataset.ready = 'true';
    }
  });
  observer.observe(root);
  // Disconnect the observer if removing this demo from a long-lived application.
</script>

<style>
  .column-demo {
    width: 760px;
    max-width: 100%;
    color: var(--stage-ink, #252b34);
    font:
      12px/1.4 system-ui,
      sans-serif;
  }
  .column-demo:not([data-ready]) * {
    transition: none !important;
  }
  .column-controls {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 18px;
  }
  .column-toggle {
    min-height: 44px;
    padding: 0 14px;
    border: 1px solid var(--stage-grid, #dce0e6);
    border-radius: 8px;
    background: var(--stage-bg, #fafafa);
    color: inherit;
    font: inherit;
    cursor: pointer;
  }
  .column-hint {
    color: var(--stage-muted, #596170);
    font-size: 11px;
  }
  .column-stage {
    min-height: 540px;
    position: relative;
  }
  .column-table {
    position: relative;
    box-sizing: border-box;
    width: min(var(--table-width, 740px), 100%);
    border: 1px solid var(--stage-grid, #dce0e6);
    border-radius: 12px;
    background: var(--stage-bg, #fafafa);
    padding: 8px;
    transition: width 400ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  .column-table[data-dragging='true'] {
    transition: none;
  }
  .column-toolbar {
    height: 46px;
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 0 10px;
    font-weight: 600;
  }
  .column-toolbar span {
    color: var(--stage-muted, #596170);
    font-weight: 400;
  }
  .column-group-toggle {
    position: relative;
    display: flex;
    align-items: center;
    gap: 9px;
    width: 100%;
    height: 38px;
    border: 0;
    border-radius: 6px;
    padding: 0 10px;
    background: color-mix(in srgb, var(--stage-ink, #252b34) 5%, transparent);
    color: inherit;
    text-align: left;
    font: inherit;
    cursor: pointer;
  }
  .column-status {
    width: 8px;
    height: 8px;
    border: 1.5px solid var(--stage-muted, #596170);
    border-radius: 50%;
  }
  [data-tone='review'] .column-status {
    border-color: #987a28;
    background: #987a2820;
  }
  [data-tone='progress'] .column-status {
    border-color: var(--stage-link, #175bc5);
    background: var(--stage-link, #175bc5);
  }
  .column-heading-fields {
    position: absolute;
    inset: 0;
    pointer-events: none;
    transition: opacity 180ms ease-out;
  }
  .column-heading-fields span {
    position: absolute;
    top: 10px;
    color: var(--stage-muted, #596170);
    font-size: 12px;
  }
  .column-heading-fields span:nth-child(1) {
    left: calc(100% - 410px);
  }
  .column-heading-fields span:nth-child(2) {
    left: calc(100% - 326px);
  }
  .column-heading-fields span:nth-child(3) {
    left: calc(100% - 226px);
  }
  .column-heading-fields span:nth-child(4) {
    left: calc(100% - 150px);
  }
  .column-table:not([data-layout='wide']) .column-heading-fields {
    opacity: 0;
  }
  .column-count {
    color: var(--stage-muted, #596170);
    font-size: 11px;
  }
  .column-chevron {
    margin-left: auto;
    transition: transform 260ms ease-out;
  }
  .column-group-toggle[aria-expanded='false'] .column-chevron {
    transform: rotate(-90deg);
  }
  .column-group-body {
    display: grid;
    grid-template-rows: 1fr;
    transition: grid-template-rows 300ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  .column-group-body[aria-hidden='true'] {
    grid-template-rows: 0fr;
  }
  .column-rows {
    min-height: 0;
    overflow: hidden;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .column-row {
    position: relative;
    height: 40px;
  }
  .column-row:last-child {
    border-bottom: 0;
  }
  .column-cell {
    position: absolute;
    top: 12px;
    white-space: nowrap;
    transition:
      left 400ms cubic-bezier(0.22, 1, 0.36, 1),
      max-width 400ms cubic-bezier(0.22, 1, 0.36, 1),
      width 400ms cubic-bezier(0.22, 1, 0.36, 1),
      opacity 180ms ease-out,
      transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  .column-title {
    left: 10px;
    max-width: calc(100% - 432px);
    overflow: hidden;
    text-overflow: ellipsis;
    font-weight: 500;
  }
  .column-pr {
    left: calc(100% - 410px);
    font:
      11px/18px ui-monospace,
      monospace;
    color: var(--stage-link, #175bc5);
    top: 11px;
  }
  .column-label {
    left: calc(100% - 326px);
    font-size: 10px;
    color: var(--stage-muted, #596170);
  }
  .column-label::before {
    content: '•';
    color: var(--stage-link, #175bc5);
    margin-right: 5px;
  }
  .column-points {
    left: calc(100% - 226px);
    color: var(--stage-muted, #596170);
    font-size: 11px;
  }
  .column-date {
    left: calc(100% - 150px);
    color: var(--stage-muted, #596170);
    font-size: 11px;
  }
  .column-owner {
    left: calc(100% - 62px);
    top: 8px;
    width: 44px;
    height: 24px;
  }
  .column-avatar {
    position: absolute;
    right: 0;
    top: 0;
    width: 24px;
    height: 24px;
    display: grid;
    place-items: center;
    border-radius: 50%;
    border: 2px solid var(--stage-bg, #fafafa);
    background: color-mix(
      in srgb,
      var(--stage-link, #175bc5) 12%,
      var(--stage-bg, #fafafa)
    );
    color: var(--stage-link, #175bc5);
    font-size: 8px;
    font-weight: 600;
  }
  .column-peer {
    right: 18px;
    transition:
      opacity 180ms ease-out,
      transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  .column-table:not([data-layout='wide']) .column-label {
    opacity: 0;
    transform: translateX(-12px);
  }
  .column-table:not([data-layout='wide']) .column-peer {
    opacity: 0;
    transform: translateX(18px);
  }
  .column-table[data-layout='medium'] .column-title {
    max-width: calc(100% - 270px);
  }
  .column-table[data-layout='medium'] .column-pr {
    left: calc(100% - 248px);
  }
  .column-table[data-layout='medium'] .column-points {
    left: calc(100% - 172px);
  }
  .column-table[data-layout='medium'] .column-date {
    left: calc(100% - 106px);
  }
  .column-table:not([data-layout='wide']) .column-owner {
    left: calc(100% - 32px);
    width: 24px;
  }
  .column-table[data-layout='compact'] .column-title {
    max-width: calc(100% - 210px);
  }
  .column-table[data-layout='compact'] .column-pr {
    left: calc(min(var(--title-width, 180px), 100% - 210px) + 20px);
  }
  .column-table[data-layout='compact'] .column-points {
    left: calc(min(var(--title-width, 180px), 100% - 210px) + 80px);
  }
  .column-table[data-layout='compact'] .column-date {
    left: calc(100% - 86px);
  }
  .column-resize {
    position: absolute;
    top: 50%;
    right: -12px;
    transform: translateY(-50%);
    width: 24px;
    height: 64px;
    display: grid;
    place-items: center;
    cursor: ew-resize;
    touch-action: none;
    border: 0;
    background: transparent;
    color: var(--stage-muted, #596170);
  }
  .column-resize::after {
    content: '';
    width: 4px;
    height: 30px;
    border-radius: 4px;
    background: currentColor;
  }
  .column-toggle:hover,
  .column-group-toggle:hover {
    background: color-mix(
      in srgb,
      var(--stage-ink, #252b34) 9%,
      var(--stage-bg, #fafafa)
    );
  }
  .column-demo :focus-visible {
    outline: 2px solid var(--stage-link, #175bc5);
    outline-offset: 3px;
  }
  @media (max-width: 420px) {
    .column-hint {
      max-width: 140px;
      text-align: right;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .column-table,
    .column-cell,
    .column-row,
    .column-group-body,
    .column-chevron,
    .column-peer,
    .column-heading-fields {
      transition: none;
    }
  }
</style>

Animation breakdown

Frame phases, motion intent, implementation constraints and accessible alternatives.

400ms
01

Wide: metadata occupies aligned columns. Below 680px: the category column and additional avatars fade away. Below 540px: pull requests and points slide directly beside each row title. Dates and the primary assignee remain pinned right, with every row staying 40px tall. Widening reverses the movement. Group headings independently collapse their rows over 300ms.

Motion intent

A grouped table compresses without adding lines: secondary columns fade away while pull requests and points tuck beside each title. Widen it and the same values return to aligned columns.

400ms connects each metadata cell to its new location; group disclosures use 300ms. Dragged width follows the pointer immediately.

A shared ease-out keeps column movements coordinated without bounce or overshoot.

Motion specification

Trigger
Narrow the table using its right edge or the Narrow table button. Toggle a status heading to collapse its group.
Timing
400ms
Easing
cubic-bezier(.22, 1, .36, 1)

Animated properties

widthleftmax-widthopacitygrid-template-rowstransform

Implementation notes

  • Use the table container width, not the viewport, to select wide, medium, and compact arrangements at 680px and 540px.
  • Keep one copy of each value mounted and preserve 40px row heights. Measure each title so compact pull requests sit 10px after that title, rather than in a fixed column.
  • Animate horizontal position and text clipping without vertical travel or text scaling. Category labels and secondary avatars fade out over 180ms. Dates and primary avatars remain at the right edge.
  • Convert pointer movement into local CSS pixels when the preview is scaled; capture the pointer and clean up on cancellation.
  • The React examples use the same CSS transitions as HTML. The native adaptation uses Reanimated layout transitions and a width toggle instead of a desktop resize handle.
  • Group controls are independent from responsive column state. Disconnect ResizeObserver when unmounting.

Reduced motion

Apply all final widths, cell positions, and group states immediately. Native layout transitions follow the system reduced-motion setting.

  • Use real buttons with aria-expanded and aria-controls for status groups.
  • Expose the resize handle as a labeled separator with current, minimum, and maximum widths. Arrow keys adjust by 20px; Home and End select the bounds.
  • Keep primary values visible and in logical reading order. Hide faded category labels from assistive technology; retain the full assignee label even when only one avatar is shown.

Avoid

  • Do not move metadata onto a second line or increase row height.
  • Do not align compact pull requests to a fixed left edge; each follows its own title.
  • Do not animate the pointer-driven width with a trailing delay.

Related blueprints

Browse all blueprints