Skip to content

Instantly share code, notes, and snippets.

@blizzardengle
Created December 19, 2025 07:28
Show Gist options
  • Select an option

  • Save blizzardengle/d210ec3ce7dc3e954812e4e47591b453 to your computer and use it in GitHub Desktop.

Select an option

Save blizzardengle/d210ec3ce7dc3e954812e4e47591b453 to your computer and use it in GitHub Desktop.
A span-aware masonry-style grid layout, a polyfill of the CSS masonry spec with my alterations.
/**
* SpanAwareMasonry (SAM) - A span-aware masonry-style grid layout system
*
* @class SpanAwareMasonry
* @description
* Creates a packed grid layout that combines CSS Grid auto-placement with masonry-style
* vertical stacking. Unlike native CSS Masonry (grid-template-rows: masonry), SAM maintains
* left-to-right sequential placement while optimizing vertical space usage.
*
* @features
* - Respects grid column spans (span-2, span-3, etc.) for proper row breaking
* - Maintains predictable left-to-right item flow within logical rows
* - Intelligently skips tall columns when starting new rows (threshold: gap distance)
* - Prevents pixel drift by synchronizing similar heights (within 5px threshold)
* - Automatically updates on resize and DOM mutations
* - Caches column span calculations for performance
*
* @example
* // HTML structure
* <div class="masonry">
* <div class="span-2">Wide item</div>
* <div>Item 1</div>
* <div>Item 2</div>
* </div>
*
* // CSS requirements
* .masonry {
* display: grid;
* grid-template-columns: repeat(4, 1fr);
* gap: 20px;
* }
*
* // JavaScript initialization
* const grid = document.querySelector('.masonry');
* const masonry = new SpanAwareMasonry(grid);
*
* @param {HTMLElement} grid - The grid container element with display: grid
*
* @method destroy - Cleans up observers and removes applied styles
*
* @algorithm
* Phase 1: Simulate grid auto-placement
* - Calculate which items fit in each logical row based on spans
* - Determine starting column for each row (skip tall columns)
* - Track estimated column heights for placement decisions
*
* Phase 2: Apply vertical positioning
* - Set grid column positions for each item
* - Calculate negative margins to stack items tightly
* - Group similar heights and apply minHeight to prevent drift
* - Update actual column heights using forced minHeight values
*
* @performance
* - Span calculations cached (cleared only on DOM mutations)
* - Single getBoundingClientRect per item per layout
* - Updates batched via requestAnimationFrame
* - WeakMap for automatic memory cleanup
*
* @browser_support
* Requires CSS Grid support (all modern browsers)
*
* @author Christopher Keers
* @version 1.0.0
* @license MIT
*/
class SpanAwareMasonry {
constructor(grid) {
this.grid = grid;
this.lastUpdateTimestamp = 0;
// Cache for column spans
this.spanCache = new WeakMap();
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(this.updateLayout);
});
this.mutationObserver = new MutationObserver((records) => {
// Clear span cache only when DOM structure changes
records.forEach((record) => {
record.addedNodes.forEach((node) => {
this.resizeObserver.observe(node);
});
record.removedNodes.forEach((node) => {
this.spanCache.delete(node);
});
});
requestAnimationFrame(this.updateLayout);
});
this.resizeObserver.observe(this.grid);
this.getChildren().forEach((child) => {
this.resizeObserver.observe(child);
});
this.mutationObserver.observe(this.grid, {
childList: true
});
}
getChildren = () => {
return Array.from(this.grid.children).filter(
(child) => { return child instanceof HTMLElement; }
);
};
clearStyles = () => {
this.getChildren().forEach((child) => {
child.style.marginTop = '';
child.style.gridColumn = '';
child.style.gridColumnStart = '';
child.style.minHeight = '';
});
// Don't clear span cache - only cleared on DOM mutations
};
// Helper function to determine how many columns an element should span
// Results are cached since spans don't change between updates
getColumnSpan = (child, numColumns) => {
// Check cache first
if (this.spanCache.has(child)) {
return this.spanCache.get(child);
}
const childStyle = window.getComputedStyle(child);
const { gridColumn, gridColumnStart, gridColumnEnd } = childStyle;
let span = 1; // Default
// Check grid-column shorthand first
if (gridColumn && gridColumn !== 'auto') {
// Handle "1 / -1" syntax (span all columns)
if (gridColumn.includes('-1')) {
const startMatch = gridColumn.match(/^(\d+)\s*\/\s*-1$/);
if (startMatch) {
const start = parseInt(startMatch[1]);
span = numColumns - start + 1;
}
}
// Handle "span X" syntax
else {
const spanMatch = gridColumn.match(/span\s+(\d+)/);
if (spanMatch) {
span = parseInt(spanMatch[1]);
}
// Handle "start / end" syntax (e.g., "1 / 3" = span 2)
else {
const slashMatch = gridColumn.match(/^(\d+)\s*\/\s*(\d+)$/);
if (slashMatch) {
span = parseInt(slashMatch[2]) - parseInt(slashMatch[1]);
}
// Handle "start / span X" syntax
else {
const startSpanMatch = gridColumn.match(/\d+\s*\/\s*span\s+(\d+)/);
if (startSpanMatch) {
span = parseInt(startSpanMatch[1]);
}
}
}
}
}
// Check grid-column-end for special cases
else if (gridColumnEnd) {
// Handle "-1" (span to end)
if (gridColumnEnd === '-1') {
const start = parseInt(gridColumnStart) || 1;
span = numColumns - start + 1;
}
// Handle "span X"
else if (gridColumnEnd.startsWith('span')) {
const spanValue = parseInt(gridColumnEnd.replace('span', '').trim());
if (!isNaN(spanValue)) {
span = spanValue;
}
}
// Handle explicit end number
else {
const endNum = parseInt(gridColumnEnd);
if (!isNaN(endNum) && endNum > 0) {
const start = parseInt(gridColumnStart) || 1;
span = endNum - start;
}
}
}
// Cache the result
this.spanCache.set(child, span);
return span;
};
updateLayout = (timestamp) => {
// Skip update if already ran in this animation frame
if (timestamp === this.lastUpdateTimestamp) {
return;
}
this.lastUpdateTimestamp = timestamp;
this.clearStyles();
const gridStyle = window.getComputedStyle(this.grid);
// Bail early if not a grid or if native masonry is supported
if (gridStyle.display !== 'grid') {
console.warn('Display must be grid for masonry layout to work');
return;
}
if (gridStyle.gridTemplateRows === 'masonry') {
console.warn('Masonry layout is already supported natively');
return;
}
const parentPaddingTop = parseFloat(gridStyle.paddingTop) || 0;
const numColumns = gridStyle.gridTemplateColumns.split(' ').length;
// No masonry needed for single column
if (numColumns <= 1) {
return;
}
const rowGap = parseFloat(gridStyle.rowGap) || 0;
const estimatedColumnHeights = new Array(numColumns).fill(0);
const children = this.getChildren().filter((child) => { return !!child.offsetParent; });
// PHASE 1: Simulate grid auto-placement with intelligent column skipping
const itemPlacements = [];
let currentRow = 0;
let columnsUsedInCurrentRow = 0;
let currentRowStartColumn = 0;
children.forEach((child, index) => {
const span = this.getColumnSpan(child, numColumns);
const actualSpan = Math.min(span, numColumns);
// Estimate height for placement decisions (only read once here)
const { height: childHeight } = child.getBoundingClientRect();
const childStyle = window.getComputedStyle(child);
const childMarginTop = parseFloat(childStyle.marginTop) || 0;
const childMarginBottom = parseFloat(childStyle.marginBottom) || 0;
const estimatedHeight = Math.round(childMarginTop + childHeight + childMarginBottom);
// Calculate how many columns are available from the current start position
const availableColumns = numColumns - currentRowStartColumn;
// Check if this item fits in the remaining space of current row
if (columnsUsedInCurrentRow + actualSpan > availableColumns) {
// Start new row - find which columns are available
currentRow++;
columnsUsedInCurrentRow = 0;
// Find shortest column and skip any columns taller than (shortest + gap)
const minHeight = Math.min(...estimatedColumnHeights);
const threshold = minHeight + rowGap;
currentRowStartColumn = 0;
for (let i = 0; i < numColumns; i++) {
if (estimatedColumnHeights[i] <= threshold) {
currentRowStartColumn = i;
break;
}
}
}
const columnStart = currentRowStartColumn + columnsUsedInCurrentRow;
itemPlacements.push({
child: child,
row: currentRow,
span: actualSpan,
index: index,
columnStart: columnStart
});
// Update estimated column heights for placement decisions
const needsGap = estimatedColumnHeights[columnStart] > 0;
const gap = needsGap ? rowGap : 0;
let maxHeightInSpan = 0;
for (let i = columnStart; i < columnStart + actualSpan; i++) {
maxHeightInSpan = Math.max(maxHeightInSpan, estimatedColumnHeights[i]);
}
const newHeight = maxHeightInSpan + estimatedHeight + gap;
for (let i = columnStart; i < columnStart + actualSpan; i++) {
estimatedColumnHeights[i] = newHeight;
}
columnsUsedInCurrentRow += actualSpan;
});
// Group items by their logical row
const rows = [];
itemPlacements.forEach(placement => {
rows[placement.row] ??= [];
rows[placement.row].push(placement);
});
// PHASE 2: Position items vertically using negative margins
const columnHeights = new Array(numColumns).fill(0);
const gridTop = this.grid.getBoundingClientRect().top;
rows.forEach((row) => {
const rowItems = [];
row.forEach((placement) => {
const child = placement.child;
const actualSpan = placement.span;
const targetColumnIndex = placement.columnStart;
// Determine if we need gap based on column content
const needsGap = columnHeights[targetColumnIndex] > 0;
const gap = needsGap ? rowGap : 0;
// Set the column position (with span if needed)
if (actualSpan > 1) {
child.style.gridColumn = `${targetColumnIndex + 1} / span ${actualSpan}`;
} else {
child.style.gridColumnStart = `${targetColumnIndex + 1}`;
}
// NOW read position AFTER setting grid column (critical for correct positioning)
const childStyle = window.getComputedStyle(child);
const childMarginTop = parseFloat(childStyle.marginTop) || 0;
const childMarginBottom = parseFloat(childStyle.marginBottom) || 0;
// Get the max height of all columns this item will span
let maxColumnHeight = 0;
for (let i = targetColumnIndex; i < targetColumnIndex + actualSpan; i++) {
maxColumnHeight = Math.max(maxColumnHeight, columnHeights[i]);
}
// Calculate margin-top to position item at the correct vertical position
const { top, height: childHeight } = child.getBoundingClientRect();
const offsetTop =
top -
childMarginTop -
parentPaddingTop -
gridTop;
const marginTop = Math.round(
maxColumnHeight - offsetTop + childMarginTop + gap
);
child.style.marginTop = `${marginTop}px`;
// Round heights to prevent pixel drift accumulation
const heightToAdd = Math.round(childMarginTop + childHeight + childMarginBottom + gap);
// Store item info for min-height sync
rowItems.push({
child,
targetColumnIndex,
actualSpan,
childHeight: Math.round(childHeight),
childMarginTop,
childMarginBottom,
gap,
maxColumnHeight
});
});
// Apply min-height only to items within 5px of each other to handle rounding errors
const heights = rowItems.map(item => item.childHeight);
const sortedHeights = [...heights].sort((a, b) => a - b);
const heightThreshold = 5;
// Group items by similar heights
const heightGroups = [];
let currentGroup = [sortedHeights[0]];
for (let i = 1; i < sortedHeights.length; i++) {
if (sortedHeights[i] - sortedHeights[i - 1] <= heightThreshold) {
currentGroup.push(sortedHeights[i]);
} else {
heightGroups.push(currentGroup);
currentGroup = [sortedHeights[i]];
}
}
heightGroups.push(currentGroup);
// Set min-height for each group and update column heights using the FORCED height
const heightGroupMap = new Map();
heightGroups.forEach((group) => {
const groupMax = Math.max(...group);
group.forEach(height => heightGroupMap.set(height, groupMax));
});
rowItems.forEach(({ child, childHeight, targetColumnIndex, actualSpan, childMarginTop, childMarginBottom, gap, maxColumnHeight }) => {
const minHeight = heightGroupMap.get(childHeight);
child.style.minHeight = `${minHeight}px`;
// CRITICAL: Update column heights using the FORCED minHeight, not the actual height
// This prevents sub-pixel drift from accumulating across rows
const heightToAdd = Math.round(childMarginTop + minHeight + childMarginBottom + gap);
const newHeight = Math.round(maxColumnHeight + heightToAdd);
// Update all columns that this item spans
for (let i = targetColumnIndex; i < targetColumnIndex + actualSpan; i++) {
columnHeights[i] = newHeight;
}
});
});
};
destroy() {
this.clearStyles();
this.resizeObserver.disconnect();
this.mutationObserver.disconnect();
this.spanCache = null;
}
}
const styles = `
/**
* SpanAwareMasonry (SAM) - A span-aware masonry-style grid layout
*
* Unlike native CSS Masonry (grid-template-rows: masonry), which places items in the shortest
* available column regardless of DOM order, SAM maintains left-to-right grid auto-placement
* while applying masonry-style vertical stacking with negative margins.
*
* Key differences from spec-compliant masonry:
* - Respects grid column spans (span-2, span-3, etc.) when determining row breaks
* - Maintains sequential left-to-right placement within logical rows
* - Skips columns that are too tall (> gap distance from shortest) when starting new rows
* - Synchronizes heights of similar items (within 5px) to prevent pixel drift
*
* This creates a "packed grid" effect that combines the benefits of predictable grid
* auto-placement with space-efficient vertical compression.
*/
.masonry,
.span-aware-masonry,
.sam {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-sm);
padding: 0;
> * {
padding: var(--spacing-med);
background-color: var(--color-card-bg);
color: var(--color-card-text);
border: 1px solid var(--color-card-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
box-sizing: border-box;
grid-column: auto; /* Default placement */
height: fit-content;
/* Force masonry items to align-self: start regardless of utility classes */
align-self: start !important;
/* Allow children to use teh alignment utilities */
&.vert-top, &.vert-center, &.vert-bottom {
display: flex;
flex-direction: column;
}
&.vert-top { justify-content: flex-start; }
&.vert-center { justify-content: center; }
&.vert-bottom { justify-content: flex-end; }
&.plain {
background-color: transparent;
border: 1px solid transparent; /* maintain layout */
box-shadow: none;
}
/* START: Style masonry cards like alerts */
&.success {
background-color: var(--color-alert-success-bg);
border-color: var(--color-alert-success-border);
color: var(--color-alert-success-text);
a {
color: var(--color-link);
text-decoration: underline;
&:hover {
color: var(--color-link-hover);
}
}
}
&.warn,
&.warning {
background-color: var(--color-alert-warning-bg);
border-color: var(--color-alert-warning-border);
color: var(--color-alert-warning-text);
a {
color: var(--color-link);
text-decoration: underline;
&:hover {
color: var(--color-link-hover);
}
}
}
&.danger,
&.error {
background-color: var(--color-alert-error-bg);
border-color: var(--color-alert-error-border);
color: var(--color-alert-error-text);
a {
color: var(--color-link);
text-decoration: underline;
&:hover {
color: var(--color-link-hover);
}
}
}
&.notice,
&.info {
background-color: var(--color-alert-info-bg);
border-color: var(--color-alert-info-border);
color: var(--color-alert-info-text);
a {
color: var(--color-link);
text-decoration: underline;
&:hover {
color: var(--color-link-hover);
}
}
}
&.message,
&.primary {
background-color: var(--color-alert-message-bg);
border-color: var(--color-alert-message-border);
color: var(--color-alert-message-text);
a {
color: var(--color-link);
text-decoration: underline;
&:hover {
color: var(--color-link-hover);
}
}
}
/* END: Style masonry cards like alerts */
}
/* span-all should always stretch across whatever grid exists */
> *.span-all {
grid-column: 1 / -1;
}
/* --- 2 columns --- */
@media (min-width: 480px) {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-med);
padding: var(--spacing-sm);
> *.span-2 { grid-column: span 2; }
}
/* --- 3 columns --- */
@media (min-width: 720px) {
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-lg);
> *.span-2 { grid-column: span 2; }
> *.span-3 { grid-column: span 3; }
}
/* --- 4 columns --- */
@media (min-width: 1080px) {
grid-template-columns: repeat(4, 1fr);
> *.span-2 { grid-column: span 2; }
> *.span-3 { grid-column: span 3; }
> *.span-4 { grid-column: span 4; }
}
}
`;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment