React Textbox with text alignment justified and ResizeObserver API
Although there is a CSS property text-align: justify to generate justified text inside a paragraph, the display of individual lines or text components is not possible with this approach. A React component can close the gap.
The app that implements this functionality generates a multi-line text box inside an enclosing wrapper class with the blocktext
component, whose individual line texts were previously specified in an array (messages
).
Github Repository des Codes
CodePen-Demo
In interaction with browser's ResizeObserverAPI, the font size of the individual line is leveled up and down until the line width is within a specified tolerance range (precisionFrom
and precisionTo
) in relation to the wrapping text box. If the width of the line does not match the width of the text box exactly, the overhanging or missing pixels will be allocated to the individual letters of the line using the CSS property letter-spacing
. In most cases a pixel-proof result will be achieved.
Inside the Blocktext
function component that renders the single line of the text block, the resizeObsever for the text box is activated in the useEffect
hook. This registration of the ResizeObserver takes place only after the first rendering of the component,because an empty array []
is passed in as second parameter.
As soon as the wrapping text box resizes, the callback function stored using useRef
is called. If the counter variable of the state is 0 (as a sign that there is no approximation rendering to the line width at that moment), the line is re-rendered by resetting the state to the initial value. Since the state variable changes, the useLayoutEffect
hook gets started.
Why is useLayoutEffect
used instead of useEffect
at this point? Because with the approximate rendering to the line width of the text container, the DOM is directly manipulated and in such useEffect>
cases the component would be immediately re-rendered, which can cause some flicker effects and should be avoided. This useLayoutEffect
hook is called whenever the state (in our case mainly the font size) and the fontsLoaded
state variable changes.
The fontsLoaded
boolean variable indicates whether all fonts specified in the font-face have been loaded. This value is set to true
inside the useLayout
hook as soon as the document.fonts.ready
promise is resolved. The reason behind this approach is to avoid an approximate rendering as long as the fonts of the website have not been loaded yet. This would lead to undesirable results for obvious reasons, since each font has individual size dimensions.
if (fontsLoaded && !state.finished){
[...]
}
Only if fontsLoaded
is true
, the approximation rendering within the useLayoutEffect
hook takes place at all.
const textItemInitValues = {
text: props.text,
counter: 0,
currentFontSize: 1,
uom: 'rem',
letterSpacing: 'normal',
finished: false
}
[...]
const [state, setState] = useReducer(
(state, newState) => ({...state, ...newState}), textItemInitValues)
Within the variable state
the current state of the line in a state object is stored after the rendering, the update of the value is done by a reducer to avoid an overwriting by accident of an object value at setState
. This procedure is always recommended when updating more complex objects. The textInitValues
constant saves the initial values of the object, which are used for the first rendering and for each resize of the text box.
const getTextItemDimensions = () => {
let [childWidth, parentWidth, diff, sign, precisionValue ] = [null];
if (textItem.current){
childWidth = textItem.current.querySelector(`span`).getBoundingClientRect().width;
parentWidth = textItem.current.parentElement.getBoundingClientRect().width
if (childWidth && parentWidth){
diff = parentWidth - childWidth;
sign = diff >= 0 ? 1 : -1;
precisionValue = childWidth / parentWidth;
}
}
return { diff, sign, precisionValue };
};
// better use useLayoutEffect instead of useEffect as this hook function is changing and detecting the DOM
useLayoutEffect(() => {
const approximateFontSize = ({diff, sign}) => {
let accumulator = 0;
let summe = CALCULATION_MAPPING.current.map(step => {
let subtotal = Math.abs(diff) - accumulator
let result = Math.floor(subtotal / step.divider);
accumulator += result * step.divider;
return result * step.multiplier;
}).reduce((a,b) => a + b);
return summe * sign;
}
if (fontsLoaded && !state.finished){
let {diff, sign, precisionValue} = getTextItemDimensions();
if (diff !== null){
if (precisionValue < CONFIG.current.precisionFrom || precisionValue > CONFIG.current.precisionTo ){
let adjustFontSize = approximateFontSize({diff, sign});
setState({currentFontSize: state.currentFontSize + adjustFontSize});
setState({counter: state.counter + 1})
if (state.counter > CONFIG.current.maximalCount){
setState({ finished: true })
}
} else {
let letterSpacing = `${diff / state.text.length}px`;
setState({ finished: true, letterSpacing })
}
}
}
}, [state, fontsLoaded])
The core functionality of the approximation rendering is implemented within the useLayoutEffect
hook. From the getTextItemDimensions()
function, the current widths of the line element and its parent, the textbox, are obtained using the getBoundingClientRect()
element function.
The ratio of the widths of child and parent gives us a precisionValue
. If this very ratio is not within a tolerance range (in this case the values 0.97 and 1.03 stored in the precisionFrom
and precisionTo
constants) a new value for the font size is determined in the approximateFontSize()
function. The values stored in the helper constant CALCULATION_MAPPING
determine that the font size is increased or decreased the more the remaining gap between line and text box width is.
This approximation to the width of the text box is repeated until the width of the line is within the tolerance range described above. A counter
state prevents the function from starting an infinite loop. If the precision condition is met, i.e. the line inside the text box is close to the final result, the state
variable finished
is set to true
and inside the else
block the letter-spacing
is set in such a way that the pixels out of line are distributed to the individual letters of the row in order to get an exact result.