Skip to content

Instantly share code, notes, and snippets.

@nickyonge
Last active March 16, 2026 06:20
Show Gist options
  • Select an option

  • Save nickyonge/73d236cac779c98d7a078bccdd4055e4 to your computer and use it in GitHub Desktop.

Select an option

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