Created
June 22, 2026 07:12
-
-
Save chrishow/7f08064a3b338b41e574d1c50a3a5788 to your computer and use it in GitHub Desktop.
Match heights of selected elements. Port of liabru/jquery-match-height to vanilla js.
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
| /* | |
| * matchHeight (vanilla) | |
| * Ported from jquery-match-height concepts https://github.com/liabru/jquery-match-height | |
| * License: MIT | |
| * | |
| * Quick start | |
| * ----------- | |
| * Include this file, then call matchHeight after the DOM is ready. | |
| * | |
| * Basic usage | |
| * ----------- | |
| * matchHeight('.card'); | |
| * matchHeight('.card', { byRow: true }); | |
| * matchHeight('.card', { property: 'min-height' }); | |
| * | |
| * Target usage | |
| * ------------ | |
| * matchHeight('.card', { target: '.card--featured' }); | |
| * matchHeight('.card', { target: document.querySelector('.card--featured') }); | |
| * | |
| * Remove usage | |
| * ------------ | |
| * matchHeight('.card', { remove: true }); | |
| * matchHeight.remove('.card'); | |
| * | |
| * Input types | |
| * ----------- | |
| * The first argument can be: | |
| * - CSS selector string | |
| * - Element | |
| * - NodeList / HTMLCollection | |
| * - Array of elements | |
| * | |
| * Options | |
| * ------- | |
| * - byRow: true|false (default: true) | |
| * - property: CSS property to set (default: 'height') | |
| * - target: selector or element to use as reference height | |
| * - remove: true to clear and ungroup matched elements | |
| * - onBeforeApply(elements, options): callback before a group apply | |
| * - onAfterApply(elements, options): callback after a group apply | |
| * | |
| * Global controls | |
| * --------------- | |
| * - matchHeight._update() // force refresh all groups | |
| * - matchHeight._update(true) // throttled refresh | |
| * - matchHeight._throttle = 80 // throttle ms for resize updates | |
| * - matchHeight._maintainScroll = true // preserve relative scroll on updates | |
| * - matchHeight._beforeUpdate = fn // global callback before all groups | |
| * - matchHeight._afterUpdate = fn // global callback after all groups | |
| * | |
| * Data API | |
| * -------- | |
| * Elements with matching values for either attribute are grouped automatically: | |
| * - data-match-height="group-a" | |
| * - data-mh="group-a" | |
| * | |
| * Notes | |
| * ----- | |
| * - Auto-updates run on load, resize, orientationchange, and media load events. | |
| * - Hidden parent containers are handled during measurement. | |
| */ | |
| // TL;DR: matchHeight('.card', { byRow: true, property: 'height' }); | |
| (function (root, factory) { | |
| 'use strict'; | |
| if (typeof define === 'function' && define.amd) { | |
| define([], factory); | |
| } else if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = factory(); | |
| } else { | |
| root.matchHeight = factory(); | |
| } | |
| }(typeof globalThis !== 'undefined' ? globalThis : this, function () { | |
| 'use strict'; | |
| var hasDOM = typeof window !== 'undefined' && typeof document !== 'undefined'; | |
| var previousResizeWidth = -1; | |
| var updateTimeout = -1; | |
| function parse(value) { | |
| return parseFloat(value) || 0; | |
| } | |
| function toArray(elements) { | |
| if (!hasDOM) { | |
| return []; | |
| } | |
| if (!elements) { | |
| return []; | |
| } | |
| if (typeof elements === 'string') { | |
| return Array.prototype.slice.call(document.querySelectorAll(elements)); | |
| } | |
| if (elements instanceof Element) { | |
| return [elements]; | |
| } | |
| if (Array.isArray(elements)) { | |
| return elements.filter(function (item) { | |
| return item instanceof Element; | |
| }); | |
| } | |
| if (typeof NodeList !== 'undefined' && elements instanceof NodeList) { | |
| return Array.prototype.slice.call(elements); | |
| } | |
| if (typeof HTMLCollection !== 'undefined' && elements instanceof HTMLCollection) { | |
| return Array.prototype.slice.call(elements); | |
| } | |
| return []; | |
| } | |
| function parseOptions(options) { | |
| var opts = { | |
| byRow: true, | |
| property: 'height', | |
| target: null, | |
| remove: false, | |
| onBeforeApply: null, | |
| onAfterApply: null | |
| }; | |
| if (typeof options === 'object' && options !== null) { | |
| var merged = {}; | |
| var key; | |
| for (key in opts) { | |
| if (Object.prototype.hasOwnProperty.call(opts, key)) { | |
| merged[key] = opts[key]; | |
| } | |
| } | |
| for (key in options) { | |
| if (Object.prototype.hasOwnProperty.call(options, key)) { | |
| merged[key] = options[key]; | |
| } | |
| } | |
| return merged; | |
| } | |
| if (typeof options === 'boolean') { | |
| opts.byRow = options; | |
| } else if (options === 'remove') { | |
| opts.remove = true; | |
| } | |
| return opts; | |
| } | |
| function getStyleCache(element) { | |
| return element.getAttribute('style'); | |
| } | |
| function restoreStyleCache(element, cacheValue) { | |
| if (cacheValue === null) { | |
| element.removeAttribute('style'); | |
| return; | |
| } | |
| element.setAttribute('style', cacheValue); | |
| } | |
| function getRows(elements) { | |
| if (!hasDOM) { | |
| return [toArray(elements)]; | |
| } | |
| var tolerance = 1; | |
| var lastTop = null; | |
| var rows = []; | |
| elements.forEach(function (element) { | |
| var computed = window.getComputedStyle(element); | |
| var marginTop = parse(computed.marginTop); | |
| var rect = element.getBoundingClientRect(); | |
| var top = rect.top + window.pageYOffset - marginTop; | |
| var lastRow = rows.length > 0 ? rows[rows.length - 1] : null; | |
| if (lastRow === null) { | |
| rows.push([element]); | |
| } else { | |
| if (Math.floor(Math.abs(lastTop - top)) <= tolerance) { | |
| lastRow.push(element); | |
| } else { | |
| rows.push([element]); | |
| } | |
| } | |
| lastTop = top; | |
| }); | |
| return rows; | |
| } | |
| function outerHeight(element) { | |
| if (!hasDOM) { | |
| return 0; | |
| } | |
| return element.getBoundingClientRect().height; | |
| } | |
| function normalizeTarget(target) { | |
| if (!hasDOM) { | |
| return null; | |
| } | |
| if (!target) { | |
| return null; | |
| } | |
| if (target instanceof Element) { | |
| return target; | |
| } | |
| if (typeof target === 'string') { | |
| return document.querySelector(target); | |
| } | |
| var targetList = toArray(target); | |
| return targetList.length > 0 ? targetList[0] : null; | |
| } | |
| function usableDisplay(display) { | |
| if (display === 'inline-block' || display === 'flex' || display === 'inline-flex') { | |
| return display; | |
| } | |
| return 'block'; | |
| } | |
| function clearProperty(element, property) { | |
| element.style[property] = ''; | |
| } | |
| function setProperty(element, property, value) { | |
| element.style[property] = value; | |
| } | |
| function apply(elements, options) { | |
| if (!hasDOM) { | |
| return; | |
| } | |
| var opts = parseOptions(options); | |
| var itemList = toArray(elements); | |
| var rows = [itemList]; | |
| if (!itemList.length) { | |
| return; | |
| } | |
| var scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; | |
| var docElement = document.documentElement; | |
| var htmlHeight = Math.max(docElement.scrollHeight, docElement.offsetHeight, docElement.clientHeight); | |
| var hiddenParents = []; | |
| itemList.forEach(function (item) { | |
| var parent = item.parentElement; | |
| while (parent && parent !== document.documentElement) { | |
| if (window.getComputedStyle(parent).display === 'none' && hiddenParents.indexOf(parent) === -1) { | |
| hiddenParents.push(parent); | |
| } | |
| parent = parent.parentElement; | |
| } | |
| }); | |
| var hiddenParentStyles = new Map(); | |
| hiddenParents.forEach(function (parent) { | |
| hiddenParentStyles.set(parent, getStyleCache(parent)); | |
| parent.style.display = 'block'; | |
| }); | |
| if (opts.byRow && !opts.target) { | |
| var itemStyles = new Map(); | |
| itemList.forEach(function (item) { | |
| var computed = window.getComputedStyle(item); | |
| itemStyles.set(item, getStyleCache(item)); | |
| item.style.display = usableDisplay(computed.display); | |
| item.style.paddingTop = '0'; | |
| item.style.paddingBottom = '0'; | |
| item.style.marginTop = '0'; | |
| item.style.marginBottom = '0'; | |
| item.style.borderTopWidth = '0'; | |
| item.style.borderBottomWidth = '0'; | |
| item.style.height = '100px'; | |
| item.style.overflow = 'hidden'; | |
| }); | |
| rows = getRows(itemList); | |
| itemList.forEach(function (item) { | |
| restoreStyleCache(item, itemStyles.get(item)); | |
| }); | |
| } | |
| var targetElement = normalizeTarget(opts.target); | |
| rows.forEach(function (row) { | |
| var targetHeight = 0; | |
| if (!targetElement) { | |
| if (opts.byRow && row.length <= 1) { | |
| row.forEach(function (item) { | |
| clearProperty(item, opts.property); | |
| }); | |
| return; | |
| } | |
| row.forEach(function (item) { | |
| var cachedStyle = getStyleCache(item); | |
| var display = usableDisplay(window.getComputedStyle(item).display); | |
| item.style.display = display; | |
| clearProperty(item, opts.property); | |
| var currentHeight = outerHeight(item); | |
| if (currentHeight > targetHeight) { | |
| targetHeight = currentHeight; | |
| } | |
| restoreStyleCache(item, cachedStyle); | |
| }); | |
| } else { | |
| targetHeight = outerHeight(targetElement); | |
| } | |
| row.forEach(function (item) { | |
| if (targetElement && item === targetElement) { | |
| return; | |
| } | |
| var computed = window.getComputedStyle(item); | |
| var verticalPadding = 0; | |
| if (computed.boxSizing !== 'border-box') { | |
| verticalPadding += parse(computed.borderTopWidth) + parse(computed.borderBottomWidth); | |
| verticalPadding += parse(computed.paddingTop) + parse(computed.paddingBottom); | |
| } | |
| var value = Math.max(0, targetHeight - verticalPadding); | |
| setProperty(item, opts.property, value + 'px'); | |
| }); | |
| }); | |
| hiddenParents.forEach(function (parent) { | |
| restoreStyleCache(parent, hiddenParentStyles.get(parent)); | |
| }); | |
| if (matchHeight._maintainScroll) { | |
| var newHtmlHeight = Math.max(docElement.scrollHeight, docElement.offsetHeight, docElement.clientHeight); | |
| if (htmlHeight > 0) { | |
| window.scrollTo(window.pageXOffset || 0, (scrollTop / htmlHeight) * newHtmlHeight); | |
| } | |
| } | |
| if (typeof opts.onAfterApply === 'function') { | |
| opts.onAfterApply(itemList, opts); | |
| } | |
| } | |
| function updateAll(event) { | |
| if (typeof matchHeight._beforeUpdate === 'function') { | |
| matchHeight._beforeUpdate(event, matchHeight._groups); | |
| } | |
| matchHeight._groups.forEach(function (group) { | |
| if (typeof group.options.onBeforeApply === 'function') { | |
| group.options.onBeforeApply(group.elements, group.options); | |
| } | |
| apply(group.elements, group.options); | |
| }); | |
| if (typeof matchHeight._afterUpdate === 'function') { | |
| matchHeight._afterUpdate(event, matchHeight._groups); | |
| } | |
| } | |
| function update(throttle, event) { | |
| if (!hasDOM) { | |
| return; | |
| } | |
| if (event && event.type === 'resize') { | |
| var windowWidth = window.innerWidth || document.documentElement.clientWidth; | |
| if (windowWidth === previousResizeWidth) { | |
| return; | |
| } | |
| previousResizeWidth = windowWidth; | |
| } | |
| if (!throttle) { | |
| updateAll(event); | |
| return; | |
| } | |
| if (updateTimeout === -1) { | |
| updateTimeout = window.setTimeout(function () { | |
| updateAll(event); | |
| updateTimeout = -1; | |
| }, matchHeight._throttle); | |
| } | |
| } | |
| function removeElements(elements, options) { | |
| var opts = parseOptions(options); | |
| var itemList = toArray(elements); | |
| itemList.forEach(function (item) { | |
| clearProperty(item, opts.property); | |
| }); | |
| matchHeight._groups.forEach(function (group) { | |
| group.elements = group.elements.filter(function (item) { | |
| return itemList.indexOf(item) === -1; | |
| }); | |
| }); | |
| matchHeight._groups = matchHeight._groups.filter(function (group) { | |
| return group.elements.length > 0; | |
| }); | |
| } | |
| function runDataApi() { | |
| if (!hasDOM) { | |
| return; | |
| } | |
| var groups = new Map(); | |
| var elements = document.querySelectorAll('[data-match-height], [data-mh]'); | |
| Array.prototype.forEach.call(elements, function (element) { | |
| var groupId = element.getAttribute('data-mh') || element.getAttribute('data-match-height'); | |
| if (!groupId) { | |
| return; | |
| } | |
| if (!groups.has(groupId)) { | |
| groups.set(groupId, []); | |
| } | |
| groups.get(groupId).push(element); | |
| }); | |
| groups.forEach(function (groupElements) { | |
| matchHeight(groupElements, true); | |
| }); | |
| } | |
| function matchHeight(elements, options) { | |
| var opts = parseOptions(options); | |
| var itemList = toArray(elements); | |
| if (opts.remove) { | |
| removeElements(itemList, opts); | |
| return itemList; | |
| } | |
| if (itemList.length <= 1 && !opts.target) { | |
| return itemList; | |
| } | |
| matchHeight._groups.push({ | |
| elements: itemList, | |
| options: opts | |
| }); | |
| if (typeof opts.onBeforeApply === 'function') { | |
| opts.onBeforeApply(itemList, opts); | |
| } | |
| apply(itemList, opts); | |
| return itemList; | |
| } | |
| matchHeight.version = '1.0.0-vanilla'; | |
| matchHeight._groups = []; | |
| matchHeight._throttle = 80; | |
| matchHeight._maintainScroll = false; | |
| matchHeight._beforeUpdate = null; | |
| matchHeight._afterUpdate = null; | |
| matchHeight._rows = getRows; | |
| matchHeight._parse = parse; | |
| matchHeight._parseOptions = parseOptions; | |
| matchHeight._apply = apply; | |
| matchHeight._applyDataApi = runDataApi; | |
| matchHeight._update = update; | |
| matchHeight.remove = function (elements, options) { | |
| removeElements(elements, options); | |
| }; | |
| if (hasDOM) { | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', runDataApi); | |
| } else { | |
| runDataApi(); | |
| } | |
| window.addEventListener('load', function (event) { | |
| update(false, event); | |
| }); | |
| window.addEventListener('resize', function (event) { | |
| update(true, event); | |
| }); | |
| window.addEventListener('orientationchange', function (event) { | |
| update(true, event); | |
| }); | |
| // Listen in capture phase so media load events that do not bubble still trigger updates. | |
| document.addEventListener('load', function (event) { | |
| var target = event.target; | |
| if (!target || !(target instanceof Element)) { | |
| return; | |
| } | |
| var tagName = target.tagName; | |
| if (tagName === 'IMG' || tagName === 'VIDEO' || tagName === 'IFRAME') { | |
| update(true, event); | |
| } | |
| }, true); | |
| } | |
| return matchHeight; | |
| })); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment