# Text envelope playground
[Full-page experience](/experience/text-envelope/) · [Open playground](/playground/#/examples/drawing/text-envelope)

Type your own words and freely drag curve handles to shape them between two or more paths.

## Try it

Type your text, drag the curve handles, add guides, and try rotation or skew.

## What to learn

- Deform real text outlines between editable guides.
- Compose mappings before approximating the final shape.

## Integration boundaries

- Guides can cross and fold; each transformation preserves the original text geometry.
- The outline font is owned by the example and released when it unmounts.

This is a Pibbl Canvas example. Its mount owns its runtime resources; call its controller's dispose method when removing it. The preview starts automatically unless reduced motion is enabled; Stop disposes the mount. Preserve the source's logical canvas size and aspect ratio. Native HTML is not captured by Canvas filters.

## Related APIs

- [loadOutlineFont](/reference/functions/text-geometry/)
- [createTextGeometry](/reference/functions/text-geometry/)
- [PathGeometry](/reference/functions/path-geometry/)
- [transformPath](/reference/functions/path-transforms/)
- [envelopeTransform](/reference/functions/path-transforms/)
- [composeTransforms](/reference/functions/path-transforms/)
- [affineTransform](/reference/functions/path-transforms/)
- [Path](/reference/components/path/)
- [Text](/reference/components/text/)
- [HtmlBox](/reference/components/html-box/)

## Complete source

Host setup for this source: use a canvas with width 960 and height 700, compile with `jsx: "react-jsx"` and `jsxImportSource: "@pibbl/core"`, import its default `mount`, and call `const controller = mount(canvas)` after attaching the canvas. Call `controller.dispose()` before removing it.

### text-envelope.example.tsx

```tsx
import {
  pibbl, Group, Path, PathGeometry, Circle, Line, Rectangle, Text, HtmlBox, signal,
  envelopeTransform, affineTransform, composeTransforms, transformPath,
  type PibblController, type PibblPointerEvent, type HtmlMountCallback, type PathPoint, type PathRect,
} from '@pibbl/core';
import { loadOutlineFont, createTextGeometry, type OutlineFont } from '@pibbl/text';

type Curve = [PathPoint, PathPoint, PathPoint, PathPoint];
const colors = ['#dc6743', '#4d9089', '#9065b0', '#bb8b31', '#4478b2'];
function curvePath(points: Curve) {
  const p = new PathGeometry(); p.moveTo(points[0].x, points[0].y);
  p.bezierCurveTo(points[1].x, points[1].y, points[2].x, points[2].y, points[3].x, points[3].y); return p;
}
function preset(name: string): Curve[] {
  if (name === 'Wave') return [
    [{ x: 110, y: 340 }, { x: 340, y: 200 }, { x: 590, y: 470 }, { x: 850, y: 320 }],
    [{ x: 110, y: 500 }, { x: 340, y: 350 }, { x: 590, y: 630 }, { x: 850, y: 490 }],
  ];
  if (name === 'Pinch') return [
    [{ x: 110, y: 290 }, { x: 340, y: 420 }, { x: 620, y: 420 }, { x: 850, y: 290 }],
    [{ x: 110, y: 550 }, { x: 340, y: 420 }, { x: 620, y: 420 }, { x: 850, y: 550 }],
  ];
  if (name === 'Straight') return [
    [{ x: 110, y: 320 }, { x: 355, y: 320 }, { x: 605, y: 320 }, { x: 850, y: 320 }],
    [{ x: 110, y: 510 }, { x: 355, y: 510 }, { x: 605, y: 510 }, { x: 850, y: 510 }],
  ];
  return [
    [{ x: 110, y: 340 }, { x: 290, y: 210 }, { x: 620, y: 300 }, { x: 850, y: 270 }],
    [{ x: 110, y: 535 }, { x: 330, y: 490 }, { x: 620, y: 610 }, { x: 850, y: 420 }],
  ];
}

export default function mount(canvas: HTMLCanvasElement): PibblController {
  const abort = new AbortController(), generation = signal(0);
  let font: OutlineFont | undefined, disposed = false;
  let curves = preset('Ribbon'), content = 'BEND THE RULES', rotation = 0, skew = 0, showGuides = true;
  let source = new PathGeometry(), bounds: PathRect = { x: 0, y: 0, width: 1, height: 1 };
  let output = new PathGeometry(), grid = new PathGeometry(), message = 'Loading outline font…', valid = false;
  let placement = new DOMMatrix(), selected = 0, lastTime = 0;
  let drag: { pointer: number; curve: number; point: number } | undefined;
  const update = () => {
    if (!font || disposed) return;
    const start = performance.now();
    try {
      placement = new DOMMatrix().translate(480, 420).rotate(rotation).skewX(skew).translate(-480, -420);
      const guides = curves.map((points, i) => ({ at: i / (curves.length - 1), path: curvePath(points) }));
      const mapping = composeTransforms(envelopeTransform({ source: bounds, guides }), affineTransform(placement));
      const next = transformPath(source, mapping, { tolerance: 0.45, maxSegments: 80_000 });
      const lines = new PathGeometry();
      for (let i = 0; i <= 12; i++) { const x = bounds.x + bounds.width * i / 12; lines.moveTo(x, bounds.y); lines.lineTo(x, bounds.y + bounds.height); }
      for (let i = 0; i <= 4; i++) { const y = bounds.y + bounds.height * i / 4; lines.moveTo(bounds.x, y); lines.lineTo(bounds.x + bounds.width, y); }
      const nextGrid = transformPath(lines, mapping, { tolerance: 0.7, maxSegments: 30_000 });
      output = next; grid = nextGrid; valid = true;
      lastTime = performance.now() - start;
      message = source.segmentCount ? `${curves.length} guides · ${output.segmentCount.toLocaleString()} outline segments · ${lastTime.toFixed(0)} ms` : 'Type something to shape.';
    } catch (error) {
      valid = false; message = `Preview paused: ${error instanceof Error ? error.message : String(error)}`;
    }
    generation.update(n => n + 1);
  };
  const shape = () => {
    if (!font) return;
    try {
      const text = createTextGeometry(font, content, { fontSize: 150 });
      source = text.geometry; bounds = text.inkBounds ?? { x: 0, y: 0, width: 1, height: 1 };
      update();
    } catch (error) { valid = false; message = `Cannot outline this text: ${error instanceof Error ? error.message : String(error)}`; generation.update(n => n + 1); }
  };
  const controls: HtmlMountCallback<unknown> = (element, _data, lifetime) => {
    element.style.cssText += ';font:13px system-ui;color:#263e3b;display:flex;flex-wrap:wrap;gap:10px;align-content:start';
    const input = document.createElement('input'); input.value = content; input.maxLength = 48; input.setAttribute('aria-label', 'Text to warp');
    input.style.cssText = 'flex:1;min-width:240px;height:42px;box-sizing:border-box;padding:10px 14px;font:600 17px system-ui;border:1px solid #b9c9be;border-radius:7px;background:#fffdf7;color:#233b36';
    input.addEventListener('input', () => { content = input.value; shape(); }, { signal: lifetime }); element.append(input);
    const select = document.createElement('select'); select.setAttribute('aria-label', 'Envelope preset');
    for (const name of ['Ribbon', 'Wave', 'Pinch', 'Straight']) { const o = document.createElement('option'); o.textContent = name; select.append(o); }
    select.style.cssText = 'height:42px;padding:0 14px;font:inherit;border:1px solid #b9c9be;border-radius:7px;background:#fffdf7;color:#233b36';
    select.addEventListener('change', () => { curves = preset(select.value); selected = 0; update(); }, { signal: lifetime }); element.append(select);
    const button = (label: string, action: () => void) => {
      const b = document.createElement('button'); b.textContent = label; b.type = 'button';
      b.style.cssText = 'height:42px;padding:0 13px;font:600 12px system-ui;border:1px solid #b9c9be;border-radius:7px;background:#e7ede2;color:#233b36;cursor:pointer';
      b.addEventListener('click', action, { signal: lifetime }); element.append(b); return b;
    };
    const addGuide = button('+ Guide', () => {
      if (curves.length >= 5) return;
      const i = Math.min(selected, curves.length - 2), a = curves[i], b = curves[i + 1];
      curves.splice(i + 1, 0, a.map((p, j) => ({ x: (p.x + b[j].x) / 2, y: (p.y + b[j].y) / 2 })) as Curve);
      selected = i + 1; update();
    }); addGuide.title = 'Insert a guide after the selected curve (up to five).';
    button('− Guide', () => { if (curves.length > 2) { curves.splice(Math.max(1, Math.min(selected, curves.length - 2)), 1); selected = 0; update(); } });
    button('Reset', () => { curves = preset('Ribbon'); rotation = skew = selected = 0; rotationInput.value = skewInput.value = '0'; select.value = 'Ribbon'; update(); });
    const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:22px;align-items:center;flex-wrap:wrap;width:100%;padding-top:4px'; element.append(row);
    const range = (label: string, min: number, max: number, change: (n: number) => void) => {
      const wrapper = document.createElement('label'); wrapper.style.cssText = 'display:flex;gap:9px;align-items:center'; wrapper.append(label);
      const slider = document.createElement('input'); slider.type = 'range'; slider.min = String(min); slider.max = String(max); slider.value = '0'; slider.setAttribute('aria-label', label); slider.style.cssText = 'width:135px;accent-color:#367c6c';
      slider.addEventListener('input', () => { change(Number(slider.value)); update(); }, { signal: lifetime }); wrapper.append(slider); row.append(wrapper); return slider;
    };
    const rotationInput = range('Rotate', -30, 30, n => { rotation = n; });
    const skewInput = range('Skew', -30, 30, n => { skew = n; });
    const label = document.createElement('label'), toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = true;
    toggle.addEventListener('change', () => { showGuides = toggle.checked; generation.update(n => n + 1); }, { signal: lifetime }); label.append(toggle, ' Show guides'); row.append(label);
    return () => {};
  };
  const stop = (event: PibblPointerEvent) => {
    if (drag?.pointer !== event.pointerId) return; drag = undefined;
    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
  };
  function Scene() {
    generation.get();
    return <>
      <Rectangle style={{ width: 960, height: 700, fill: '#f6f3e9' }} pointerEvents="none" />
      <Text style={{ left: 28, top: 24, font: '800 28px system-ui', fill: '#233b36' }} pointerEvents="none">Bend the rules.</Text>
      <Text style={{ left: 932, top: 34, textAlign: 'right', font: '600 11px system-ui', fill: '#47796d' }} pointerEvents="none">TEXT / ENVELOPE / COMPOSITION</Text>
      <Text style={{ left: 28, top: 62, font: '14px system-ui', fill: '#63746b' }} pointerEvents="none">Your words. Your curves. Drag the solid endpoints and hollow control handles anywhere.</Text>
      <Group style={{ translateX: 28, translateY: 94 }}><HtmlBox mount={controls} data={null} style={{ width: 904, height: 108 }} /></Group>
      <Rectangle style={{ left: 20, top: 220, width: 920, height: 420, fill: '#eeeee3' }} pointerEvents="none" />
      {showGuides && <Path style={{ d: grid, stroke: '#bdc9bb', strokeWidth: 0.8 }} pointerEvents="none" />}
      <Path style={{ d: output, fill: '#233b36', stroke: '#233b36', strokeWidth: 1.5, opacity: valid ? 1 : 0.3 }} pointerEvents="none" />
      {showGuides && curves.map((points, i) => {
        const color = colors[i], shown = points.map(p => placement.transformPoint(p));
        const path = curvePath(points); path.transform(placement);
        return <>
          <Path key={`curve-${i}`} style={{ d: path, stroke: color, strokeWidth: selected === i ? 2.5 : 1.5 }} pointerEvents="none" />
          <Line key={`arm-a-${i}`} style={{ coords: [[shown[0].x, shown[0].y], [shown[1].x, shown[1].y]], stroke: color, strokeWidth: 1 }} pointerEvents="none" />
          <Line key={`arm-b-${i}`} style={{ coords: [[shown[2].x, shown[2].y], [shown[3].x, shown[3].y]], stroke: color, strokeWidth: 1 }} pointerEvents="none" />
          {shown.map((p, j) => <Circle key={`handle-${i}-${j}`} style={{ cx: p.x, cy: p.y, radius: j === 0 || j === 3 ? 8 : 7, fill: j === 0 || j === 3 ? color : '#fffdf7', stroke: color, strokeWidth: 2, cursor: 'grab' }}
            onPointerDown={event => { selected = i; event.currentTarget.setPointerCapture(event.pointerId); drag = { pointer: event.pointerId, curve: i, point: j }; generation.update(n => n + 1); }}
            onPointerMove={event => {
              if (drag?.pointer !== event.pointerId || drag.curve !== i || drag.point !== j) return;
              const q = placement.inverse().transformPoint({ x: event.x, y: event.y });
              curves[i][j] = { x: q.x, y: q.y }; update();
            }} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={() => { drag = undefined; }} />)}
        </>;
      })}
      <Text style={{ left: 28, top: 656, font: '12px system-ui', fill: valid ? '#63746b' : '#a33b25' }} pointerEvents="none">{message}</Text>
      <Text style={{ left: 932, top: 678, textAlign: 'right', font: '11px system-ui', fill: '#63746b' }} pointerEvents="none">Outline → envelope → rotate & skew · original text stays intact</Text>
    </>;
  }
  const placeholder = document.createComment('text-envelope canvas'); canvas.before(placeholder);
  const wrapper = document.createElement('div'); wrapper.style.cssText = 'position:relative;width:100%;height:100%;isolation:isolate';
  const overlay = document.createElement('div'); overlay.style.cssText = 'position:absolute;inset:0;z-index:1;pointer-events:none';
  const previousStyle = canvas.getAttribute('style'); canvas.style.cssText = 'display:block;width:100%;height:100%;touch-action:none';
  placeholder.after(wrapper); wrapper.append(canvas, overlay);
  let controller: PibblController | undefined;
  const dispose = () => {
    if (disposed) return; disposed = true; abort.abort(); font?.dispose(); drag = undefined;
    try { controller?.dispose(); } finally {
      if (canvas.parentElement === wrapper) placeholder.after(canvas);
      if (previousStyle === null) canvas.removeAttribute('style'); else canvas.setAttribute('style', previousStyle);
      wrapper.remove(); placeholder.remove();
    }
  };
  try { controller = pibbl(canvas, <Scene />, { viewport: { width: 960, height: 700 }, htmlOverlay: overlay }); }
  catch (error) { dispose(); throw error; }
  void loadOutlineFont(new URL('/pibbl-text/Lato-Regular.ttf', document.baseURI), { signal: abort.signal }).then(value => {
    if (disposed) { value.dispose(); return; } font = value; shape();
  }).catch(error => { if (!disposed) { message = `Font loading failed: ${String(error)}`; generation.update(n => n + 1); } });
  return { dispose };
}

```

## Documentation version

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.
