Calculate Number of Characters Fit into an HTML Element

Published on
Updated on
10 min read
dynamically truncate text cover

If you need to know the number of characters that can fit into an HTML element, where the width of the target HTML element and the text are not static, then this post is for you. To solve this problem, I am going to talk about custom react hooks, and a little bit about the Web API. And eventually, I am going to show you that a frontend developer needs some algorithm and problem solving skills to solve pure frontend problems.

Introduction

At work, designers ask me to shorten the button texts with three dots from the middle. Like below:

truncate from middle

Truncating 'text text text' in different container widths

The challenge is that the width of the buttons were not fixed, and the text inside of them were not fixed as well. In short, I had to do the following:

  • calculating the width of the button,
  • then calculating the width of the button's text,
  • then compare that if the width of the text is larger than the width of the button, then cut the text in the middle with three dots... But also I needed to find how to cut, I mean how many characters should be shown? Let's solve this together.

Final Code and Demo

You can find the full code and a demo for the subject of this blog, dynamically calculating fit character length, from this repository

Width of Button

Since the width of the button is dynamic, and itself loads asynchronously; we cannot calculate width by using the componentDidMount or useEffect hook. Instead we need to use the useLayoutEffect hook due to the asynchronous UI effect.
And to follow the size changes of an element, I used ResizeObserver which is a Web API Interface that is available in most browsers.
There is another way to watch size changes of an element that is addEventListener('resize',()=>{}) approach. However, I suggest using ResizeObserver instead, because it is more reliable since it is easier to disconnect the observer and to stop the listening callbacks. Actually, I suggest you do your own research about when to use ResizeObserver over Resize Event Listener, you can start from reading comments in this issue.

import { useLayoutEffect, useState } from 'react'
/**
* Returns current width of specified element.
*
* @param {Ref} ref element to use in width calculation
*/
const useElementWidth = (ref) => {
const [width, setWidth] = useState(0)
const elObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentBoxSize) {
setWidth(entry.contentRect.width)
}
}
})
useLayoutEffect(() => {
if (!ref?.current) return
elObserver.observe(ref.current)
return () => {
elObserver.disconnect()
}
}, [])
return width
}
export default useElementWidth

Width of Text

Normally, we are able to know the length of text, but there is no such direct information about the width of a text. To calculate the width of a text, we need to use Web API's TextMetrics interface. This interface gives us a read-only width value of a text, based on the font information of it. You may guess that a text with bold font and with large font size has larger width than the small font size. However, this interface calculates the width of text, that is filled in canvas element only. But our element is button. For this, what we are going to here is, copy the font style of our button text, with window.getComputedStyle method and fill canvas with this text and then calculate its width. And with this approach we will be able to know whether the width of text is larger than button's content width, and if it is larger, it should be truncated.

By the way, the case could be different, I mean it is not just about truncating the text as calculating its width, it is about the calculating the number of characters that can fit the given button element, truncating from middle is just a case from my work and used as an example. Next, we are going to write a custom hook which gives the number of characters fit into an element based on width calculation.

Calculating the Number of Characters

Now we are able to calculate the width of button, and calculate the width of text. Here we are going to create a for loop, which is within the length of button's text, we are going to get a character in each iteration from button's text, and fill it in a canvas, with its font style as using window.computedStyle(), and then calculate the text width. If it is larger than button's content width, then break the loop and return the number of characters that fit, that is the value of previous iteration's index.

import { useMemo } from 'react'
/**
* Calculates the maximum number of characters that can be fit into give maxwidth.
* Calculates the width of text with given font family via ref object, in terms of px
*
* @param {Ref} ref
* @param {number} maxWidth
* @param {string} middleChars i.e. '...' ellipsis
* @return number of chars of ref's text, including number of chars of given/default
* returned value is an odd number, first part has +1 characters
*/
// create canvas element, and get its content
const getContext = () => {
const fragment = document.createDocumentFragment()
const canvas = document.createElement('canvas')
fragment.appendChild(canvas)
return canvas.getContext('2d')
}
const useFitCharacterNumber = (options) => {
const { ref, maxWidth, middleChars } = options
return useMemo(() => {
if (ref.current?.textContent && maxWidth) {
const context = getContext()
const computedStyles = window.getComputedStyle(ref.current)
context.font = computedStyles.font
? computedStyles.font
: `${computedStyles.fontSize}" "${computedStyles.fontFamily}`
const textWidth = context.measureText(ref.current.textContent).width // width of text
let fitLength = ref.current.textContent.length
let prefix = '' // char from start
let suffix = '' // char from end
let i = 0
let j = fitLength - 1
let current = middleChars || '' // i.e. '...'
let prev = current
while (i < j) {
prefix = prefix + ref.current.textContent.charAt(i)
current = prefix + middleChars + suffix
if (context.measureText(current).width > maxWidth) {
fitLength = prev.length
break
}
prev = current
suffix = ref.current.textContent.charAt(j) + suffix
current = prefix + middleChars + suffix
if (context.measureText(current).width > maxWidth) {
fitLength = prev.length
break
}
prev = current
i++
j--
}
return { textWidth, charNumber: fitLength }
}
return { textWidth: NaN, charNumber: NaN }
}, [ref, maxWidth, middleChars])
}
export default useFitCharacterNumber

As you see above at the lines 39 and 46, we get one character from start and calculate the text width, and get one character from back then again calculate the text width as comparing the current text width with contianer's content width.

Why we didn't just pick one character from the start, and continue with the loop? Because the purpose here is truncating the text from middle, and since some of characters occupies different width, we needed to pick character from end as well to get more accurate number of characters that fit into button element.

So the solution can be even number and odd number, if it is odd number, we have one extra character from the beginning. For example suppose our long text is: 'longer button text', and button width is 128px, solution could be :'long...text' or it could be 'longe...text'.

Conclusion

In this blog, I would like to share how you can solve truncating text from middle design problem as calculating the maximum number of characters that can fit into a given container element's content width. We created custom react hook to make our solution reusable across the application. I hope this hook could help you in your work and inspire you to develop your custom hook development skill, as well as your problem solving skill😃... Thank you for reading.