@fringeworks/text-fitting
v0.4.0
Published
A library some will find handy, for fitting text into an element by truncating it, resizing it, or choosing among candidates.
Maintainers
Readme
@fringeworks/text-fitting
@fringeworks/text-fitting is a library some will find handy, for fitting text into an element by truncating it, resizing it, or choosing among candidates.
Installation
npm install @fringeworks/text-fitting
# or
pnpm add @fringeworks/text-fittingUsage
Functions and types can be imported by the same name from either the root or the per-function path. From a per-function path, the function can also be imported as the default export.
import { truncateText } from '@fringeworks/text-fitting';
import truncateText from '@fringeworks/text-fitting/truncateText';Compared with CSS truncation
If you only need to truncate the end of a single line, CSS text-overflow: ellipsis is enough. Use this library when you want to:
- Truncate the start or the middle (
…/components/Button.tsx,very-long-fi…-final.pdf) - Avoid cutting in the middle of a word
- Truncate multi-line text to fit the element's height or a number of lines
- Also enforce a maximum number of graphemes (user-perceived characters)
- Know whether the text was truncated (for example, to show a "Read more" button)
- Use an ellipsis other than
...
The library does not interpret the content of the string. If you want to normalize line breaks or whitespace, do so before passing the string (for example with trim(), or unifyLineBreaks / removeSpaces from @fringeworks/japanese-text). Line breaks and whitespace are displayed as is.
API
| Function | Description |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| truncateText(text, options) | Truncates a string so that its number of graphemes stays within a limit. Does not use the DOM |
| createTruncateTextController(text, options?) | Creates a controller that truncates a string to fit an element's size (width and height) and lines. Requires the DOM |
| createResizeTextController(text, options?) | Creates a controller that changes the font size to fit an element's size (width and height) and lines. Requires the DOM |
| createChooseTextController(candidates, options?) | Creates a controller that chooses, from candidates, the expression that fits an element's size (width and height) and lines. Requires the DOM |
| createFitTextController(text, options?) | Creates a controller that fits text into an element's size and lines by combining choosing, resizing, and truncating. Requires the DOM |
truncateText
truncateText(text: string, options: TruncateTextOptions): stringTruncates a string so that the number of graphemes (user-perceived characters), including the ellipsis, is at most maxChars. It does not use the DOM, so it works in any environment.
- If the string is within the limit, it is returned as is (no room is reserved for the ellipsis)
- Emoji and combining characters are never split
- If the ellipsis alone exceeds the limit, the string is cut without an ellipsis
truncateText('Hello wonderful world', { maxChars: 12 });
// 'Hello won...'
truncateText('Hello wonderful world', {
maxChars: 12,
truncateBoundary: 'word',
});
// 'Hello...'
truncateText('very-long-file-name.pdf', {
maxChars: 13,
truncatePosition: 'middle',
ellipsis: '…',
});
// 'very-l…me.pdf'| Option | Type | Description |
| ------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| maxChars | number | Maximum number of graphemes, including the ellipsis. NaN means no limit, negative values are treated as 0, and fractions are truncated |
| ellipsis? | string | The ellipsis. An empty string cuts the text without an ellipsis. Defaults to '...' |
| truncatePosition? | TruncatePosition | Where to truncate: 'end', 'start', or 'middle'. Defaults to 'end' |
| truncateBoundary? | TruncateBoundary | The unit to cut at: 'grapheme' or 'word'. With 'word', no whitespace or line break is left at the kept edges. Defaults to 'grapheme' |
| locale? | string | Locale used to split graphemes and words. If omitted or invalid, the runtime's default locale is used |
When truncating the middle, half of the graphemes are kept on each side (the start gets one more when the count is odd). When cutting at word boundaries, any budget the start could not use is given to the end.
createTruncateTextController
createTruncateTextController(text: string, options?: TruncateTextControllerOptions): TruncateTextControllerCreates a controller that selects the longest string that fits the element's size (width and height) and number of lines, by actually rendering and measuring candidates.
- When an element is connected with
attach, the controller measures it, and measures again when the element's size changes or fonts finish loading. It does not measure again automatically when only the text formatting (font-size,letter-spacing, and so on) changes while the element's size stays the same, so callrefreshin that case - The controller returns the text to display and the style to apply as a snapshot. It never rewrites the element's content or style, so apply the snapshot's
displayTextandstyleto the same element. Without thestyle, the measured formatting and the displayed formatting differ, and the result will be wrong - Creating a controller does not touch the DOM, so it can be created during SSR. Measuring requires the DOM
const element = document.querySelector<HTMLElement>('.title')!;
const controller = createTruncateTextController(text, {
wrap: 'normal',
maxLines: 2,
});
const render = () => {
const { displayText, style } = controller.getSnapshot();
Object.assign(element.style, style);
element.textContent = displayText;
};
controller.subscribe(render);
render();
controller.attach(element);| Option | Type | Description |
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| wrap? | TextWrap | Wrapping: 'none' (no wrapping), 'normal' (wrap at normal break points), or 'anywhere' (also break long words without break points). Defaults to 'none' |
| maxLines? | number | Maximum number of lines. NaN means no limit, negative values are treated as 0, and fractions are truncated |
| maxChars? | number | Maximum number of graphemes, including the ellipsis. Enforced in addition to the element's size |
| disabled? | boolean | Stops the controller. While stopped, it does not measure and displays the original string. Defaults to false |
| detectionDelay? | number | The time (ms) from detecting a change in the element's size or font loading until it is reflected in a measurement. Further detections restart the wait. 0 measures in the next frame. Defaults to 0 (see "Measuring while changes continue" below) |
| hideWhilePending? | boolean | Hides the text by adding visibility: hidden to style while waiting to reflect a detected change. Defaults to false |
| ellipsis? | string | Same as truncateText |
| truncatePosition? | TruncatePosition | Same as truncateText |
| truncateBoundary? | TruncateBoundary | Same as truncateText |
| locale? | string | Same as truncateText |
With wrap: 'none', each line separated by a line break is fit to the element's width.
TruncateTextController
| Method | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------ |
| attach(element) | Connects the element to display and measures immediately. Pass null to disconnect |
| getSnapshot() | Returns the latest result. The same object is returned while the result does not change |
| subscribe(listener) | Subscribes to changes in the result and returns a function that unsubscribes |
| update(text, options?) | Changes the string and options and measures immediately. Does nothing if the values are the same |
| refresh() | Measures again immediately with the current string and options |
| destroy() | Removes all observers and subscriptions. Any operation after destruction does nothing |
Properties of TruncateTextSnapshot:
| Property | Type | Description |
| ------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| displayText | string | The text to display |
| isTruncated | boolean \| null | Whether the text was truncated. While not yet measured (no element attached, not in the DOM, display: none, or zero width), it is null and the original string is displayed |
| isPending | boolean | Whether the controller is waiting to reflect a detected change (true during detectionDelay). The result in the meantime is the one measured last |
| style | TextFittingStyle | The style to apply to the element (whiteSpace, overflowWrap when wrap is 'anywhere', and visibility while waiting with hideWhilePending) |
About the element's size
- The element's width must be determined by layout (a block element, a flex or grid item, and so on). Inline elements, whose width follows their content, are never truncated
- The element's height is used as a constraint only when it is determined by CSS (
height/max-height) or layout (flex, grid, and so on). For an element whose height grows with its content, only the width andmaxLinesare checked. So when wrapping (wrapis'normal'or'anywhere') in an element whose height grows with its content and withoutmaxLines, the text is not truncated (the element grows to show the full text) - To determine whether the height is fixed, the controller temporarily changes the element's font size and line height (it restores them immediately, so this is never painted)
- While stopped with
disabled, the full text may overflow if the element's height is set with CSS. Remove the height restriction as well when displaying the full text
// Expand the full text with a "Read more" button
controller.update(text, { wrap: 'normal', maxLines: 3, disabled: expanded });
// Show the button when expanded || isTruncatedcreateResizeTextController
createResizeTextController(text: string, options?: ResizeTextControllerOptions): ResizeTextControllerCreates a controller that changes the font size so that the text fits the element's size (width and height) and number of lines. It selects the largest size within minFontSize to maxFontSize that fits, by actually rendering and measuring.
- If
maxFontSizeis omitted, the upper limit is the element's CSS font size. If the text already fits, nothing changes; it only shrinks when the text overflows - If
maxFontSizeis specified, the text is enlarged up to that size as long as it fits (for example, to fill a dashboard tile with a number) - If the text does not fit even at the lower limit, the lower limit is used and
isOverflowingreports it. The overflow is left to CSS - Usage is the same as
createTruncateTextController. The snapshot'sstylecontains the selectedfontSize
const controller = createResizeTextController('1,234,567', {
maxFontSize: 64,
});
const render = () => {
const { displayText, style } = controller.getSnapshot();
Object.assign(element.style, style);
element.textContent = displayText;
};
controller.subscribe(render);
render();
controller.attach(element);| Option | Type | Description |
| ------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| minFontSize? | number | Lower limit of the font size (px). If omitted, there is no lower limit (in practice, the browser's minimum font size applies). Specify it for long text to keep it readable. 0 or less and NaN are treated as omitted |
| maxFontSize? | number | Upper limit of the font size (px). If omitted, the element's CSS font size. Infinity enlarges the text as much as it fits. 0 or less and NaN are treated as omitted |
| wrap? | TextWrap | Same as createTruncateTextController |
| maxLines? | number | Same as createTruncateTextController |
| disabled? | boolean | Stops the controller. While stopped, it does not measure and does not set a font size (the CSS size is used). Defaults to false |
| detectionDelay? | number | The time (ms) from detecting a change in the element's size or font loading until it is reflected in a measurement. Further detections restart the wait. 0 measures in the next frame. Defaults to 0 (see "Measuring while changes continue" below) |
| hideWhilePending? | boolean | Hides the text by adding visibility: hidden to style while waiting to reflect a detected change. Defaults to false |
If minFontSize is larger than the upper limit (maxFontSize, or the CSS font size if omitted), the font size is fixed at minFontSize. The lower limit exists to keep the text readable, so it takes precedence over the upper limit.
ResizeTextController
The methods are the same as TruncateTextController (attach, getSnapshot, subscribe, update, refresh, destroy).
Properties of ResizeTextSnapshot:
| Property | Type | Description |
| --------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| displayText | string | The text to display (the original string as is) |
| fontSize | number \| null | The selected font size (px). null while not yet measured and while stopped |
| isOverflowing | boolean \| null | Whether the text does not fit even at the lower limit. null while not yet measured and while stopped |
| isPending | boolean | Whether the controller is waiting to reflect a detected change (true during detectionDelay). The result in the meantime is the one measured last |
| style | ResizeTextStyle | The style to apply to the element (whiteSpace, overflowWrap, and the selected fontSize) |
Notes on changing the font size
- Specify the element's CSS font size with CSS such as a class. Applying the controller's
styleoverrides the element's inlinefont-size - As with
createTruncateTextController, the element's height is used as a constraint only when it is determined by CSS or layout. For an element whose height grows with its content, the size is determined by the width and lines only (so after shrinking, it grows again when the width gets wider) - To read the CSS font size and whether the height is fixed, the controller temporarily changes the element's font size and line height and restores them immediately (it is never painted)
- A unitless,
em, or%line-heightis measured as scaling with the font size, and apxvalue as fixed.letter-spacingand similar properties specified inemare measured as not scaling - If the user has set a minimum font size in the browser settings, the text never gets smaller than that. In that case it is judged not to fit and reported with
isOverflowing(the controller does not shrink it withtransform, to respect the user's readability setting)
createChooseTextController
createChooseTextController(candidates: readonly string[], options?: ChooseTextControllerOptions): ChooseTextControllerCreates a controller that chooses, from candidates, the expression that fits the element's size (width and height) and number of lines. It measures the candidates in order and displays the first one that fits.
- Order the candidates from most to least preferred. The content of the candidates is not interpreted, so format numbers, dates, and so on yourself and pass the results
- If no candidate fits, the last one is displayed and
isOverflowingreports it - An empty array is treated as an array with one empty string
- The options are
wrap,maxLines,disabled,detectionDelay, andhideWhilePending(same ascreateTruncateTextController)
const controller = createChooseTextController([
'Monday, October 26, 2026',
'2026/10/26',
'10/26',
]);ChooseTextController
The methods are the same as TruncateTextController. update takes an array of candidates instead of a string.
Properties of ChooseTextSnapshot:
| Property | Type | Description |
| --------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| displayText | string | The text to display (the chosen candidate) |
| index | number | The index of the chosen candidate. 0 while not yet measured and while stopped (the first one is displayed) |
| isOverflowing | boolean \| null | Whether no candidate fits. null while not yet measured and while stopped |
| isPending | boolean | Whether the controller is waiting to reflect a detected change (true during detectionDelay). The result in the meantime is the one measured last |
| style | TextFittingStyle | The style to apply to the element |
createFitTextController
createFitTextController(text: string | readonly string[], options?: FitTextControllerOptions): FitTextControllerCreates a controller that fits a string into the element's size (width and height) and number of lines by combining choosing among candidates, resizing, and truncating. Each feature is enabled only when specified.
| Feature | How to enable |
| ------------------------- | --------------------------------------------------------------------------------------------- |
| Choosing among candidates | Pass an array of candidates as the first argument. With a single string, no switching happens |
| Resizing | Specify minFontSize or maxFontSize |
| Truncating | Specify truncate: true |
resizeFirst selects the order. Truncation is always the last resort: the last candidate is truncated at the lower size limit.
| resizeFirst | Order | Suited for |
| ----------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| false (default) | Try each candidate at the upper size → shrink the last candidate → truncate the last candidate | Keeping font sizes consistent (dashboard numbers, and so on) |
| true | Try each candidate while shrinking → truncate the last candidate | Keeping the precise expression as long as possible |
For example, when fitting ['1,234,567', '1.2M'] with maxFontSize: 48, false tries 1,234,567 (48px) → 1.2M (48px) → 1.2M (shrunk), and true tries 1,234,567 (48px) → 1,234,567 (shrunk) → 1.2M (shrunk). If maxFontSize is Infinity, both behave like true.
If nothing fits, the last candidate is displayed at the lower size limit and isOverflowing reports it.
const controller = createFitTextController(['1,234,567', '1.2M'], {
minFontSize: 12,
maxFontSize: 48,
truncate: true,
});| Option | Type | Description |
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| minFontSize? | number | Same as createResizeTextController. Specifying it enables resizing |
| maxFontSize? | number | Same as createResizeTextController. Specifying it enables resizing |
| truncate? | boolean | Truncates as the last resort. Defaults to false |
| resizeFirst? | boolean | Shrinks the text before switching candidates. Defaults to false |
| maxChars? | number | Maximum number of graphemes, including the ellipsis. Used only when truncate is true: candidates exceeding it are not chosen, and the last one is truncated to fit it |
| ellipsis? | string | Same as truncateText |
| truncatePosition? | TruncatePosition | Same as truncateText |
| truncateBoundary? | TruncateBoundary | Same as truncateText |
| locale? | string | Same as truncateText |
| wrap? | TextWrap | Same as createTruncateTextController |
| maxLines? | number | Same as createTruncateTextController |
| disabled? | boolean | Stops the controller. While stopped, it does not measure and displays the first candidate. Defaults to false |
| detectionDelay? | number | The time (ms) from detecting a change in the element's size or font loading until it is reflected in a measurement. Further detections restart the wait. 0 measures in the next frame. Defaults to 0 (see "Measuring while changes continue" below) |
| hideWhilePending? | boolean | Hides the text by adding visibility: hidden to style while waiting to reflect a detected change. Defaults to false |
FitTextController
The methods are the same as TruncateTextController. update takes a string or an array of candidates.
Properties of FitTextSnapshot:
| Property | Type | Description |
| --------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| displayText | string | The text to display |
| index | number | The index of the chosen candidate. 0 while not yet measured and while stopped |
| fontSize | number \| null | The selected font size (px). null when not resizing, while not yet measured, and while stopped |
| isTruncated | boolean \| null | Whether the text was truncated. null while not yet measured and while stopped |
| isOverflowing | boolean \| null | Whether nothing fits. null while not yet measured and while stopped |
| isPending | boolean | Whether the controller is waiting to reflect a detected change (true during detectionDelay). The result in the meantime is the one measured last |
| style | ResizeTextStyle | The style to apply to the element (includes the selected fontSize when resizing) |
The notes on resizing are the same as createResizeTextController.
Measuring while changes continue (detectionDelay, hideWhilePending)
By default, the controller measures in the next frame after detecting a change in the element's size or font loading. While the window width is being dragged, for example, it measures every frame, and the result changes every frame. Use the following options when you render many elements or do not want to show intermediate results (shared by all four controllers).
- With
detectionDelayin milliseconds, the controller waits that long after the last detection before measuring. Detections in the meantime restart the wait, so it does not measure while the size keeps changing - While waiting, the snapshot's
isPendingistrue, and the result stays as it was last measured - With
hideWhilePending: true,visibility: hiddenis added tostylewhile waiting, which hides the text. The element's size does not change. To fade the text in, specify atransition(for example, onopacity) in your own styles - Measurements by
attach,update, andrefreshdo not wait. Calling them while waiting measures on the spot and stops waiting - Changes detected in the few frames right after a measurement are treated as caused by rendering the controller's own result (the text or font size), and are measured in the next frame without waiting (so that the text is not hidden again right after it is shown)
const controller = createTruncateTextController(text, {
detectionDelay: 150,
hideWhilePending: true,
});About Intl.Segmenter
Graphemes and words are split with Intl.Segmenter. In environments without Intl.Segmenter (Firefox before 125), simple rules are used instead.
- Graphemes are split by code point, so composed emoji (family emoji, flags, and so on) may be cut in the middle
- Words are split at whitespace, and kanji, hiragana, and katakana are split one character at a time. Japanese may be cut in the middle of a word
To split accurately even on older Firefox, load a polyfill such as @formatjs/intl-segmenter/polyfill. The library checks for Intl.Segmenter at runtime, so the polyfill is used automatically.
Truncating icon fonts and numbers
Truncating icon fonts drawn with ligatures (such as home in Material Icons) breaks the icon, and truncating numbers (1,23…) is misleading. Do not truncate them.
Types
| Type | Description |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| TruncateTextOptions | Options of truncateText |
| TruncateTextControllerOptions | Options of createTruncateTextController |
| TruncateTextController | The controller returned by createTruncateTextController |
| TruncateTextSnapshot | The result of TruncateTextController |
| ChooseTextControllerOptions | Options of createChooseTextController |
| ChooseTextController | The controller returned by createChooseTextController |
| ChooseTextSnapshot | The result of ChooseTextController |
| FitTextControllerOptions | Options of createFitTextController |
| FitTextController | The controller returned by createFitTextController |
| FitTextSnapshot | The result of FitTextController |
| ResizeTextControllerOptions | Options of createResizeTextController |
| ResizeTextController | The controller returned by createResizeTextController |
| ResizeTextSnapshot | The result of ResizeTextController |
| ResizeTextStyle | The style returned by ResizeTextController to apply to the element |
| TruncateOptions | Options shared by the truncation features |
| TextLayoutOptions | Options shared by the fitting features (wrap, maxLines, disabled, detectionDelay, hideWhilePending) |
| TruncatePosition | Where to truncate ('end' \| 'start' \| 'middle') |
| TruncateBoundary | The unit to cut at ('grapheme' \| 'word') |
| TextWrap | Wrapping ('none' \| 'normal' \| 'anywhere') |
| TextFittingStyle | The style to apply to the element |
