# Breathing text

> Weight breathes in and out over time.

- Type: Component (physics)
- Page: https://textlab.dev/components/breathing-text
- Markdown: https://textlab.dev/components/breathing-text.md
- Registry item: https://textlab.dev/r/breathing-text.json
- Source file: `registry/textlab/breathing-text/breathing-text.tsx`
- Dependencies: none
- Variants: breathe, heartbeat
- Keywords: variable font, breathing, weight, pulse, heartbeat, calm

## When to use

- Meditation, health and calm brands.
- Idle and waiting states that should feel alive, not busy.

## When not to use

- Fonts without a weight axis.
- Layouts where width changes would push neighbours.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/breathing-text
pnpm dlx shadcn@latest add @textlab/breathing-text
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

```bash
npx shadcn@latest add https://textlab.dev/r/breathing-text.json
```

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { BreathingText } from "@/components/textlab/breathing-text/breathing-text";

export function Example() {
  return <BreathingText as="h2" text="Inhale, exhale" />;
}
```

## API reference

### BreathingText

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"breathe" \| "heartbeat"` | `"breathe"` | A slow swell through the word, or a double thump and rest. |
| `from` | `number` | `250` | Weight at rest. |
| `to` | `number` | `800` | Weight at the peak. |
| `speed` | `number` | `3.6` | Seconds per breath. |
| `font` | `string` | `var(--tl-font-flex)` | A variable font with a weight axis. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- Letters are aria-hidden; the text is read once.
- Slow and continuous, with no flashing.

## Motion

- The breath travels through the word 90 ms per letter.
- Heartbeat thumps twice in the first 40% of each cycle, then rests.

## Reduced motion

A still word at a medium weight.

## Performance

- A registered CSS property drives font-variation-settings; text reflows within the line each frame, so use it on short words.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: breathe, heartbeat.

## Related

- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Wave text](https://textlab.dev/components/wave-text.md): A sine wave runs through the line.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.

## Also in physics

- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.
- [Elastic text](https://textlab.dev/components/elastic-text.md): Drag a letter and the word stretches like rubber.
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.

## Source

### components/textlab/breathing-text/breathing-text.tsx

```tsx
"use client";

import type { CSSProperties, ElementType } from "react";
import { SrText } from "../lib/a11y";
import { Letters } from "../lib/letters";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type BreathingTextVariant = "breathe" | "heartbeat";

export interface BreathingTextProps {
  text: string;
  /** breathe swells slowly through the word, heartbeat thumps twice and rests. */
  variant?: BreathingTextVariant;
  /** Weight at rest and at the peak. */
  from?: number;
  to?: number;
  /** Seconds per breath. */
  speed?: number;
  /** Variable font with a weight axis. */
  font?: string;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

const CSS = `
@property --tl-wght { syntax: "<number>"; inherits: true; initial-value: 400; }
@keyframes tl-breathe { 0%, 100% { --tl-wght: var(--tl-from); } 50% { --tl-wght: var(--tl-to); } }
@keyframes tl-heartbeat { 0%, 40%, 100% { --tl-wght: var(--tl-from); } 10%, 26% { --tl-wght: var(--tl-to); } 18% { --tl-wght: calc((var(--tl-from) + var(--tl-to)) / 2); } }
.tl-breath [data-tl-char] { font-variation-settings: "wght" var(--tl-wght); }
@media (prefers-reduced-motion: reduce) { .tl-breath[data-motion="user"] [data-tl-char] { animation: none !important; } }
`;

/**
 * Weight breathes in and out, travelling through the word like a slow inhale. The weight is a
 * registered CSS property, so the browser interpolates it with no JavaScript.
 */
export function BreathingText({ text, variant = "breathe", from = 250, to = 800, speed = 3.6, font = "var(--tl-font-flex)", as: Tag = "span", reducedMotion = "user", className, style }: BreathingTextProps) {
  const reduced = useReduced(reducedMotion);
  if (reduced) {
    return (
      <Tag className={className} style={{ fontFamily: font, fontVariationSettings: `"wght" ${(from + to) / 2}`, ...style }}>
        {text}
      </Tag>
    );
  }
  const name = variant === "heartbeat" ? "tl-heartbeat" : "tl-breathe";
  return (
    <Tag
      className={["tl-breath", className].filter(Boolean).join(" ")}
      data-motion={reducedMotion === "user" ? "user" : undefined}
      style={{ fontFamily: font, "--tl-from": from, "--tl-to": to, ...style } as CSSProperties}
    >
      <style href="tl-breathing-text" precedence="default">{CSS}</style>
      <SrText>{text}</SrText>
      <span aria-hidden="true">
        <Letters
          text={text}
          charStyle={(i) => ({
            animation: `${name} ${speed}s ${variant === "heartbeat" ? "ease-out" : "ease-in-out"} infinite`,
            animationDelay: `${variant === "heartbeat" ? 0 : -(speed * 2) + i * 0.09}s`,
          })}
        />
      </span>
    </Tag>
  );
}

export default BreathingText;
```

Full library index: https://textlab.dev/llms.txt
