Skip to content

Clamps long text with a fading last line, offering more only when it overflows.

Disclosuremotion

01Preview

  • Maya Okafor2h

    I reproduced the double charge on staging: when the card needs 3-D Secure, the confirm step retries on the client after the redirect, and the webhook also finalizes the invoice. Both paths call finalizeInvoice, so we bill twice whenever the webhook wins the race.

    Proposed fix: make finalization idempotent on the invoice id and drop the client retry entirely. I’ve drafted it in #2291, with a test that fires both paths within 50ms of each other. Refunds for the 14 affected customers are queued for tomorrow.

  • Jonas Lindqvist1h

    Approved. Ship it behind the billing flag first.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { ShowMore } from "@/components/ui/show-more";

<ShowMore lines={3}>
  <p>{comment.body}</p>
</ShowMore>

04Source

"use client";
import { useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { useControllableState } from "@/lib/use-controllable-state";

type Measure = { full: number; clamp: number };

/** The nearest ancestor that scrolls vertically, or null for the page itself. */
function scrollParent(el: HTMLElement): HTMLElement | null {
  for (let p = el.parentElement; p; p = p.parentElement) {
    const y = getComputedStyle(p).overflowY;
    if ((y === "auto" || y === "scroll") && p.scrollHeight > p.clientHeight) return p;
  }
  return null;
}

export type ShowMoreProps = Omit<React.ComponentProps<"div">, "children"> & {
  children: React.ReactNode;
  /** Lines visible while collapsed. */
  lines?: number;
  expanded?: boolean;
  defaultExpanded?: boolean;
  onExpandedChange?: (expanded: boolean) => void;
  moreLabel?: React.ReactNode;
  lessLabel?: React.ReactNode;
  /** Classes for the text box (type size and color live here). */
  contentClassName?: string;
};

/**
 * Clamps text to a number of lines and fades the last one out. The button exists
 * only when the text actually overflows, which is measured, not guessed from length.
 */
export function ShowMore({
  children,
  lines = 3,
  expanded,
  defaultExpanded = false,
  onExpandedChange,
  moreLabel = "Show more",
  lessLabel = "Show less",
  className,
  contentClassName,
  style,
  ...rest
}: ShowMoreProps) {
  const [open, setOpen] = useControllableState({ value: expanded, defaultValue: defaultExpanded, onChange: onExpandedChange });
  const outer = useRef<HTMLDivElement>(null);
  const inner = useRef<HTMLDivElement>(null);
  const root = useRef<HTMLDivElement>(null);
  // null until measured: the server can't know whether the text overflows.
  const [m, setM] = useState<Measure | null>(null);
  // Height animates only for a toggle; a resize or a font swap snaps to the new size.
  const [moving, setMoving] = useState(false);
  const reduce = useReducedMotion();
  const id = useId();

  useEffect(() => {
    const o = outer.current;
    const i = inner.current;
    if (!o || !i) return;
    const ro = new ResizeObserver(() => {
      const cs = getComputedStyle(o);
      const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.5;
      const next = { full: i.offsetHeight, clamp: Math.round(lh * lines) };
      setM((prev) => (prev && prev.full === next.full && prev.clamp === next.clamp ? prev : next));
    });
    ro.observe(i);
    return () => ro.disconnect();
  }, [lines]);

  const overflowing = m ? m.full > m.clamp + 1 : null;
  const clamped = overflowing !== false;
  const distance = m ? Math.abs(m.full - m.clamp) : 0;
  // Longer text travels further, so it gets a little longer, within 220–420ms.
  const duration = reduce ? 0 : Math.round(Math.min(420, Math.max(220, 180 + distance * 0.5)));

  const toggle = (next: boolean) => {
    if (next === open) return;
    if (!next && root.current) {
      // Collapsing from far down the text can strand the reader below the fold; bring the top back.
      const el = root.current;
      const parent = scrollParent(el);
      const top = parent ? parent.getBoundingClientRect().top : 0;
      if (el.getBoundingClientRect().top < top) el.scrollIntoView({ block: "start", behavior: reduce ? "auto" : "smooth" });
    }
    if (!reduce) setMoving(true);
    setOpen(next);
  };

  return (
    <div ref={root} data-state={open ? "open" : "closed"} className={cn("flex min-w-0 scroll-mt-4 flex-col items-start", className)} style={style} {...rest}>
      <div
        ref={outer}
        id={id}
        onTransitionEnd={(e) => {
          if (e.target === e.currentTarget && e.propertyName === "height") setMoving(false);
        }}
        // A link hidden in the clamped lines can still take focus: open up so it is seen.
        onFocusCapture={(e) => {
          if (open || !overflowing || !m || !inner.current) return;
          const y = (e.target as HTMLElement).getBoundingClientRect().bottom - inner.current.getBoundingClientRect().top;
          if (y > m.clamp) toggle(true);
        }}
        style={
          {
            "--lines": lines,
            height: m && overflowing ? (open ? m.full : m.clamp) : undefined,
            transitionDuration: moving ? `${open ? duration : Math.round(duration * 0.8)}ms` : "0ms",
          } as React.CSSProperties
        }
        className={cn(
          "relative w-full overflow-clip text-[13px] leading-[1.6] text-fg-2 [overflow-anchor:none]",
          open ? "ease-out-quart" : "ease-in-out-quart",
          "transition-[height,mask-position]",
          // Before hydration, CSS clamps by line height so the server render already looks right.
          !m && !open && "max-h-[calc(var(--lines)*1lh)]",
          // The mask is taller than the box by the fade; sliding it down past the edge clears the fade as the text opens.
          clamped &&
            "[mask-image:linear-gradient(to_bottom,var(--fg)_calc(100%-1.2lh),transparent)] [mask-repeat:no-repeat] [mask-size:100%_calc(100%+1.2lh)]",
          clamped && (open ? "[mask-position:0_0]" : "[mask-position:0_100%]"),
          contentClassName,
        )}
      >
        <div ref={inner} className="text-pretty">
          {children}
        </div>
      </div>

      {overflowing !== false && (
        <button
          type="button"
          aria-expanded={open}
          aria-controls={id}
          // Unmeasured, the button holds its space invisibly so nothing jumps when it appears.
          className={cn(
            "group/more relative -mx-2 mt-1 flex h-8 select-none items-center gap-1.5 rounded-md px-2 text-[12.5px] font-medium text-fg-2",
            "touch-manipulation [-webkit-tap-highlight-color:transparent]",
            "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3",
            "transition-[background-color,color,scale,opacity] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75",
            "pointer-coarse:before:absolute pointer-coarse:before:-inset-y-1.5 pointer-coarse:before:inset-x-0 pointer-coarse:before:content-['']",
            overflowing === null && "invisible opacity-0",
          )}
          onClick={() => toggle(!open)}
        >
          <svg
            width="14"
            height="14"
            viewBox="0 0 16 16"
            fill="none"
            stroke="currentColor"
            strokeWidth={1.5}
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden
            className={cn(
              "shrink-0 text-fg-3 transition-[rotate,color] duration-[240ms] ease-in-out-quart motion-reduce:transition-none group-hover/more:text-fg-2",
              open && "rotate-180",
            )}
          >
            <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
          </svg>
          {/* Both labels share one cell so the button keeps its width; only the current one is visible to assistive tech. */}
          <span className="grid overflow-hidden py-0.5">
            <Swap show={!open}>{moreLabel}</Swap>
            <Swap show={open} from="below">
              {lessLabel}
            </Swap>
          </span>
        </button>
      )}
    </div>
  );
}

function Swap({ show, from = "above", children }: { show: boolean; from?: "above" | "below"; children: React.ReactNode }) {
  return (
    <span
      className={cn(
        "col-start-1 row-start-1 whitespace-nowrap transition-[opacity,translate,visibility] duration-200 ease-out-expo motion-reduce:translate-y-0",
        show ? "visible translate-y-0 opacity-100" : cn("invisible opacity-0", from === "below" ? "translate-y-2" : "-translate-y-2"),
      )}
    >
      {children}
    </span>
  );
}

05Props

PropTypeDefaultDescription
children*ReactNodeThe text. Paragraphs, links and inline code are fine.
linesnumber3Lines visible while collapsed, measured from the text's own line height.
defaultExpandedbooleanfalseStart open. Uncontrolled.
expandedbooleanControlled open state. Pair with onExpandedChange.
onExpandedChange(expanded: boolean) => voidCalled when the button, or focus moving into hidden text, opens or closes it.
moreLabelReactNode"Show more"Button label while collapsed.
lessLabelReactNode"Show less"Button label while open. Shares a box with moreLabel, so the button keeps its width.
contentClassNamestringClasses for the text box: size, color, leading.

06Notes

Behavior

  • Overflow is measured with a ResizeObserver, not guessed from character count: text that fits gets no button and no fade, and a resize that makes it fit removes them.
  • Before hydration CSS clamps by line height and the button holds its space invisibly, so the server render already looks right and long text never jumps when the button appears.
  • Show less, pressed far down the text, scrolls the top of the block back into view (in the page or the nearest scrolling parent) instead of stranding the reader.
  • Tabbing into a link hidden in the clamped lines opens the text, so focus is never on something you can't see. Resizes and font swaps snap; only a press animates.

Motion

  • Height animates to the measured size on ease-out-quart, 220–420ms depending on how far it travels; collapsing runs at 80% of that on ease-in-out-quart.
  • The fade is a mask taller than the box by 1.2 lines; it slides off the bottom edge as the text opens, so the last line clears in step with the height instead of popping.
  • The chevron turns over in 240ms and the label rises out as the next arrives from below. Reduced motion snaps the height and mask and smooth scrolling becomes a jump.

Accessibility

  • A native button with aria-expanded and aria-controls pointing at the text.
  • The clamped text stays in the accessibility tree, so screen reader users hear it all whether or not it is expanded.
  • Only the current label is visible to assistive tech; the button draws at 32px and reaches 44px on touch.