Subframe
Animation & motionElastic draggable letters

Tethered type

Individual letters stretch away from their outline and spring back along a fine elastic tether.

Drag a letter and release it, or use arrow keys and Enter

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 { useEffect, useRef, useState } from "react"

type Glyph = { x: number; y: number; vx: number; vy: number; held: boolean }
const letters = ["P", "U", "L", "L"]

export default function TetheredType({ active = true }: { active?: boolean }) {
  const field = useRef<HTMLDivElement>(null)
  const glyphs = useRef<(HTMLButtonElement | null)[]>([])
  const lines = useRef<(SVGPathElement | null)[]>([])
  const controls = useRef<{
    reset: () => void
    nudge: (i: number, x: number, y: number) => void
    release: (i: number) => void
  }>({ reset() {}, nudge() {}, release() {} })
  const [selected, setSelected] = useState(-1)
  const [reduced, setReduced] = useState(true)

  useEffect(() => {
    const root = field.current
    if (!root) return
    const media = matchMedia("(prefers-reduced-motion: reduce)")
    let still = media.matches
    setReduced(still)
    const state: Glyph[] = letters.map(() => ({ x: 0, y: 0, vx: 0, vy: 0, held: false }))
    let frame = 0,
      last = 0,
      width = root.clientWidth,
      height = root.clientHeight
    let drag: { id: number; i: number; x: number; y: number; originX: number; originY: number } | null = null
    const center = (i: number) => ({ x: width * (0.2 + i * 0.2), y: height * 0.5 })
    const paint = () =>
      state.forEach((g, i) => {
        const button = glyphs.current[i],
          line = lines.current[i],
          c = center(i)
        if (button) button.style.transform = `translate(-50%, -50%) translate(${g.x}px, ${g.y}px)`
        if (line) {
          line.setAttribute(
            "d",
            `M${c.x},${c.y + 30} Q${c.x + g.x * 0.25},${c.y + 30 + Math.abs(g.x) * 0.18} ${c.x + g.x},${
              c.y + g.y + 30
            }`,
          )
          line.style.opacity = Math.hypot(g.x, g.y) > 3 ? "1" : "0"
        }
      })
    const reset = () => {
      cancelAnimationFrame(frame)
      frame = 0
      if (drag) {
        const button = glyphs.current[drag.i]
        if (button?.hasPointerCapture(drag.id)) button.releasePointerCapture(drag.id)
      }
      drag = null
      state.forEach((g) => Object.assign(g, { x: 0, y: 0, vx: 0, vy: 0, held: false }))
      paint()
    }
    const tick = (now: number) => {
      frame = 0
      const dt = Math.min((now - last) / 1000 || 1 / 60, 1 / 30)
      last = now
      let moving = false
      for (const g of state) {
        if (g.held) continue
        for (let j = 0; j < 2; j++) {
          g.vx += ((-340 * g.x - 23 * g.vx) * dt) / 2
          g.vy += ((-340 * g.y - 23 * g.vy) * dt) / 2
          g.x += (g.vx * dt) / 2
          g.y += (g.vy * dt) / 2
        }
        if (Math.hypot(g.x, g.y, g.vx, g.vy) < 0.15) Object.assign(g, { x: 0, y: 0, vx: 0, vy: 0 })
        else moving = true
      }
      paint()
      if (moving && active && !still && !document.hidden) frame = requestAnimationFrame(tick)
    }
    const wake = () => {
      if (!frame && active && !still && !document.hidden) {
        last = performance.now()
        frame = requestAnimationFrame(tick)
      }
    }
    const place = (i: number, x: number, y: number) => {
      const c = center(i)
      const halfWidth = (glyphs.current[i]?.offsetWidth ?? width * 0.21) / 2 + 2
      const halfHeight = (glyphs.current[i]?.offsetHeight ?? 110) / 2 + 2
      state[i].x = Math.max(halfWidth - c.x, Math.min(width - halfWidth - c.x, x))
      state[i].y = Math.max(halfHeight - c.y, Math.min(height - halfHeight - c.y, y))
      state[i].vx = state[i].vy = 0
      paint()
    }
    const release = (i: number) => {
      state[i].held = false
      if (still) {
        state[i].x = state[i].y = 0
        paint()
      } else wake()
    }
    controls.current = {
      reset,
      nudge: (i, x, y) => {
        if (!active) return
        state[i].held = true
        place(i, state[i].x + x, state[i].y + y)
      },
      release,
    }
    const down = (e: PointerEvent) => {
      if (!active || !e.isPrimary || e.button !== 0 || drag) return
      const button = (e.target as Element).closest<HTMLButtonElement>("button[data-glyph]")
      if (!button) return
      const i = Number(button.dataset.glyph)
      drag = { id: e.pointerId, i, x: e.clientX, y: e.clientY, originX: state[i].x, originY: state[i].y }
      state[i].held = true
      button.setPointerCapture(e.pointerId)
      setSelected(i)
    }
    const move = (e: PointerEvent) => {
      if (!drag || e.pointerId !== drag.id) return
      const rect = root.getBoundingClientRect()
      place(
        drag.i,
        drag.originX + ((e.clientX - drag.x) * root.clientWidth) / rect.width,
        drag.originY + ((e.clientY - drag.y) * root.clientHeight) / rect.height,
      )
    }
    const up = (e: PointerEvent) => {
      if (!drag || drag.id !== e.pointerId) return
      const { i, id } = drag
      drag = null
      if (glyphs.current[i]?.hasPointerCapture(id)) glyphs.current[i]?.releasePointerCapture(id)
      release(i)
    }
    const change = () => {
      still = media.matches
      setReduced(still)
      reset()
    }
    const visibility = () => {
      if (document.hidden) reset()
    }
    const resize = new ResizeObserver(() => {
      width = root.clientWidth
      height = root.clientHeight
      reset()
    })
    resize.observe(root)
    root.addEventListener("pointerdown", down)
    root.addEventListener("pointermove", move)
    root.addEventListener("pointerup", up)
    root.addEventListener("pointercancel", up)
    root.addEventListener("lostpointercapture", up)
    media.addEventListener("change", change)
    document.addEventListener("visibilitychange", visibility)
    paint()
    return () => {
      reset()
      resize.disconnect()
      root.removeEventListener("pointerdown", down)
      root.removeEventListener("pointermove", move)
      root.removeEventListener("pointerup", up)
      root.removeEventListener("pointercancel", up)
      root.removeEventListener("lostpointercapture", up)
      media.removeEventListener("change", change)
      document.removeEventListener("visibilitychange", visibility)
      controls.current = { reset() {}, nudge() {}, release() {} }
    }
  }, [active])

  return (
    <section className="sf-tethered-type" data-reduced={reduced} aria-label="Tethered type specimen">
      <div className="word" ref={field}>
        <svg aria-hidden="true">
          <g fill="none" stroke="currentColor" strokeWidth="1.25">
            {letters.map((_, i) => (
              <path
                key={i}
                ref={(el) => {
                  lines.current[i] = el
                }}
              />
            ))}
          </g>
        </svg>
        {letters.map((letter, i) => (
          <span className="origin" key={`o-${i}`} style={{ left: `${20 + 20 * i}%` }} aria-hidden="true">
            {letter}
          </span>
        ))}
        {letters.map((letter, i) => (
          <button
            key={i}
            type="button"
            data-glyph={i}
            ref={(el) => {
              glyphs.current[i] = el
            }}
            style={{ left: `${20 + 20 * i}%` }}
            disabled={!active}
            aria-label={`${letter}, letter ${i + 1}. Drag or use arrow keys; Enter returns it.`}
            onFocus={() => setSelected(i)}
            onBlur={() => controls.current.release(i)}
            onKeyDown={(event) => {
              if (
                ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "Escape", "Enter", " "].includes(event.key)
              )
                event.preventDefault()
              if (event.key === "ArrowLeft") controls.current.nudge(i, -12, 0)
              if (event.key === "ArrowRight") controls.current.nudge(i, 12, 0)
              if (event.key === "ArrowUp") controls.current.nudge(i, 0, -12)
              if (event.key === "ArrowDown") controls.current.nudge(i, 0, 12)
              if (["Home", "Escape", "Enter", " "].includes(event.key)) controls.current.release(i)
            }}
          >
            {letter}
          </button>
        ))}
      </div>
      <footer>
        <button
          type="button"
          disabled={!active}
          onClick={() => {
            controls.current.reset()
            setSelected(-1)
          }}
        >
          Reset <span aria-hidden="true">↺</span>
        </button>
      </footer>
      <span className="sr" role="status">
        {selected < 0 ? "Type reset" : `Letter ${selected + 1} selected`}
      </span>
      <style>{`
        .sf-tethered-type{height:100%;width:100%;position:relative;overflow:hidden;container-type:inline-size;background:#f45b96;color:#191820;font-family:Arial,sans-serif;box-sizing:border-box}.sf-tethered-type *{box-sizing:border-box}.sf-tethered-type .word{height:calc(100% - 51px);position:relative}.sf-tethered-type svg{position:absolute;width:100%;height:100%;pointer-events:none;overflow:visible}.sf-tethered-type .word>button,.sf-tethered-type .origin{position:absolute;top:50%;transform:translate(-50%,-50%);width:21%;text-align:center;font:900 clamp(70px,27cqw,162px)/1 "Arial Black",Impact,sans-serif;letter-spacing:-.065em}.sf-tethered-type .word>button{padding:0 6px 10px 0;border:0;background:none;color:inherit;touch-action:none;cursor:grab;user-select:none;z-index:1}.sf-tethered-type .word>button:active{cursor:grabbing}.sf-tethered-type .origin{color:transparent;-webkit-text-stroke:1px #19182028;padding:0 6px 10px 0;pointer-events:none}.sf-tethered-type button:focus-visible{outline:1px dashed currentColor;outline-offset:5px}.sf-tethered-type button:disabled{cursor:default}.sf-tethered-type footer{height:51px;margin:0 24px;display:flex;align-items:center;justify-content:flex-end;font-size:11px;letter-spacing:-.15px}.sf-tethered-type footer button{min-height:44px;border:0;background:none;display:flex;align-items:center;gap:20px;color:inherit;font:inherit;cursor:pointer;touch-action:manipulation}.sf-tethered-type footer button>span{font-size:22px}.sf-tethered-type .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
      `}</style>
    </section>
  )
}

Where it works well

Turn a short wordmark or type specimen into a tactile, direct-manipulation object.

Reduced motion

Direct dragging and keyboard positioning remain available; release returns the letter immediately without oscillation.

Things to know

A four-letter specimen with bounded displacement. Resize, host pause and document hiding reset all letters. It is not a text editor or font shaper.

More examples