Last active
March 16, 2026 06:20
-
-
Save nickyonge/73d236cac779c98d7a078bccdd4055e4 to your computer and use it in GitHub Desktop.
JS script that matches an element's text to fill the inline width of its containing element
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
| /** TEXT FITTER | |
| * | |
| * A script that automatically matches an element's text | |
| * to perfectly fill the inline width of its containing element. | |
| * | |
| * (At least, perfectly to the glyph level. If a glyph has a lot | |
| * of leading or trailing space, that space will still be respected) | |
| * | |
| * Presented as-is under The Unlicense, see below for license info. | |
| */ | |
| // @ts-check | |
| const defaultCSSFontSizeVariable = '--fit-text-font-size'; | |
| const defaultTextPxReferenceSize = 100; | |
| //TODO: customize fitStyleID and fitStyleName same way as CSS font size var | |
| const fitStyleID = 'fit-text-style'; | |
| const fitStyleName = 'fit'; | |
| /** @type {string[]} */ | |
| let initializedVarNames = []; | |
| /** @type {ResizeObserver} */ | |
| let textFitResizeObserver; | |
| /** @type {MutationObserver} */ | |
| let textFitMutationObserver; | |
| /** @type {Map<Element,function():void>} */ | |
| const resizeObserverUpdateCallbacks = new Map(); | |
| /** @type {Map<Element,function():void>} */ | |
| const mutationObserverUpdateCallbacks = new Map(); | |
| /** @type {Map<Element,function():void>} */ | |
| const elementUpdateCallbacks = new Map(); | |
| const mutationOptions = | |
| { | |
| characterData: true, | |
| childList: true, | |
| subtree: true | |
| }; | |
| /** | |
| * Ensures the CSS style for the given CSS variable name is initialized. | |
| * Returns `true` the *first* time this var is initialzed. | |
| * Otherwise, returns `false`. | |
| * @param {string} cssVarName | |
| * @returns {boolean} | |
| */ | |
| function Initialize(cssVarName) { | |
| if (textFitMutationObserver == null) { textFitMutationObserver = new MutationObserver(MOUpdateCallback); } | |
| if (textFitResizeObserver == null) { textFitResizeObserver = new ResizeObserver(ROUpdateCallback) } | |
| if (initializedVarNames.contains(cssVarName)) { return false; } | |
| const style = document.createElement('style'); | |
| style.id = fitStyleID; | |
| style.textContent = `.${fitStyleName} { | |
| white-space: nowrap; | |
| font-size: var(${cssVarName}); | |
| }`; | |
| document.head.appendChild(style); | |
| initializedVarNames.push(cssVarName); | |
| return true; | |
| } | |
| /** | |
| * Automatically fit text on the given element to the element's inline size. | |
| * @param {Element} element | |
| * Element to apply inline fit to. | |
| * @param {Object} [options] | |
| * Customization options for this inline fit | |
| * @param {number} [options.textPxReferenceSize = defaultTextPxReferenceSize] | |
| * Reference size to use for text pixels. Should be relatively high, | |
| * so that measurements are accurate even at small font sizes. | |
| * Rounded to integer. Default {@linkcode defaultTextPxReferenceSize} | |
| * | |
| * - Remember: `font-size` is not set directly, it's defined by the | |
| * width of the associated element. | |
| * @param {string} [options.cssFontSizeVar = defaultCSSFontSizeVariable] | |
| * Name to use for the CSS variable defining `font-size` on this element. | |
| * If `null` or blank, defaults to {@linkcode defaultCSSFontSizeVariable}. | |
| * That's also the default value. | |
| * @param {boolean} [options.observeResize = true] | |
| * Use a `ResizeObserver` to watch for resizing of the element, and | |
| * automatically update sizing as needed? Default `true` | |
| * @param {boolean} [options.observeTextChange = true] | |
| * Use a `MutationObserver` to watch for text changes or other relevant | |
| * mutations of the element, and automatically update sizing as needed? | |
| * Default `true` | |
| * @param {boolean} [options.listenForIsConnected = true] | |
| * If true, if `element.isConnected` is `false` on calling this method, | |
| * a `requestAnimationFrame` loop will run until it's connected and has | |
| * a `clientWidth` of greater than zero, at which point the `updateFit` | |
| * method will be called again. Default `true` | |
| */ | |
| export function ApplyTextFitter(element, | |
| { | |
| textPxReferenceSize = defaultTextPxReferenceSize, | |
| cssFontSizeVar = defaultCSSFontSizeVariable, | |
| observeResize = true, | |
| observeTextChange = true, | |
| listenForIsConnected = true, | |
| } = {}) { | |
| // ensure valid css variable name | |
| cssFontSizeVar = ValidateCSSVarName(cssFontSizeVar); | |
| // const firstInit = Initialize(cssVarName); | |
| Initialize(cssFontSizeVar); | |
| // id based off of cssVarName | |
| const spanID = cssFontSizeVar.replaceAll('-', ''); | |
| /** | |
| * Either gets an existing element based on the `spanID`, | |
| * or creates a new one, for measuring text pixel size. | |
| * @returns {HTMLElement} | |
| */ | |
| const getMeasuringSpan = () => { | |
| const existingSpan = document.getElementById(spanID); | |
| if (existingSpan != null) { return existingSpan; } | |
| const newSpan = document.createElement('span'); | |
| Object.assign(newSpan.style, { | |
| position: 'fixed', | |
| left: '-99999px', | |
| top: '0', | |
| visibility: 'hidden', | |
| whiteSpace: 'nowrap', | |
| margin: '0', | |
| padding: '0', | |
| border: '0', | |
| }); | |
| newSpan.id = spanID; | |
| document.body.appendChild(newSpan); | |
| return newSpan; | |
| } | |
| const measuringSpan = getMeasuringSpan(); | |
| // element.classList.add(fitStyleName); | |
| element.style?.setProperty('font-size', `var(${cssFontSizeVar})`); | |
| if (!Number.isFinite) { textPxReferenceSize = defaultTextPxReferenceSize; } | |
| if (!Number.isSafeInteger) { textPxReferenceSize = Math.round(textPxReferenceSize); } | |
| let hasUpdatedFit = false; | |
| /** @param {CSSStyleDeclaration} [style] @returns {number} */ | |
| const getElementWidth = (style) => { | |
| if (style == null) { | |
| style = getComputedStyle(element); | |
| } | |
| const clientWidth = element.clientWidth; | |
| const paddingLeft = parseFloat(style.paddingLeft) || 0; | |
| const paddingRight = parseFloat(style.paddingRight) || 0; | |
| return clientWidth - paddingLeft - paddingRight; | |
| } | |
| /** | |
| * Updates the text to fit this element's size. | |
| * Can call manually, but also automatically invoked by | |
| * a `ResizeObserver` on the given element. | |
| */ | |
| const updateFit = () => { | |
| // get default width | |
| const style = getComputedStyle(element); | |
| const width = getElementWidth(style); | |
| if (width <= 0) { return; } | |
| hasUpdatedFit = true; | |
| // assign CSS to measuringSpan | |
| measuringSpan.textContent = element.textContent || ''; | |
| measuringSpan.style.font = style.font; | |
| measuringSpan.style.letterSpacing = style.letterSpacing; | |
| measuringSpan.style.textTransform = style.textTransform; | |
| measuringSpan.style.wordSpacing = style.wordSpacing; | |
| measuringSpan.style.fontSize = `${textPxReferenceSize}px`; | |
| // flush CSS updates | |
| void measuringSpan.offsetHeight; | |
| const measuredRect = measuringSpan.getBoundingClientRect(); | |
| const measuredWidth = measuredRect.width; | |
| if (!measuredWidth) { return; } // ensure not dividing by zero | |
| const targetWidth = (width / measuredWidth) * textPxReferenceSize; | |
| element.style.setProperty(cssFontSizeVar, `${targetWidth}px`); | |
| }; | |
| // watch for resizing | |
| if (observeResize && !resizeObserverUpdateCallbacks.has(element)) { | |
| resizeObserverUpdateCallbacks.set(element, updateFit); | |
| textFitResizeObserver.observe(element); | |
| } | |
| // watch for text changes | |
| if (observeTextChange && !mutationObserverUpdateCallbacks.has(element)) { | |
| mutationObserverUpdateCallbacks.set(element, updateFit); | |
| textFitMutationObserver.observe(element, mutationOptions); | |
| } | |
| /** | |
| * Destroys measuring div and ResizeObserver related to this text fitter. | |
| * | |
| * **Remember to call this if/when destroying the target element. | |
| * @param {boolean} [deInitializeVarName=true] | |
| * Should the {@linkcode cssFontSizeVar} associated with this callback | |
| * be removed from {@linkcode initializedVarNames}? Default `false` | |
| * - **Note:** If this var has been used for multiple text fits, | |
| * this can cause issues re-initializing | |
| */ | |
| const destroy = (deInitializeVarName = false) => { | |
| measuringSpan.remove(); | |
| if (elementUpdateCallbacks.has(element)) { | |
| elementUpdateCallbacks.delete(element); | |
| } | |
| if (resizeObserverUpdateCallbacks.has(element)) { | |
| textFitResizeObserver.unobserve(element); | |
| resizeObserverUpdateCallbacks.delete(element); | |
| } | |
| if (mutationObserverUpdateCallbacks.has(element)) { | |
| const pendingMutations = textFitMutationObserver.takeRecords(); | |
| MOUpdateCallback(pendingMutations); | |
| textFitMutationObserver.disconnect(); | |
| mutationObserverUpdateCallbacks.delete(element); | |
| for (const element of mutationObserverUpdateCallbacks.keys()) { | |
| textFitMutationObserver.observe(element, mutationOptions); | |
| } | |
| } | |
| if (deInitializeVarName) { | |
| const cvnIndex = initializedVarNames.indexOf(cssFontSizeVar); | |
| if (cvnIndex >= 0) { initializedVarNames.splice(cvnIndex, 1); } | |
| } | |
| } | |
| /** loop & wait until `isConnected` then run `updateFit` */ | |
| const updateWhenConnected = () => { | |
| // don't bother running if it's already successfully executed once | |
| if (hasUpdatedFit) { return; } | |
| if (element.isConnected) { | |
| const width = getElementWidth(); | |
| if (width !== 0) { | |
| updateFit(); | |
| return; | |
| } | |
| } | |
| requestAnimationFrame(updateWhenConnected); | |
| } | |
| // general update callbacks | |
| elementUpdateCallbacks.set(element, updateFit); | |
| // intial fit update | |
| if (listenForIsConnected) { | |
| updateWhenConnected(); | |
| } else { | |
| updateFit(); | |
| } | |
| /** @type {{ updateFit: () => void, destroy: () => void }} */ | |
| const textFitFunctions = { | |
| updateFit: updateFit, | |
| destroy: destroy | |
| } | |
| return textFitFunctions; | |
| } | |
| /** @param {ResizeObserverEntry[]} entries */ | |
| function ROUpdateCallback(entries) { | |
| for (const entry of entries) { | |
| const element = entry.target; | |
| if (element != null) { | |
| if (resizeObserverUpdateCallbacks.has(element)) { | |
| _UpdateTextFits(element); | |
| } | |
| } | |
| } | |
| } | |
| /** @param {MutationRecord[]} mutations */ | |
| function MOUpdateCallback(mutations) { | |
| for (const mutation of mutations) { | |
| // get element for mutation based on mutation type | |
| const element = mutation.target instanceof Element ? | |
| mutation.target : mutation.target instanceof Text ? | |
| mutation.target.parentElement : null; | |
| if (element != null && element instanceof Element) { | |
| if (mutationObserverUpdateCallbacks.has(element)) { | |
| _UpdateTextFits(element); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Update the text fitter on the given element, if it has one. | |
| * If {@linkcode element} is `null`, updates the text fitters | |
| * on *every* element that's been given one. | |
| * @param {Element} [element] Element to update. If `null`, | |
| * updates all text fitter-equipped elements. Default `null` | |
| */ | |
| export function UpdateTextFits(element = null) { | |
| _UpdateTextFits(element); | |
| } | |
| /** | |
| * Internal version of {@linkcode UpdateTextFits} | |
| * @param {Element} element | |
| * @param {boolean} nested prevent stack overflow | |
| */ | |
| function _UpdateTextFits(element, nested = false) { | |
| if (element != null) { | |
| if (elementUpdateCallbacks.has(element)) { | |
| const updateFit = elementUpdateCallbacks.get(element); | |
| if (typeof updateFit === 'function') { | |
| updateFit(); | |
| } | |
| } | |
| } else { | |
| if (nested) { return; } | |
| for (const element of elementUpdateCallbacks.keys()) { | |
| if (element != null) { | |
| _UpdateTextFits(element, true); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Ensures the given CSS variable name is valid | |
| * @param {string} cssVarName | |
| * @returns {string} | |
| */ | |
| function ValidateCSSVarName(cssVarName) { | |
| if (typeof cssVarName !== 'string' || | |
| cssVarName.trim() === '') { | |
| cssVarName = defaultCSSFontSizeVariable; | |
| } | |
| cssVarName = cssVarName.trim(); | |
| if (!cssVarName.startsWith('--')) { | |
| // ensure starts with proper hyphening | |
| cssVarName = cssVarName.indexOf('-') === 0 ? | |
| `-${cssVarName}` : `--${cssVarName}`; | |
| } | |
| return cssVarName; | |
| } | |
| /** Hosted on GitHub Gist | |
| * https://gist.github.com/nickyonge/73d236cac779c98d7a078bccdd4055e4 */ | |
| /** | |
| * LICENSE INFO | |
| * | |
| * This script was written by Nick Yonge, 2026, and is released | |
| * under the terms of The Unlicense: | |
| * | |
| * This is free and unencumbered software released into the public domain. | |
| * | |
| * Anyone is free to copy, modify, publish, use, compile, sell, or | |
| * distribute this software, either in source code form or as a compiled | |
| * binary, for any purpose, commercial or non-commercial, and by any | |
| * means. | |
| * | |
| * In jurisdictions that recognize copyright laws, the author or authors | |
| * of this software dedicate any and all copyright interest in the | |
| * software to the public domain. We make this dedication for the benefit | |
| * of the public at large and to the detriment of our heirs and | |
| * successors. We intend this dedication to be an overt act of | |
| * relinquishment in perpetuity of all present and future rights to this | |
| * software under copyright law. | |
| * | |
| * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
| * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. | |
| * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR | |
| * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, | |
| * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR | |
| * OTHER DEALINGS IN THE SOFTWARE. | |
| * | |
| * For more information, please refer to <https://unlicense.org/> | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment