Text Effects

Truncate Expand

A line-clamped paragraph with a Show more control that expands via grid-template-rows, fades the last visible line, and hides itself when the text already fits.

Install

npx shadcn@latest add @paragon/truncate-expand

truncate-expand.tsx

"use client";

import * as React from "react";
import { ChevronDown } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface TruncateExpandProps extends React.ComponentProps<"div"> {
  /** Number of lines visible while clamped. */
  lines?: number;
  /** Control label while clamped. */
  moreLabel?: string;
  /** Control label while expanded. */
  lessLabel?: string;
}

/**
 * A clamped paragraph with a "Show more" disclosure that never jumps.
 *
 * Expansion animates `grid-template-rows: 0fr → 1fr` (the house-sanctioned
 * height exception); a `min-height` on the clipped row holds the collapsed
 * window at N lines, and a gradient fade sits over the last visible line
 * while clamped. The component measures its own content (ResizeObserver) —
 * if the text already fits, the control and fade never render. Collapse runs
 * faster than expand; reduced motion toggles instantly.
 *
 * The fade color defaults to the page background; set `--truncate-fade` on
 * a card or tinted surface to match.
 */
export function TruncateExpand({
  lines = 3,
  moreLabel = "Show more",
  lessLabel = "Show less",
  className,
  children,
  ...props
}: TruncateExpandProps) {
  const contentRef = React.useRef<HTMLDivElement>(null);
  const contentId = React.useId();
  const reducedMotion = useReducedMotion() ?? false;
  const [expanded, setExpanded] = React.useState(false);
  const [clampNeeded, setClampNeeded] = React.useState(true);
  const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(
    null,
  );

  React.useLayoutEffect(() => {
    const node = contentRef.current;
    if (!node) return;
    const measure = () => {
      const target =
        node.firstElementChild instanceof HTMLElement
          ? node.firstElementChild
          : node;
      const styles = getComputedStyle(target);
      let lineHeight = parseFloat(styles.lineHeight);
      if (Number.isNaN(lineHeight)) {
        lineHeight = parseFloat(styles.fontSize) * 1.5;
      }
      const collapsed = lineHeight * lines;
      setCollapsedHeight(collapsed);
      // Half-line tolerance: don't ship a control that reveals 3px.
      setClampNeeded(node.scrollHeight > collapsed + lineHeight / 2);
    };
    measure();
    if (typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(measure);
    observer.observe(node);
    return () => observer.disconnect();
  }, [lines]);

  const open = expanded || !clampNeeded;

  return (
    <div className={className} {...props}>
      <div className="relative">
        <div
          className="grid transition-[grid-template-rows]"
          style={{
            gridTemplateRows: open ? "1fr" : "0fr",
            transitionDuration: reducedMotion
              ? "0ms"
              : expanded
                ? "300ms"
                : "200ms",
            transitionTimingFunction: "var(--ease-out)",
          }}
        >
          <div
            id={contentId}
            className="overflow-hidden"
            style={{
              minHeight:
                collapsedHeight !== null
                  ? collapsedHeight
                  : `calc(${lines} * 1lh)`,
            }}
          >
            <div ref={contentRef}>{children}</div>
          </div>
        </div>
        <div
          aria-hidden
          className="pointer-events-none absolute inset-x-0 bottom-0 h-10"
          style={{
            backgroundImage:
              "linear-gradient(to top, var(--truncate-fade, var(--color-background)), transparent)",
            opacity: open ? 0 : 1,
            transitionProperty: "opacity",
            transitionDuration: "200ms",
            transitionTimingFunction: "var(--ease-out)",
          }}
        />
      </div>
      {clampNeeded && (
        <button
          type="button"
          aria-expanded={expanded}
          aria-controls={contentId}
          onClick={() => setExpanded((value) => !value)}
          className="relative mt-1.5 inline-flex items-center gap-1 text-[13px] font-medium text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground after:absolute after:-inset-x-2 after:-inset-y-2.5"
        >
          {expanded ? lessLabel : moreLabel}
          <ChevronDown
            aria-hidden
            className={cn(
              "size-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none",
              expanded && "rotate-180",
            )}
          />
        </button>
      )}
    </div>
  );
}