# useExpandableText > Collapse long text by a character and/or line budget with a show-more toggle. - Category: DOM - Package: `hookli` (install with `npm i hookli`) - Docs: https://hookli.vercel.app/docs/use-expandable-text ## Signature ```ts useExpandableText(text: string, options?: UseExpandableTextOptions): UseExpandableTextResult ``` ## Parameters - `text`: `string` — The full text to (maybe) collapse. - `options`: `UseExpandableTextOptions` (default: `{}`) — Character and/or line budgets and display options. Whichever limit clips first wins. ## Returns - `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` — 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. ## Types ### UseExpandableTextOptions Character and line budgets — provide either, or both. - `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` (default: `"…"`) — Appended to character-truncated text. - `defaultExpanded`: `boolean` (default: `false`) — Whether the text starts expanded. ## Usage ```tsx import { useExpandableText } from "hookli"; export function Review({ body }: { body: string }) { const { text, isExpanded, isTruncated, toggle, ref, clampStyle } = useExpandableText(body, { maxChars: 180, maxLines: 3 }); return (

{text}

{isTruncated && ( )}
); } ``` ## Source `src/hooks/use-expandable-text/use-expandable-text.ts` ```ts import { useCallback, useEffect, useState, type CSSProperties, type RefCallback, } from "react"; export interface UseExpandableTextOptions { maxChars?: number; maxLines?: number; ellipsis?: string; defaultExpanded?: boolean; } export interface UseExpandableTextResult { text: string; isExpanded: boolean; isTruncated: boolean; toggle: () => void; expand: () => void; collapse: () => void; ref: RefCallback; 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 = ( text: string, options: UseExpandableTextOptions = {}, ): UseExpandableTextResult => { const { maxChars, maxLines, ellipsis = "…", defaultExpanded = false } = options; const [isExpanded, setIsExpanded] = useState(defaultExpanded); const [node, setNode] = useState(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>((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, }; }; ```