When creating my somewhat quirky portfolio site I came across a challenge to make short bits of text resize based on their container size. Although I had just completed a course in React development I wanted to work with Javascript to further my regained comfort with the language. I know lot's of people get frustrated by it's lack of strict typing and all of it's quirks but I always rather enjoyed it and wanted to be creative, which is where it really excels for me.
The project also gave me the opportunity to work further with CSS and catch up with some of the developments that had become available in the many years since I last developed for the web. It seemed that using container queries and the new (to me) container units along with some tweaked clamp() definitions would get me close to achieving the results I wanted, and although it worked for many situations it was a bit of a hack and was not fully responsive. The problem was that I wanted to have strings that were of variable lengths wrapping and resizing to fit the container perfectly and there are only so many variables that I could cater for with this approach.
I decided, when I went back to add another page for the project I had adapted from a Node.js course, that I would cast a fresh eye over my structure and implementations particularly the way I animated the elements but also finally tackle these text areas (called floaterTitles btw). At the time of developing it originally I had thought that I might have to create a function that would take the size of the container and the string I gave it and go through some kind of loop to resize the font until it fitted and that is kinda what I ended up exploring.
The first attempts simply checked the offset sizes of the container and compared them to the size of the text area itself but with, as I understand, quirks and races between the css engine and the layout rendering engine it became clear that I wanted to be working with the raw font metrics. I was achieving almost the correct behaviour but there was a lack of consistency, sometimes the same text would be made much smaller than it needed whilst at other times it would be causing overflow because it was too large. As I am working alone to try to transition my career back to web-development the only guide or mentor I have to ask questions is free to use AI and so I had a long winded conversation with Gemini (plenty of "You're absolutely right, that won't work" moments). It offered a lot of suggestions that were simply what I had tried already or code with obvious problems until it mentioned the font metrics available via the 2D Canvas Context - brilliant, no more battling the layout just use the rendering engine directly to do vector maths.
I'll just share the code as it has lots of comments which I add to remind me of what I've learnt and what's going on. Just to say, there's a lot of hardcoded variables but it would be very simple to add them as arguments, maybe even as an options object that could be passed in, but I didn't need to extend it in this way for my project. Also, be aware that this is part of a larger sub-class and so this.contentHolder is simply an H1 element that is created in the constructor and has this simple CSS styling -
.content-title {
/* set a baseline size */
font-size: 1rem;
text-wrap: balance;
font-weight: 400;
width: 100%;
word-spacing: -0.3rem;
line-height: 1.2;
text-align: center;
}
And so I ended up with this (which works not only on load but also gets triggered by a throttled resize event listener) -
//this function creates an off-screen canvas that works out the largest font possible for the length of the string vs the size of the container. By means of noting our learnings here's the final sign-off paragraph from Gemini
//The Canvas-driven approach is a fantastic tool to keep in your back pocket. Whenever you find yourself battling a browser layout engine that is aggressively caching values, dropping frame updates, or thrashing the CPU mid-transition, shifting those checks into a completely off-screen, data-driven vector space like Canvas will save you every single time. It treats text like raw coordinates rather than a messy, slow DOM tree.
#fitTextToContainer(targetWidth, targetHeight, paddingAsPercent = true) {
if (!this.contentHolder || !targetWidth || !targetHeight) return;
const minFont = 10; // 1rem minimum safety gate
const maxFont = 44; // 4.4rem maximum limit
// Add in padding as an option
const visualPadding = 12; // Internal padding safety clearance
const visualPaddingPercent = 7; // allow for padding to be defined as a percentage
// Do we want this to be the total padding? Namely if we enter a percentage do we expect it to be this much all around or do we expect it to be the amount of padding overall? I'll plump for the first option...
const visualPaddingAsPercentWidth =
(targetWidth / 100) * visualPaddingPercent;
const visualPaddingAsPercentHeight =
(targetHeight / 100) * visualPaddingPercent;
// if no padding is defined then simply use the whole area
let maxAllowedWidth = targetWidth;
let maxAllowedHeight = targetHeight;
if (paddingAsPercent && visualPaddingPercent > 0) {
maxAllowedWidth = targetWidth - visualPaddingAsPercentWidth * 2;
maxAllowedHeight = targetHeight - visualPaddingAsPercentHeight * 2;
} else if (visualPadding > 0) {
maxAllowedWidth = targetWidth - visualPadding * 2;
maxAllowedHeight = targetHeight - visualPadding * 2;
}
// Set up the wrapping behaviour - remember this sub-class is specifically for titles and short bits of text
const maxLinesPossible = 8; // if the floater is narrow and tall it can have this many lines
const singleLineHeightLock = 60; // the height at which single line is the maxLinesPossible
const maxLinesRatio = 1; // height / width ratio for defining as narrow and tall - anything taller than a square seems to work best
// Start with a default for if it's roughly a square or banner
let maxLinesAllowed = 5;
// check if it's narrower and taller or it's definitely only tall enough for one line
if (maxAllowedHeight / maxAllowedWidth > maxLinesRatio) {
maxLinesAllowed = maxLinesPossible; // Tall boxes get extra lines
} else if (maxAllowedHeight <= singleLineHeightLock) {
maxLinesAllowed = 1; // Ultra-short banners are locked to a single line
}
// Extract raw characters, stripping active link markup tags safely by using textContent rather than the HTML string itself
const rawTextString = this.contentHolder.textContent || '';
// Fetch typography parameters from your stylesheet
const computedStyle = getComputedStyle(this.contentHolder);
// I don't like the line-height being hard-coded as 1.2 so let's get that information here too
const rawLineHeight = computedStyle.lineHeight; //can be "normal", a pixel value, a raw value (like 1.2), or a percentage. Interesting to discover though, percentages and values like rem etc are calculated into explicit pixel values by the browser's css engine so no need to worry about those cases in here
// set a line-height multiplier for use in the while loop
const currentFontSize = parseFloat(computedStyle.fontSize);
let unitlessHeightMultiplier = 1.2; //same as setting line-height to "normal"
if (rawLineHeight.includes('px')) {
// If the browser hands you "24px", turn it into a fluid ratio (e.g., 24 / 16 = 1.5)
unitlessHeightMultiplier = parseFloat(rawLineHeight) / currentFontSize;
} else if (!isNaN(parseFloat(rawLineHeight))) {
// If it's already a raw unitless multiplier (e.g., 1.2), use it directly
unitlessHeightMultiplier = parseFloat(rawLineHeight);
}
const fontFamily = computedStyle.fontFamily || 'sans-serif';
const fontWeight = computedStyle.fontWeight || '400';
const h1WordSpacing = computedStyle.wordSpacing || 'normal';
// Setup offscreen canvas utility
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// variable to be calculated by the sizing loop - start as big as possible and make it only as small as it needs to be
let optimalFontSize = maxFont;
//RUN SIZING LOOP ------------------------------------------
while (optimalFontSize > minFont) {
// to use the measureText function you must define the font first
ctx.font = `${fontWeight} ${optimalFontSize}px ${fontFamily}`;
// then we should add the word-spacing value assigned to h1 in the css
ctx.wordSpacing = h1WordSpacing;
// Calculate single continuous text width metrics
//Unlike DOM properties like offsetWidth or scrollWidth (which tell you the size of the HTML element wrapper box), measureText() asks the operating system's underlying font rendering engine to calculate the exact vector physics of the text characters themselves, right down to the sub-pixel level.
// It executes instantly in memory, completely bypassing the browser’s slow rendering and painting queue.
const textMetrics = ctx.measureText(rawTextString);
// the original typographical metrics available from measureText, although you can now access the height as well by adding together actualBoundingBoxAscent and actualBoundingBoxDescent (the top of an H and bottom of a j for example)
const totalSingleLineWidth = textMetrics.width;
// Determine how many lines this iteration font size forces the string to wrap into
const linesNeeded = Math.ceil(totalSingleLineWidth / maxAllowedWidth);
//dynamic line height based on the multiplier worked out on execution of this function
const totalBlockHeight =
linesNeeded * (optimalFontSize * unitlessHeightMultiplier);
// Does it stay under the width OR wrap comfortably under the line cap limit?
if (totalSingleLineWidth <= maxAllowedWidth) {
// Fits beautifully on 1 line
break;
} else if (
linesNeeded <= maxLinesAllowed &&
totalBlockHeight <= maxAllowedHeight
) {
// Wraps cleanly, and doesn't break your line cap or push fit-content bounds
break;
}
// If it breaks the above constraints, step down 1px and evaluate again
optimalFontSize--;
}
// END SIZING LOOP --------------------------------
// Write the perfect size onto the element in a single step
this.contentHolder.style.fontSize = `${optimalFontSize}px`;
}
As I say, I am just trying to become proficient again so that I can find a way back into a career as a web-developer so any feedback or suggestions are very warmly appreciated but I hope this might be the handy solution someone else is looking for :)
A little bonus learning from the process
I had a lot of strings that were lists of technologies I had used for my projects all separated by forward slashes and with CSS it became clear that there was not a perfect wrap setting that wouldn't just break into the middle of words or keep the entire string on one line. Whilst trying to find a solution I was reminded of the soft-line-break element available in HTML <wbr> which simply suggests to the rendering engine that if it's struggling to find a place to wrap a string then it can do it wherever this appears. During this little journey of discovery I was made aware of the element which was the perfect place to walk through my string as a series of Nodes (so that I could still have links and so on in the content). This functionality could easily be extended to look for any other characters as well but for me I just wanted to deal with those pesky forward slashes. Again I'll just dump the heavily commented code here for you to use/appraise/extend and mention a few things so that the code is understandable - this.src is the HTML passed into this sub-class as a template literal, the main font resizing function has this.contentHolder described, and appendChildToElement is a custom function that is inherited from the base-class that is simply there to do some housework so don't go looking for it on MDN :) In fact, I'll just share the whole constructor function that this handy little recursive function is part of -
constructor(src, ...rest) {
super(...rest);
this.src = src;
//Add an element to hold the content so it can have it's visibilty toggled in float() and reveal()
this.contentHolder = document.createElement('h1');
this.contentHolder.classList.add('content-title');
// as I am writing long strings which include forward slashes we can add in a 'soft word-break' element after them. However, we also have <a> tags inside those strings and we don't want to break them by adding <wbr> eg http:/<wbr>/<wbr> etc
if (
typeof this.src === 'string' &&
(this.src.includes('<a') || this.src.includes('/'))
) {
// Create a safe offscreen fragment container to parse the markup safely
// The <template> element is a specialized HTML tag specifically engineered to hold raw HTML markup without actually rendering it on the screen. It acts as an offscreen blueprint.
//To run your link-safe injectWbrToTextNodes loop, you first need to convert that flat this.src text string into live, interactable JavaScript node objects. That is exactly what the template element achieves, a "safe offscreen sandbox tree".
const template = document.createElement('template');
template.innerHTML = this.src;
//When you read template.content, it doesn't return standard HTML. It returns a specialized object called a DocumentFragment. These do not have a parent and so live outside the webpage structure meaning changes to to their children do not trigger a screen paint. Once these fragments are appended they "pour" their pre-formatted child nodes into the container that they are appended and then "vanish" from memory. If you hear about Web Components then you will likely come across these used alongside the ShadowDOM
const fragment = template.content;
// Traversal function: Walk exclusively through the raw text nodes
const injectWbrToTextNodes = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
// If it's a literal text block, it's safe to process slashes
if (node.nodeValue.includes('/')) {
const spanWrapper = document.createElement('span');
// Safely swap the slash with the slash + break-opportunity tag
spanWrapper.innerHTML = node.nodeValue.replace(/\//g, '/<wbr>');
node.parentNode.replaceChild(spanWrapper, node);
}
} else {
// If it's an element (like an <a> tag), walk into its children
// without touching its raw outer attributes (like href="...")
for (let child of Array.from(node.childNodes)) {
injectWbrToTextNodes(child);
}
}
};
// Process the nodes and append them safely
injectWbrToTextNodes(fragment);
this.contentHolder.appendChild(fragment);
} else {
// Fallback for clean unformatted entries
this.contentHolder.innerHTML = this.src;
}
// Use the custom append function that's inherited
this.appendChildToElement(this.contentHolder);
}
That's it for this little code-tinkering learnings post, I really hope it helps someone who is facing a similar requirement.
All the best for now, Giles.
Top comments (0)