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 ↗
Build it
Choose a target and adapt the source to your interface.
<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.
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
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.