Created
December 19, 2025 07:28
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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