---
title: ExpandableText
description: A text container that truncates to a configurable number of lines and expands with a toggle button
---

```tsx
'use client';
import { ExpandableText } from '@/ui/ExpandableText';

export function ExpandableTextDemo() {
  return (
    <div className="max-w-prose space-y-12">
      <ExpandableText
        id="desc"
        labels={{ expand: 'Read more', reduce: 'Show less' }}
        lineClamps="line-clamp-3"
      >
        <p>
          Nestled between the Mediterranean Sea and the mountains, our resort offers an
          unforgettable all-inclusive experience. Enjoy world-class cuisine, daily activities, and
          breathtaking views every morning. Our dedicated G.Os are here to ensure every moment of
          your stay is filled with joy and discovery. From water sports to evening shows, there is
          something for every member of the family. Book now and create memories that last a
          lifetime.
        </p>
      </ExpandableText>
    </div>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/expandable-text.json
```

Or manually, copy the following code into `/ui/ExpandableText.tsx`:

```tsx
'use client';
import clsx from 'clsx';
import type { CSSProperties, PropsWithChildren } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';

import { useIsClient } from './hooks/useIsClient';
import { useResizeObserver } from './hooks/useResizeObserver';

export interface ExpandableTextLabels {
  expand: string;
  reduce: string;
}

export interface ExpandableTextProps {
  buttonClassName?: string;
  className?: string;
  id: string;
  labels: ExpandableTextLabels;
  /** Tailwind line-clamp class to use when collapsed, e.g. "line-clamp-3" */
  lineClamps?: string;
}

export const ExpandableText = ({
  buttonClassName,
  children,
  className,
  id,
  labels,
  lineClamps = 'line-clamp-3',
}: PropsWithChildren<ExpandableTextProps>) => {
  const ref = useRef<HTMLDivElement>(null!);
  const [min, setMin] = useState(0);
  const [max, setMax] = useState(0);
  const [isExpanded, setIsExpanded] = useState(false);

  const isClient = useIsClient();

  const onResize = useCallback(() => {
    setMax(ref.current.scrollHeight);
    setMin((currentMin) => {
      if (currentMin === 0) {
        return ref.current.offsetHeight;
      }
      return currentMin;
    });
  }, []);

  useResizeObserver<HTMLDivElement>({
    ref,
    onResize,
  });

  useLayoutEffect(() => {
    if (!ref.current) {
      return;
    }
    setMin(ref.current.offsetHeight);
    setMax(ref.current.scrollHeight);
  }, []);

  if (!children) {
    return null;
  }

  const withToggleButton = max > min || isExpanded;
  const style = { '--height': `${isExpanded ? max : min}px` } as CSSProperties;

  return (
    <>
      <div
        className={clsx(className, 'overflow-clip transition-all duration-500', {
          'h-(--height)': isClient,
        })}
        style={style}
      >
        <div className={clsx({ [lineClamps]: !isExpanded })} id={id} ref={ref}>
          {children}
        </div>
      </div>
      {withToggleButton && (
        <button
          aria-controls={id}
          aria-expanded={isExpanded}
          className={clsx(
            'self-start border-b border-current font-semibold text-inherit transition-colors outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2',
            buttonClassName,
          )}
          onClick={() => setIsExpanded(!isExpanded)}
          type="button"
        >
          {isExpanded ? labels.reduce : labels.expand}
        </button>
      )}
    </>
  );
};
```

## Usage

```tsx showLineNumbers
import { ExpandableText } from '@/ui/ExpandableText';

export function Component() {
  return (
    <ExpandableText
      id="product-description"
      labels={{ expand: 'Read more', reduce: 'Show less' }}
      lineClamps="line-clamp-3"
    >
      <p>Your long text content here…</p>
    </ExpandableText>
  );
}
```

## API Reference

### ExpandableText

| Prop              | Type                  | Default         | Description                                                              |
| ----------------- | --------------------- | --------------- | ------------------------------------------------------------------------ |
| `id`              | `string`              | —               | **Required**. ID applied to the inner content wrapper (used by `aria-controls`). |
| `labels`          | `ExpandableTextLabels`| —               | **Required**. Object with `expand` and `reduce` strings for the toggle button. |
| `lineClamps`      | `string`              | `"line-clamp-3"` | A Tailwind `line-clamp-*` class used to truncate the content when collapsed. |
| `buttonClassName` | `string`              | `undefined`     | Additional classes for the toggle button.                                |
| `className`       | `string`              | `undefined`     | Additional classes for the outer height-animated container.              |
| `children`        | `ReactNode`           | `undefined`     | The content to display. Renders nothing when absent.                     |

### ExpandableTextLabels

| Field    | Type     | Description                           |
| -------- | -------- | ------------------------------------- |
| `expand` | `string` | Label for the button when collapsed.  |
| `reduce` | `string` | Label for the button when expanded.   |

## Notes

- Uses `ResizeObserver` internally to detect when the content overflows — the toggle button only appears when the text actually overflows the clamped height
- Height transition is animated via a CSS `--height` custom property
- The component renders nothing when `children` is falsy
- The toggle button uses `aria-controls` pointing to the `id` prop and `aria-expanded` to communicate state
- Works correctly with SSR: height CSS only applies on the client (`useIsClient` guard)
