Skip to content

Instantly share code, notes, and snippets.

@chrishow
Created June 22, 2026 07:12
Show Gist options
  • Select an option

  • Save chrishow/7f08064a3b338b41e574d1c50a3a5788 to your computer and use it in GitHub Desktop.

Select an option

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.
/*
* 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