# Envelope

> An editor for a breakpoint ramp, such as an ADSR shape or an automation curve.

```tsx
import { Envelope } from "@/components/audio-ui/envelope";

export function EnvelopeSizeDefaultDemo() {
  return <Envelope aria-label="Envelope" />;
}
```

Edits a ramp made of breakpoints, such as an ADSR shape or an automation curve. The registry component wraps the `Envelope` primitive from `@audio-ui/react` and draws the points, the segments between them and a grid.

## Overview

- **Value:** `value` is a list of `{ x, y, curve? }` points. Both axes are normalized to 0…1, and the points are sorted by `x`. Map that range to your own unit, such as time or level.
- **Curves:** a point's `curve`, from -1 to 1, bends the segment that leaves it. 0, or no value, draws a straight line. A positive curve holds the start of the segment and steepens its end; a negative curve is its mirror image, rushing the start and holding the end. The bend is the exponential of an analog envelope stage, `(e^(k·t) - 1) / (e^k - 1)` with `k` the curve times 11, so neither end turns vertical. The last point has no segment.
- **Pointer:** drag a point to move it. Double-click empty space to add a point, or double-click a point to remove it. A point stays between its neighbors, so points never cross.
- **Point count:** points are added and removed between `minPoints` (default `2`, a start and an end) and `maxPoints`. Set both to the same number to fix the shape.
- **Grid:** `snap` sets the grid that points snap to, for example `0.25` for quarters, or one step per axis, `{ x: 0.125, y: 0 }`, to snap across and leave the level free. Hold Cmd/Ctrl to move off the grid. Without `snap`, points move freely. The registry component draws the grid.
- **Fine control:** hold Shift while dragging to move a point at a quarter of the pointer's speed. The registry component shows the point's value above it while it is dragged or focused.
- **Accessibility:** the points share one tab stop. The arrow keys move the focused point, and Delete or Backspace removes it. Each point is a `role="slider"`. The registry component names the points "Point n of N". With the primitive, set `aria-label` and `getAriaValueText` yourself.

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/envelope
```

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/react class-variance-authority
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/envelope.tsx"
"use client";

import { Envelope as EnvelopePrimitive } from "@audio-ui/react";
import { cva, type VariantProps } from "class-variance-authority";
import { type CSSProperties, useCallback } from "react";
import { cn } from "@/lib/utils";

const envelopeVariants = cva("", {
  defaultVariants: {
    size: "default",
  },
  variants: {
    size: {
      default: "h-48",
      lg: "h-64",
      sm: "h-32",
    },
  },
});

/** The frame's room for the end points, which the grid's lines are measured from. */
const INSET = "0.875rem";
const DEFAULT_GRID_STEP = 0.25;
/** A segment's bend as a signed percentage: positive holds the start of the segment, negative the end. */
const describeCurve = (curve = 0) =>
  `Curve ${curve > 0 ? "+" : ""}${Math.round(curve * 100)}%`;
/** Above this level a point's label sits under it, not over it. */
const LABEL_FLIP_ABOVE = 0.8;

/** A grid step, or the default one where an axis has none. */
const gridStep = (step: number | undefined) =>
  step && step > 0 ? step : DEFAULT_GRID_STEP;

/** A line every step of `snap` across and down, spread over the whole frame like a plot's: they sit on the data's own coordinates, so a point is on a line at 0, ¼, ½, ¾ and 1. */
function gridLines(snap: EnvelopePrimitive.Root.Props["snap"]): CSSProperties {
  const [x, y] = typeof snap === "object" ? [snap.x, snap.y] : [snap, snap];
  const share = (step: number | undefined) =>
    `calc((100% - 2 * ${INSET}) * ${gridStep(step)})`;
  return {
    backgroundImage:
      "linear-gradient(to right, var(--border) 1px, transparent 1px), linear-gradient(to bottom, var(--border) 1px, transparent 1px)",
    backgroundPosition: `${INSET} 0, 0 ${INSET}`,
    backgroundSize: `${share(x)} 100%, 100% ${share(y)}`,
  };
}

type Domain = readonly [number, number];

/** A share of the axis as a value in the axis's own units, to three significant digits. */
function describeAxis(share: number, domain?: Domain, unit = "") {
  const [min, max] = domain ?? [0, 100];
  const value = Number((min + share * (max - min)).toPrecision(3));
  const text = domain ? String(value) : `${Math.round(share * 100)}`;
  const suffix = domain ? unit : "%";
  return suffix === "%" ? `${text}%` : `${text} ${suffix}`.trim();
}

interface EnvelopeProps
  extends EnvelopePrimitive.Root.Props,
    VariantProps<typeof envelopeVariants> {
  /** Whether a handle on each segment bends it. @default true */
  curves?: boolean;
  /** Prints a point's value in the label that shows while it is dragged or focused, and in its accessible value text. Defaults to the values in `xDomain` and `yDomain`, or percentages. */
  formatValue?: (point: { x: number; y: number }) => string;
  /** The values the horizontal axis runs from and to, for the labels: `[0, 2000]` with `xUnit="ms"`. Unset, it reads in percent. */
  xDomain?: Domain;
  xUnit?: string;
  /** The values the vertical axis runs from and to, for the labels. */
  yDomain?: Domain;
  yUnit?: string;
}

/**
 * The frame has room inside it, so the first and last points sit within it
 * instead of on its edge. Drag a point to move it, the small handle on a
 * segment to bend it, and hold Shift for a fine drag. With `snap`, the grid's
 * lines are the snapping ones.
 */
export function Envelope({
  className,
  curves = true,
  formatValue,
  size,
  snap,
  xDomain,
  xUnit,
  yDomain,
  yUnit,
  ...props
}: EnvelopeProps) {
  const describe = useCallback(
    (point: { x: number; y: number }) =>
      formatValue
        ? formatValue(point)
        : `${describeAxis(point.x, xDomain, xUnit)} · ${describeAxis(point.y, yDomain, yUnit)}`,
    [formatValue, xDomain, xUnit, yDomain, yUnit]
  );

  return (
    <div
      className={cn(
        "rounded-xl border border-transparent bg-card ring-1 ring-foreground/10 transition-[color,border-color,box-shadow,opacity] duration-150 ease-out hover:ring-3 hover:ring-ring/50 has-focus-visible:ring-3 has-focus-visible:ring-ring/50 active:ring-3 active:ring-ring/50 has-focus-visible:border-ring active:border-ring motion-reduce:transition-none group/envelope box-border w-full cursor-crosshair touch-none select-none p-3.5",
        "has-data-disabled:pointer-events-none has-data-disabled:opacity-50",
        envelopeVariants({ size }),
        className
      )}
      data-slot="envelope"
      style={gridLines(snap)}
    >
      <EnvelopePrimitive.Root
        className="size-full data-disabled:cursor-not-allowed"
        role="group"
        snap={snap}
        {...props}
      >
        <EnvelopePrimitive.Graph className="size-full overflow-visible">
          <EnvelopePrimitive.Fill className="fill-primary/15 stroke-none" />
          <EnvelopePrimitive.Line className="stroke-primary stroke-2 [stroke-linejoin:round]" />
        </EnvelopePrimitive.Graph>
        {curves ? (
          <EnvelopePrimitive.Segments>
            {(point, index) => (
              <EnvelopePrimitive.Segment
                aria-label={`Curve ${index + 1}`}
                className="size-3 rounded-full border border-primary bg-background shadow-xs transition-[box-shadow,opacity] hover:ring-4 hover:ring-ring/30 focus-visible:ring-4 focus-visible:ring-ring/30 data-dragging:ring-4 data-dragging:ring-ring/30 data-flat:hidden motion-reduce:transition-none group/segment cursor-ns-resize opacity-0 outline-none after:absolute after:-inset-2 pointer-coarse:opacity-100 pointer-coarse:after:-inset-4 group-focus-within/envelope:opacity-100 group-hover/envelope:opacity-100 data-dragging:opacity-100"
                index={index}
                key={index}
              >
                <span
                  aria-hidden="true"
                  className="rounded-full bg-popover px-1.5 py-0.5 font-mono text-[10px] text-popover-foreground shadow-md ring-1 ring-foreground/10 -translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap group-data-dragging/segment:block"
                >
                  {describeCurve(point.curve)}
                </span>
              </EnvelopePrimitive.Segment>
            )}
          </EnvelopePrimitive.Segments>
        ) : null}
        <EnvelopePrimitive.Points>
          {(point, index, points) => (
            <EnvelopePrimitive.Point
              aria-label={`Point ${index + 1} of ${points.length}`}
              className="rounded-full border-2 border-primary bg-background shadow-xs data-active:bg-primary transition-[box-shadow] duration-150 ease-out hover:ring-4 hover:ring-ring/30 focus-within:ring-4 focus-within:ring-ring/30 data-dragging:ring-4 data-dragging:ring-ring/30 motion-reduce:transition-none group/point size-4 cursor-grab data-dragging:bg-primary focus-visible:bg-primary touch-none select-none outline-none after:absolute after:-inset-2 pointer-coarse:after:-inset-3.5 active:cursor-grabbing"
              getAriaValueText={describe}
              index={index}
              key={index}
            >
              <span
                aria-hidden="true"
                className={cn(
                  "rounded-full bg-popover px-1.5 py-0.5 font-mono text-[10px] text-popover-foreground shadow-md ring-1 ring-foreground/10 -translate-x-1/2 pointer-events-none absolute left-1/2 z-10 hidden whitespace-nowrap group-hover/point:block group-data-dragging/point:block group-focus-visible/point:block",
                  // Near the top the label goes under the point, so it stays inside the page.
                  point.y > LABEL_FLIP_ABOVE
                    ? "top-full mt-2"
                    : "bottom-full mb-2"
                )}
              >
                {describe(point)}
              </span>
            </EnvelopePrimitive.Point>
          )}
        </EnvelopePrimitive.Points>
      </EnvelopePrimitive.Root>
    </div>
  );
}
```

**3. Update the import paths to match your project setup.**

## Anatomy

Import the primitive and assemble its parts.

```tsx
import { Envelope } from "@audio-ui/react";

<Envelope.Root>
  <Envelope.Graph>
    <Envelope.Fill />
    <Envelope.Line />
  </Envelope.Graph>
  <Envelope.Segments>
    <Envelope.Segment />
  </Envelope.Segments>
  <Envelope.Points>
    <Envelope.Point />
  </Envelope.Points>
</Envelope.Root>
```

## Usage

```tsx
import { Envelope } from "@/components/audio-ui/envelope";
```

**Uncontrolled:** set only the initial points.

```tsx
<Envelope
  defaultValue={[
    { x: 0, y: 0 },
    { x: 0.2, y: 1 },
    { x: 0.6, y: 0.5 },
    { x: 1, y: 0 },
  ]}
/>
```

**Controlled:** drive the points from state.

```tsx
const [points, setPoints] = useState(initialPoints);

<Envelope
  onValueChange={setPoints}
  onValueCommitted={(next, details) => console.log("Committed:", next, details.reason)}
  value={points}
/>
```

## Interaction

### Pointer

**Behavior**

| Gesture | Effect |
| --- | --- |
| Drag a point | Moves it; `x` stays between its neighbors and `y` within 0…1. Dragging never deletes a point. While dragging, the point follows the pointer even if a controlled `value` updates late. |
| Double-click empty space | Adds a point at that location, unless at `maxPoints`. Added on a bent segment it sits on the curve, and each half is bent to keep its shape. |
| Double-click a point | Removes it, unless at `minPoints`. |
| Hold Shift while dragging a point | Moves it at a quarter of the pointer's speed, for fine adjustments. The point does not jump when Shift is pressed or released mid-drag. |
| Alt + drag a segment | Bends it, like a DAW's automation lane: the segment's midpoint follows the pointer. A flat segment, or a press away from any segment, does nothing. |
| Alt + double-click a segment | Straightens it, instead of adding a point. |
| Alt + click a point | Removes it, unless at `minPoints`. |
| Drag a segment's handle | Bends the segment: its midpoint follows the pointer up or down. A segment between two points of the same level has nothing to bend. |
| Double-click a segment's handle | Straightens the segment. |
| Commit event | `onValueCommitted` fires when a drag that moved a point ends, and right away for an add or a remove. |

A refused add or remove sets `data-blocked` for a moment on the graph or the point, which the registry styles turn into a short shake.

### Keyboard

The points share one tab stop. On the focused point:

**Keys**

| Key | Effect |
| --- | --- |
| `ArrowUp` / `ArrowDown` | Nudge the focused point's `y` by `step` |
| `ArrowLeft` / `ArrowRight` | Nudge the focused point's `x` by `step` (clamped between neighbors) |
| `Shift` + arrow | Nudge by 10 steps |
| `Delete` / `Backspace` | Remove the focused point, unless at `minPoints` |

Each segment has a handle on its curve. The handles share one tab stop:

**Keys**

| Key | Effect |
| --- | --- |
| `ArrowLeft` / `ArrowRight` | Move to the previous or next segment's handle |
| `ArrowUp` / `ArrowDown` | Lift or lower the handle, bending the segment by 5 steps |
| `Shift` + arrow | Bend by 50 steps |
| `Delete` / `Backspace` | Straighten the segment |

## API reference

### Root

Groups all parts of the breakpoint envelope and owns its points, each normalized to 0…1 on both axes and kept sorted by `x`. Points are dragged and nudged with the keyboard; a double click on the `Graph` adds one and a double click (or Delete) on a `Point` removes it, within `minPoints` / `maxPoints`. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `GeomPoint[]` |  | Uncontrolled starting points. Defaults to an ADSR-like shape. |
| `disabled` | `boolean` |  |  |
| `format` | `Intl.NumberFormatOptions` |  | Formats point coordinates for each point's `aria-valuetext`. |
| `locale` | `Intl.LocalesArgument` |  |  |
| `maxPoints` | `number` |  | Ceiling on the number of points; adding is blocked at this count. Equal to `minPoints`, the shape is fixed. |
| `minPoints` | `number` | `2` | Floor on the number of points; removal is blocked at this count. |
| `onValueChange` | `(value: GeomPoint[], eventDetails: ChangeEventDetails) => void` |  | Fires on every change: drag, keyboard, add and remove. |
| `onValueCommitted` | `(value: GeomPoint[], eventDetails: CommitEventDetails) => void` |  | Fires when an interaction that changed the points ends. |
| `step` | `number` | `0.01` | Keyboard step on both axes, in normalized units. |
| `snap` | `number \| { x: number; y: number }` | `0` | Grid the pointer snaps points to, in normalized units: 0.25 snaps to quarters, 0 is no grid, and `{ x, y }` sets a step for each axis (`{ x: 0.125, y: 0 }` snaps across and leaves the level free). Holding Cmd/Ctrl leaves the grid, and Shift's fine drag ignores it. Keys move by `step`. |
| `value` | `GeomPoint[]` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a point is being dragged. |

### Graph

The drawing surface, a 100×100 stretched viewBox: holds `Line` and `Fill`, and a double click on it adds a point. Carries `data-blocked` briefly when adding is refused at `maxPoints`. Renders a `<svg>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a point is being dragged. |
| `data-blocked` | Briefly true after an add was refused at `maxPoints`. |

### Line

The envelope curve through the points. Renders a `<polyline>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a point is being dragged. |

### Fill

The area under the curve. Renders a `<polyline>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a point is being dragged. |

### Points

Renders one child per point, in `x` order.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `(point: GeomPoint, index: number, points: GeomPoint[]) => React.ReactNode` |  |  |

### Point

A draggable handle for the point at `index`, positioned with `left`/`bottom` percentages over the `Graph`. It is a `role="slider"` on the point's level (`y`): give it an `aria-label`. One point at a time is the tab stop; arrow keys move it (Shift for larger steps), Delete or a double click removes it. Carries `data-blocked` briefly when removal is refused at `minPoints`. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `getAriaValueText` | `(point: GeomPoint, index: number) => string` |  | Returns the point's `aria-valuetext`. Defaults to the formatted `x, y`. |
| `index` | `number` |  | Index of the point in the (sorted) value. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a point is being dragged. |
| `data-blocked` | Briefly true after a removal was refused at `minPoints`. |
| `data-removable` | Whether the point can currently be removed. |

### Registry component

`Envelope` accepts style props (`size`, `className`) plus primitive props from `EnvelopePrimitive.Root.Props`. `aria-label` / `aria-labelledby` name the envelope group.

#### Value and state

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `{ x: number; y: number; curve?: number }[]` |  | Controlled points, normalized 0-1, sorted by `x`. `curve`, -1 to 1, bends the segment leaving the point. |
| `defaultValue` | `{ x: number; y: number; curve?: number }[]` | `ADSR-like 5-point shape` | Uncontrolled starting points. Defaults to an ADSR-like shape. |
| `minPoints` | `number` | `2` | Floor on the number of points; removal is blocked at this count. |
| `maxPoints` | `number` |  | Ceiling on the number of points; adding is blocked at this count. Equal to `minPoints`, the shape is fixed. |
| `step` | `number` | `0.01` | Keyboard step on both axes, in normalized units. |
| `disabled` | `boolean` | `false` | Whether the envelope is disabled. |
| `format` | `Intl.NumberFormatOptions` |  | Formats point coordinates for each point's `aria-valuetext`. |
| `onValueChange` | `(points, eventDetails) => void` |  | Fires on every change: drag, keyboard, add and remove. |
| `onValueCommitted` | `(points, eventDetails) => void` |  | Fires when an interaction that changed the points ends. |

#### Layout and display

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Size variant (sets the envelope height). |
| `curves` | `boolean` | `true` | Whether a handle on each segment bends it. |
| `formatValue` | `(point: { x: number; y: number }) => string` | `from the domains` | Prints a point's value in its label and its accessible value text. |
| `xDomain / yDomain` | `[number, number]` | `percent` | The values each axis runs from and to, so the labels read in real units: `xDomain={[0, 2000]}`. |
| `xUnit / yUnit` | `string` |  | The unit written after the value, such as `"ms"` or `"dB"`. |
| `className` | `string` |  | Classes for the graph surface. |

`@audio-ui/react/envelope` exposes `Envelope.Root`, `Envelope.Graph` (the SVG surface; double-click to add), `Envelope.Line` and `Envelope.Fill` (the curve and the area under it), `Envelope.Points` (renders one child per point), `Envelope.Point` (the draggable handle), `Envelope.Segments` (renders one child per segment) and `Envelope.Segment` (the handle on a segment's midpoint, which bends it). Every part accepts a `render` prop and `className` / `style` as functions of state; points expose `data-dragging`, `data-removable`, `data-blocked`, `data-disabled` and `data-index`, and segment handles `data-dragging`, `data-flat` and `data-disabled`.

## Examples

### ADSR with its stages read out

```tsx
"use client";

import { useState } from "react";
import { Envelope } from "@/components/audio-ui/envelope";

/** The span the envelope's x axis stands for, in milliseconds. */
const SPAN_MS = 2000;

const INITIAL_POINTS = [
  { x: 0, y: 0 },
  { x: 0.08, y: 1 },
  { x: 0.28, y: 0.6 },
  { x: 0.72, y: 0.6 },
  { x: 1, y: 0 },
];

const toMs = (share: number) => `${Math.round(share * SPAN_MS)} ms`;

/**
 * An ADSR from five points: the second is the end of the attack, the third
 * the end of the decay, and the fourth the start of the release. The
 * consumer maps the normalized axes to its own units, here milliseconds
 * and a level.
 */
export function EnvelopeAdsrDemo() {
  const [points, setPoints] = useState(INITIAL_POINTS);
  const [, peak, decayEnd, releaseStart] = points;

  return (
    <div className="flex w-full flex-col gap-3">
      <Envelope
        aria-label="ADSR envelope"
        maxPoints={5}
        minPoints={5}
        onValueChange={setPoints}
        value={points}
        xDomain={[0, SPAN_MS]}
        xUnit="ms"
        yDomain={[0, 100]}
        yUnit="%"
      />
      {peak && decayEnd && releaseStart ? (
        <dl className="grid grid-cols-4 gap-2 text-center font-mono text-xs tabular-nums">
          <div>
            <dt className="text-muted-foreground">Attack</dt>
            <dd>{toMs(peak.x)}</dd>
          </div>
          <div>
            <dt className="text-muted-foreground">Decay</dt>
            <dd>{toMs(decayEnd.x - peak.x)}</dd>
          </div>
          <div>
            <dt className="text-muted-foreground">Sustain</dt>
            <dd>{Math.round(decayEnd.y * 100)}%</dd>
          </div>
          <div>
            <dt className="text-muted-foreground">Release</dt>
            <dd>{toMs(1 - releaseStart.x)}</dd>
          </div>
        </dl>
      ) : null}
    </div>
  );
}
```

