DOM

useExpandableText

Collapse long text by a character and/or line budget with a show-more toggle.

Demo

hookli bundles the React hooks you reach for most into one typed, SSR-safe, zero-dependency package — state, effects, DOM and data helpers that tree-shake cleanly so you only ship…

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.

Usage

use-expandable-text.tsxtsx
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.d.tsts
useExpandableText<T extends HTMLElement = HTMLElement>(text: string, options?: UseExpandableTextOptions): UseExpandableTextResult<T>

Parameters

NameTypeDefaultDescription
textstringThe full text to (maybe) collapse.
optionsUseExpandableTextOptions{}Character and/or line budgets and display options. Whichever limit clips first wins.

Returns

NameTypeDescription
textstringThe text to render — character-capped when collapsed and maxChars is set, otherwise the full text.
isExpandedbooleanWhether the full text is currently shown.
isTruncatedbooleanTrue when either limit actually clips the text — use it to hide the toggle when the text fits.
toggle() => voidFlip between expanded and collapsed.
expand() => voidShow the full text.
collapse() => voidCollapse back to the limit.
refRefCallback<T>Attach to the text element. Required for the maxLines clamp and its overflow measurement.
clampStyleCSSPropertiesSpread onto the text element; applies the CSS line-clamp while collapsed and maxLines is set.

UseExpandableTextOptions

Character and line budgets — provide either, or both.

NameTypeDescription
maxCharsnumberMax characters shown while collapsed. Pure string logic (SSR-safe), trimmed to a word boundary.
maxLinesnumberMax lines shown while collapsed. Applied as a CSS line-clamp and measured in the DOM, so it re-clips on resize.
ellipsisstringAppended to character-truncated text.
defaultExpandedbooleanWhether the text starts expanded.

Hook

The implementation, straight from hookli. Copy it, or install the package.

src/hooks/use-expandable-text/use-expandable-text.tstsx
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,
  };
};

View source on GitHub

Related hooks