Subframe
Animation & motionText on a curved path

Path typesetter

Editable type stretches across a cubic curve with two direct manipulation handles.

Drag a bend or edit the text.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 *
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use client"

import { useId, useRef, useState } from "react"

type Point = { x: number; y: number }
const initial = [
  { x: 145, y: 36 },
  { x: 435, y: 266 },
]
const bound = (p: Point) => ({ x: Math.max(48, Math.min(552, p.x)), y: Math.max(32, Math.min(268, p.y)) })
function curveLength(a: Point, b: Point) {
  let length = 0,
    last = { x: 36, y: 164 }
  for (let i = 1; i <= 40; i++) {
    const t = i / 40,
      s = 1 - t
    const p = {
      x: s ** 3 * 36 + 3 * s * s * t * a.x + 3 * s * t * t * b.x + t ** 3 * 564,
      y: s ** 3 * 164 + 3 * s * s * t * a.y + 3 * s * t * t * b.y + t ** 3 * 164,
    }
    length += Math.hypot(p.x - last.x, p.y - last.y)
    last = p
  }
  return length
}
export default function PathTypesetter({ active = true }: { active?: boolean }) {
  const id = useId(),
    field = useRef<HTMLDivElement>(null)
  const [points, setPoints] = useState<Point[]>(initial),
    [text, setText] = useState("Free form")
  const drag = useRef<{ pointerId: number; index: number; start: Point; offset: Point } | null>(null)
  const move = (index: number, p: Point) => setPoints((value) => value.map((v, i) => (i === index ? bound(p) : v)))
  const locate = (x: number, y: number) => {
    const b = field.current!.getBoundingClientRect()
    return { x: ((x - b.left) / b.width) * 600, y: ((y - b.top) / b.height) * 300 }
  }
  const d = `M36 164 C${points[0].x} ${points[0].y} ${points[1].x} ${points[1].y} 564 164`
  return (
    <section className="sf-path-typesetter" data-active={active}>
      <div className="pt-field" ref={field}>
        <svg viewBox="0 0 600 300" preserveAspectRatio="none" aria-label={text || "Empty type path"} role="img">
          <defs>
            <path id={id} d={d} />
          </defs>
          <path
            d={`M36 164 L${points[0].x} ${points[0].y} M564 164 L${points[1].x} ${points[1].y}`}
            stroke="#ddd"
            fill="none"
            strokeDasharray="3 4"
          />
          <use href={`#${id}`} stroke="#bababa" fill="none" />
          <text
            fontFamily="Georgia,serif"
            fontStyle="italic"
            fontSize="91"
            fill="#191919"
            textLength={curveLength(points[0], points[1]) * 0.96}
            lengthAdjust="spacingAndGlyphs"
          >
            <textPath href={`#${id}`} startOffset="2%">
              {text}
            </textPath>
          </text>
          {[36, 564].map((x) => (
            <circle key={x} cx={x} cy="164" r="3" fill="#191919" />
          ))}
        </svg>
        {points.map((p, i) => (
          <button
            key={i}
            type="button"
            className="pt-handle"
            role="slider"
            aria-label={i ? "Second curve bend" : "First curve bend"}
            aria-valuemin={32}
            aria-valuemax={268}
            aria-valuenow={Math.round(p.y)}
            aria-valuetext={`X ${Math.round(p.x)}, Y ${Math.round(p.y)}. Arrow keys move the bend.`}
            style={{ left: `${p.x / 6}%`, top: `${p.y / 3}%` }}
            onPointerDown={(e) => {
              if (e.button !== 0 || !e.isPrimary || drag.current) return
              const at = locate(e.clientX, e.clientY)
              drag.current = { pointerId: e.pointerId, index: i, start: p, offset: { x: p.x - at.x, y: p.y - at.y } }
              e.currentTarget.setPointerCapture(e.pointerId)
            }}
            onPointerMove={(e) => {
              const a = drag.current
              if (!a || a.index !== i || a.pointerId !== e.pointerId) return
              const at = locate(e.clientX, e.clientY)
              move(i, { x: at.x + a.offset.x, y: at.y + a.offset.y })
            }}
            onPointerUp={(e) => {
              if (drag.current?.pointerId !== e.pointerId) return
              drag.current = null
            }}
            onPointerCancel={(e) => {
              if (drag.current?.pointerId !== e.pointerId) return
              move(i, drag.current.start)
              drag.current = null
            }}
            onLostPointerCapture={(e) => {
              if (drag.current?.pointerId !== e.pointerId) return
              drag.current = null
            }}
            onKeyDown={(e) => {
              const amount = e.shiftKey ? 1 : 10
              const directions: Record<string, Point> = {
                ArrowLeft: { x: -amount, y: 0 },
                ArrowRight: { x: amount, y: 0 },
                ArrowUp: { x: 0, y: -amount },
                ArrowDown: { x: 0, y: amount },
              }
              if (directions[e.key]) {
                e.preventDefault()
                move(i, { x: p.x + directions[e.key].x, y: p.y + directions[e.key].y })
              }
              if (e.key === "Home") {
                e.preventDefault()
                move(i, initial[i])
              }
            }}
          >
            <i />
          </button>
        ))}
      </div>
      <footer>
        <label htmlFor={`${id}-text`}>Text</label>
        <input
          id={`${id}-text`}
          value={text}
          maxLength={18}
          onChange={(e) => setText(e.target.value)}
          spellCheck={false}
        />
        <button type="button" onClick={() => setPoints(initial)} aria-label="Reset curve">
          ↺
        </button>
      </footer>
      <style>{`.sf-path-typesetter{height:100%;width:100%;min-height:280px;background:#fafafa;color:#191919;display:flex;flex-direction:column;overflow:hidden;font-family:Arial,sans-serif;box-sizing:border-box}.sf-path-typesetter *{box-sizing:border-box}.sf-path-typesetter .pt-field{position:relative;flex:1;min-height:0}.sf-path-typesetter svg{width:100%;height:100%;display:block;overflow:hidden}.sf-path-typesetter .pt-handle{position:absolute;width:44px;height:44px;padding:0;transform:translate(-50%,-50%);display:grid;place-items:center;border:0;background:none;touch-action:none;cursor:grab}.sf-path-typesetter .pt-handle i{width:11px;height:11px;background:#e92f21;border:2px solid #fff;box-shadow:0 0 0 1px #e92f21;border-radius:50%}.sf-path-typesetter .pt-handle:active{cursor:grabbing}.sf-path-typesetter footer{height:62px;margin:0 22px;display:flex;align-items:center;border-top:1px solid #ddd;gap:14px;flex:none}.sf-path-typesetter label{font:11px monospace;color:#686868}.sf-path-typesetter input{font:16px Georgia,serif;min-width:0;width:100%;height:44px;border:0;background:none;color:inherit;outline-offset:2px}.sf-path-typesetter footer button{width:44px;height:44px;flex:none;font:24px Arial;border:0;background:none;color:inherit;cursor:pointer}.sf-path-typesetter button:focus-visible,.sf-path-typesetter input:focus-visible{outline:2px solid #e92f21;outline-offset:2px}`}</style>
    </section>
  )
}

Where it works well

Shape a short typographic phrase while preserving its reading order.

Reduced motion

All edits are immediate; no autonomous animation.

Things to know

A short, single-line phrase up to 18 characters. SVG textPath metrics vary with available Georgia font metrics.

More examples