DOM
useExpandableText
Collapse long text by a character and/or line budget with a show-more toggle.
Demo
live · imported from hookli
- isExpanded
- false
- isTruncated
- true
The character budget yields a genuinely shortened string (SSR-safe); the line budget clamps via CSS and re-measures on resize. Switch to “both” and narrow the window to see whichever limit clips first win.
import { useExpandableText } from "hookli";
export function Review({ body }: { body: string }) {
const { text, isExpanded, isTruncated, toggle, ref, clampStyle } =
useExpandableText<HTMLParagraphElement>(body, { maxChars: 180, maxLines: 3 });
return (
<div>
<p ref={ref} style={clampStyle}>{text}</p>
{isTruncated && (
<button onClick={toggle}>{isExpanded ? "Show less" : "Show more"}</button>
)}
</div>
);
}Usage
import { useExpandableText } from "hookli";
export function Review({ body }: { body: string }) {
const { text, isExpanded, isTruncated, toggle, ref, clampStyle } =
useExpandableText<HTMLParagraphElement>(body, { maxChars: 180, maxLines: 3 });
return (
<div>
<p ref={ref} style={clampStyle}>{text}</p>
{isTruncated && (
<button onClick={toggle}>{isExpanded ? "Show less" : "Show more"}</button>
)}
</div>
);
}API
useExpandableText<T extends HTMLElement = HTMLElement>(text: string, options?: UseExpandableTextOptions): UseExpandableTextResult<T>Parameters
| Name | Type | Default | Description |
|---|---|---|---|
| text | string | — | The full text to (maybe) collapse. |
| options | UseExpandableTextOptions | {} | Character and/or line budgets and display options. Whichever limit clips first wins. |
Returns
| Name | Type | Description |
|---|---|---|
| text | string | The text to render — character-capped when collapsed and maxChars is set, otherwise the full text. |
| isExpanded | boolean | Whether the full text is currently shown. |
| isTruncated | boolean | True when either limit actually clips the text — use it to hide the toggle when the text fits. |
| toggle | () => void | Flip between expanded and collapsed. |
| expand | () => void | Show the full text. |
| collapse | () => void | Collapse back to the limit. |
| ref | RefCallback<T> | Attach to the text element. Required for the maxLines clamp and its overflow measurement. |
| clampStyle | CSSProperties | Spread onto the text element; applies the CSS line-clamp while collapsed and maxLines is set. |
UseExpandableTextOptions
Character and line budgets — provide either, or both.
| Name | Type | Description |
|---|---|---|
| maxChars | number | Max characters shown while collapsed. Pure string logic (SSR-safe), trimmed to a word boundary. |
| maxLines | number | Max lines shown while collapsed. Applied as a CSS line-clamp and measured in the DOM, so it re-clips on resize. |
| ellipsis | string | Appended to character-truncated text. |
| defaultExpanded | boolean | Whether the text starts expanded. |
Hook
The implementation, straight from hookli. Copy it, or install the package.
import {
useCallback,
useEffect,
useState,
type CSSProperties,
type RefCallback,
} from "react";
export interface UseExpandableTextOptions {
maxChars?: number;
maxLines?: number;
ellipsis?: string;
defaultExpanded?: boolean;
}
export interface UseExpandableTextResult<T extends HTMLElement = HTMLElement> {
text: string;
isExpanded: boolean;
isTruncated: boolean;
toggle: () => void;
expand: () => void;
collapse: () => void;
ref: RefCallback<T>;
clampStyle: CSSProperties;
}
const truncateChars = (
text: string,
maxChars: number,
ellipsis: string,
): string => {
if (text.length <= maxChars) return text;
const slice = text.slice(0, maxChars);
const lastSpace = slice.lastIndexOf(" ");
const cut = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
return cut.trimEnd() + ellipsis;
};
export const useExpandableText = <T extends HTMLElement = HTMLElement>(
text: string,
options: UseExpandableTextOptions = {},
): UseExpandableTextResult<T> => {
const { maxChars, maxLines, ellipsis = "…", defaultExpanded = false } = options;
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [node, setNode] = useState<T | null>(null);
const [lineOverflow, setLineOverflow] = useState(false);
const charTruncated = maxChars !== undefined && text.length > maxChars;
const collapsedText = charTruncated
? truncateChars(text, maxChars, ellipsis)
: text;
const displayText = isExpanded ? text : collapsedText;
const ref = useCallback<RefCallback<T>>((el) => setNode(el), []);
// scrollHeight reflects the full content height regardless of the clamp, so
// the overflow read is accurate in both states; the char cap alone drives
// isTruncated when it has already shortened the string.
useEffect(() => {
if (!node || maxLines === undefined) {
setLineOverflow(false);
return;
}
const measure = () => {
const style = window.getComputedStyle(node);
let lineHeight = Number.parseFloat(style.lineHeight);
if (!Number.isFinite(lineHeight)) {
lineHeight = Number.parseFloat(style.fontSize) * 1.2;
}
const padding =
Number.parseFloat(style.paddingTop) +
Number.parseFloat(style.paddingBottom);
const maxHeight = lineHeight * maxLines + (padding || 0);
setLineOverflow(node.scrollHeight > maxHeight + 1);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
}, [node, maxLines, text, isExpanded]);
const toggle = useCallback(() => setIsExpanded((value) => !value), []);
const expand = useCallback(() => setIsExpanded(true), []);
const collapse = useCallback(() => setIsExpanded(false), []);
const clampStyle: CSSProperties =
!isExpanded && maxLines !== undefined
? {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: maxLines,
overflow: "hidden",
}
: {};
return {
text: displayText,
isExpanded,
isTruncated: charTruncated || lineOverflow,
toggle,
expand,
collapse,
ref,
clampStyle,
};
};