---
title: Paragraph
description: A block of editorial text — title, subtitle and an expandable HTML description — optionally next to an image
---

<ComponentPreview align="start" name="paragraph-demo" />

## Overview

`Paragraph` presents a piece of editorial content: a title and subtitle grouped in an `hgroup`, then an HTML description clamped to 3 lines with a "see more" toggle. With an `image`, the image sits above the text on narrow widths and next to it on wide ones.

The layout reacts to the width of the paragraph itself (a container query on `@container/paragraph`), not to the viewport, so the same paragraph stacks in a sidebar and spreads in a main column.

## Installation

Using the CLI:

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

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

```tsx
'use client';
import {
  type ComponentPropsWithoutRef,
  type FunctionComponent,
  type ReactNode,
  isValidElement,
  useId,
} from 'react';

import Markdown from 'markdown-to-jsx';

import { ExpandableText, type ExpandableTextLabels } from './ExpandableText';
import { Image, type ImageProps } from './Image';
import { Link } from './Link';
import { Button } from './buttons/Button';
import { cn } from './helpers/cn';
import { Heading, type HeadingProps } from './heading/Heading';

// `Link` takes a plain-text `label` (it splits it into spans for its hover effect) rather than
// `children`, so markdown link content (which may include nested formatting) is flattened to text.
function linkLabel(node: ReactNode): string {
  if (node == null || typeof node === 'boolean') {
    return '';
  }
  if (Array.isArray(node)) {
    return node.map(linkLabel).join('');
  }
  if (isValidElement<{ children?: ReactNode }>(node)) {
    return linkLabel(node.props.children);
  }
  return String(node);
}

const DescriptionLink = ({ children, ...attrs }: ComponentPropsWithoutRef<'a'>) => (
  <Link {...attrs} label={linkLabel(children)} />
);

const DESCRIPTION_MARKDOWN_OPTIONS = { overrides: { a: DescriptionLink } };

export type ParagraphTheme = 'default' | 'circle' | 'event';

/**
 * Each theme switches to its side-by-side layout at a container breakpoint (`@xl` or `@lg`), so
 * `reversed` uses the same breakpoint to move the image after the text.
 */
const THEMES = {
  default: {
    layout: 'grid gap-30 @xl/paragraph:grid-cols-2 @xl/paragraph:gap-20',
    image: 'aspect-horizontal w-full rounded-16',
    reversed: '@xl/paragraph:order-last',
    title: 'text-b2 font-semibold',
    subtitle: 'text-b3',
  },
  circle: {
    layout:
      'flex flex-col items-center gap-30 @xl/paragraph:flex-row @xl/paragraph:items-start @xl/paragraph:gap-20',
    image: 'aspect-square max-w-240 rounded-full',
    reversed: '@xl/paragraph:order-last',
    title: 'font-serif text-h3',
    subtitle: 'text-b2 font-semibold',
  },
  event: {
    layout: 'flex flex-col items-start gap-20 @lg/paragraph:flex-row',
    image: 'aspect-horizontal rounded-16 @lg/paragraph:aspect-vertical @lg/paragraph:w-172',
    reversed: '@lg/paragraph:order-last',
    title: 'font-serif text-h5',
    subtitle: 'text-b3 font-semibold',
  },
} as const satisfies Record<ParagraphTheme, Record<string, string>>;

export interface ParagraphButtonProps extends Omit<ComponentPropsWithoutRef<'a'>, 'children'> {
  label: string;
}

export interface ParagraphProps extends Omit<ComponentPropsWithoutRef<'div'>, 'slot'> {
  /**
   * HTML tag of the root element. An `article` is labelled by its title.
   */
  as?: 'div' | 'article';
  /**
   * Outline link rendered after the description, inside the expandable area
   */
  button?: ParagraphButtonProps | null;
  /**
   * Text of the paragraph. Accepts Markdown (including links, bold, italic) as well as raw HTML
   * for backward compatibility. Links are rendered with the `Link` component.
   */
  description: string;
  /**
   * Level of the title heading
   */
  hLevel?: HeadingProps['level'];
  /**
   * Image displayed next to the text. Without it, the text takes the full width.
   */
  image?: ImageProps | null;
  /**
   * Labels of the button expanding and reducing the description
   */
  labels: ExpandableTextLabels;
  /**
   * Places the image after the text once the layout is side by side
   */
  reversed?: boolean;
  /**
   * Content rendered after the text column, in the same layout as the image
   */
  slot?: ReactNode;
  subtitle?: string;
  theme?: ParagraphTheme;
  title?: string;
}

// Padding rather than margins: margins would collapse out of the measured expandable area.
const ParagraphButton = ({ className, label, ...attrs }: ParagraphButtonProps) => (
  <div className="mt-24 pb-12 @xl:mt-40">
    <Button
      {...attrs}
      className={cn('w-fit', className)}
      color="black"
      component="a"
      theme="outline"
    >
      {label}
    </Button>
  </div>
);

export const Paragraph: FunctionComponent<ParagraphProps> = ({
  as: TagName = 'div',
  button,
  children,
  className,
  description,
  hLevel = 2,
  image,
  labels,
  reversed = false,
  slot,
  subtitle,
  theme = 'default',
  title,
  ...attrs
}) => {
  const generatedId = useId();
  const baseId = attrs.id ?? generatedId;
  const headingId = `${baseId}-heading`;
  const th = THEMES[theme];

  return (
    <TagName
      aria-labelledby={TagName === 'article' && title ? headingId : undefined}
      {...attrs}
      className={cn('@container/paragraph', className)}
      data-name="Paragraph"
    >
      <div className={cn({ [th.layout]: image })}>
        {image && (
          <Image
            {...image}
            className={cn('object-cover', th.image, { [th.reversed]: reversed }, image.className)}
          />
        )}
        <div className="flex-1 space-y-12">
          {(title || subtitle) && (
            <hgroup>
              {title && (
                <Heading className={th.title} id={headingId} level={hLevel}>
                  {title}
                </Heading>
              )}
              {subtitle && <p className={th.subtitle}>{subtitle}</p>}
            </hgroup>
          )}
          <ExpandableText
            buttonClassName="text-b3"
            className="text-b3"
            id={`${baseId}-text`}
            labels={labels}
            lineClamps="line-clamp-3"
          >
            <Markdown
              className={cn('prose', '[&_p+p]:mt-24')}
              options={DESCRIPTION_MARKDOWN_OPTIONS}
            >
              {description}
            </Markdown>
            {button && <ParagraphButton {...button} />}
          </ExpandableText>
          {children}
        </div>
        {slot}
      </div>
    </TagName>
  );
};
```

## Themes

`theme` changes the image shape, the layout breakpoint and the typography.

<ComponentPreview align="start" name="paragraph-themes-demo" />

| Theme     | Image                                                 | Side by side from | Title                    | Subtitle                 |
| --------- | ----------------------------------------------------- | ----------------- | ------------------------ | ------------------------ |
| `default` | Horizontal, full column width (half/half)             | `@xl`             | `text-b2 font-semibold`  | `text-b3`                |
| `circle`  | Round, max `240px`, centered while stacked            | `@xl`             | `font-serif text-h3`     | `text-b2 font-semibold`  |
| `event`   | Horizontal while stacked, then vertical `172px` wide  | `@lg`             | `font-serif text-h5`     | `text-b3 font-semibold`  |

`reversed` moves the image after the text, only once the layout is side by side: while stacked, the image always comes first.

In a chapter, alternate the image side from one paragraph to the next (`reversed={index % 2 !== 0}`). The chapter title is not part of `Paragraph`: render it above the paragraphs (Newsreader `font-serif text-h3`, `32px` above the first paragraph) and lower the paragraphs' `hLevel` accordingly.

## With a button

`button` renders an outline black link after the description. It lives inside the expandable area, so it is revealed with the rest of the description when the text is clamped. It sits `20px` under the text while stacked and `40px` once side by side, and `24px` above the "see less" toggle.

<ComponentPreview align="start" name="paragraph-button-demo" />

## Extra content

- `children` are rendered at the end of the text column, after the description.
- `slot` is rendered after the text column, as a sibling of the image: it takes part in the side-by-side layout (a third grid cell in the `default` theme, a flex item otherwise).

## API Reference

### Paragraph

Extends the attributes of `div` (except `slot`), forwarded to the root element.

| Prop          | Type                              | Default     | Description                                                                                  |
| ------------- | --------------------------------- | ----------- | -------------------------------------------------------------------------------------------- |
| `description` | `string`                          | —           | **Required**. Text of the paragraph. Accepts HTML, rendered with `Prose`.                    |
| `labels`      | `ExpandableTextLabels`            | —           | **Required**. `expand` and `reduce` labels of the "read more" toggle.                        |
| `title`       | `string`                          | `undefined` | Title, rendered as a heading.                                                                |
| `subtitle`    | `string`                          | `undefined` | Subtitle, rendered as a `p` under the title in the `hgroup`.                                 |
| `hLevel`      | `1 \| 2 \| 3 \| 4 \| 5 \| 6`      | `2`         | Level of the title heading.                                                                  |
| `image`       | `ImageProps \| null`              | `undefined` | Image next to the text. Accepts `sources` to render a `picture`.                             |
| `reversed`    | `boolean`                         | `false`     | Places the image after the text in the side-by-side layout.                                  |
| `theme`       | `'default' \| 'circle' \| 'event'` | `'default'` | Image shape, layout breakpoint and typography.                                               |
| `button`      | `ParagraphButtonProps \| null`    | `undefined` | Outline link rendered after the description.                                                 |
| `as`          | `'div' \| 'article'`              | `'div'`     | HTML tag of the root element.                                                                |
| `slot`        | `ReactNode`                       | `undefined` | Content rendered after the text column, in the image layout.                                 |
| `children`    | `ReactNode`                       | `undefined` | Content rendered at the end of the text column.                                              |
| `className`   | `string`                          | `undefined` | Classes of the root element, merged with `cn`.                                               |
| `id`          | `string`                          | `undefined` | Root id. The heading gets `{id}-heading` and the description `{id}-text`.                   |

### ParagraphButtonProps

Extends the attributes of `a` (except `children`).

| Field   | Type     | Description                     |
| ------- | -------- | ------------------------------- |
| `label` | `string` | **Required**. Text of the link. |

## Accessibility

- The title and subtitle are grouped in an `hgroup`, so the subtitle is not announced as a separate heading. The `hgroup` is omitted when both are empty.
- With `as="article"` and a `title`, the article gets `aria-labelledby` pointing to the heading, so assistive technologies announce its name.
- The "see more" toggle exposes `aria-expanded` and `aria-controls` (see [ExpandableText](/docs/components/expandable-text)). It only appears when the description overflows 3 lines.
- Give the `image` a meaningful `alt`, or `alt=""` when it is decorative.

## Notes

- `description` is injected as HTML: only pass trusted, sanitised content.
- `Paragraph` is a client component (`'use client'`) because `ExpandableText` measures the text.
- Without `id`, ids are generated with `useId`, so they stay stable between server and client renders.
- The `button` is clamped with the description: when the text is long, it only shows once expanded.
