Text envelope playground
Full-page experience · Open playground
Type your own words and freely drag curve handles to shape them between two or more paths.
Try it
Section titled “Try it”Type your text, drag the curve handles, add guides, and try rotation or skew.
What to learn
Section titled “What to learn”- Deform real text outlines between editable guides.
- Compose mappings before approximating the final shape.
Integration boundaries
Section titled “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
Section titled “Related APIs”- loadOutlineFont
- createTextGeometry
- PathGeometry
- transformPath
- envelopeTransform
- composeTransforms
- affineTransform
- Path
- Text
- HtmlBox
Complete source
Section titled “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
Section titled “text-envelope.example.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
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.