Subframe
Components & interactionsKeyframe timeline editor

Letter arrival editor

Magnetic keyframes set when three original letterforms arrive, with a scrubber and a live preview of the edited timing.

Align the arrival diamonds, scrub the ruler, then preview the animation

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"

const END = 1.6
const letters = ["C", "U", "T"]
const outlines = [
  "M122 0H28C10 0 0 10 0 28v74c0 18 10 28 28 28h94V96H42c-4 0-6-2-6-6V40c0-4 2-6 6-6h80Z",
  "M0 0h36v89c0 6 3 9 9 9h32c6 0 9-3 9-9V0h36v99c0 21-10 31-31 31H31C10 130 0 120 0 99Z",
  "M0 0h130v35H83v95H47V35H0Z",
]
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
export default function KeyframeSnap({ active = true }: { active?: boolean }) {
  const [frames, setFrames] = useState([0.5, 0.85, 1.2])
  const [time, setTime] = useState(END)
  const [playing, setPlaying] = useState(false)
  const [snap, setSnap] = useState<number | null>(null)
  const [reduced, setReduced] = useState(false)
  const area = useRef<HTMLDivElement>(null)
  const drag = useRef<{ pointerId: number; index: number; x: number; value: number; width: number } | null>(null)
  const timeRef = useRef(time)
  timeRef.current = time
  useEffect(() => {
    const m = matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => {
      setReduced(m.matches)
      if (m.matches) setPlaying(false)
    }
    update()
    m.addEventListener("change", update)
    return () => m.removeEventListener("change", update)
  }, [])
  useEffect(() => {
    if (!active || !playing || reduced) return
    let frame = 0
    const origin = performance.now() - timeRef.current * 1000
    const tick = (now: number) => {
      const t = Math.min(END, (now - origin) / 1000)
      setTime(t)
      if (t < END) frame = requestAnimationFrame(tick)
      else setPlaying(false)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [active, playing, reduced])
  const update = (index: number, value: number, magnetic: boolean) => {
    let next = clamp(value, 0.3, 1.4)
    const nearest = magnetic
      ? frames
          .filter((_, i) => i !== index)
          .sort((a, b) => Math.abs(a - next) - Math.abs(b - next))
          .find((v) => Math.abs(v - next) < 0.045)
      : undefined
    if (nearest !== undefined) next = nearest
    next = Math.round(next * 100) / 100
    setSnap(nearest ?? null)
    setPlaying(false)
    setFrames((v) => v.map((n, i) => (i === index ? next : n)))
    setTime(next)
  }
  const stopDrag = (pointerId: number) => {
    if (drag.current?.pointerId !== pointerId) return
    drag.current = null
    setSnap(null)
  }
  return (
    <div className="sf-ks2" data-active={active}>
      <style>{`
.sf-ks2{height:100%;min-height:300px;display:grid;place-items:center;background:#d5cfdf;font-family:Arial,sans-serif;color:#c1baca}.sf-ks2 *{box-sizing:border-box}.sf-ks2-panel{width:min(480px,92%);overflow:hidden;border-radius:8px;background:#211b2b;border:1px solid #352a43;box-shadow:0 15px 30px #34224528,0 1px 2px #34224530}.sf-ks2-art{height:146px;position:relative;background:#773cea;overflow:hidden;display:grid;place-items:center}.sf-ks2-art>svg{width:84%;height:118px;overflow:hidden}.sf-ks2-time{position:absolute;top:8px;left:13px;font:9px ui-monospace,monospace;color:#e9d9ff}.sf-ks2-preview{display:flex;align-items:center;gap:5px;border:1px solid #ffffff38;background:#6430c4;color:#f7f0ff;border-radius:4px;height:25px;padding:0 9px;cursor:pointer;font:10px Arial}@media(hover:hover){.sf-ks2-preview:hover{background:#5925b5}}.sf-ks2-controls{padding:13px 17px 12px}.sf-ks2-editor{display:grid;grid-template-columns:23px 1fr;gap:12px}.sf-ks2-labels{padding-top:20px}.sf-ks2-labels span{display:flex;align-items:center;height:44px;font-size:11px;font-weight:700;color:#e0d5ea}.sf-ks2-timeline{position:relative;background:repeating-linear-gradient(90deg,#ffffff09 0 1px,transparent 1px 25%);touch-action:none}.sf-ks2-ruler{height:20px;display:flex;justify-content:space-between;font:8px ui-monospace,monospace;color:#887b96}.sf-ks2-row{position:relative;height:44px;border-top:1px solid #ffffff0b}.sf-ks2-duration{height:4px;position:absolute;top:20px;border-radius:2px;background:#8050b4;opacity:.65}.sf-ks2-node{position:absolute;top:0;width:44px;height:44px;background:none;border:0;transform:translateX(-50%);cursor:ew-resize;touch-action:none;display:grid;place-items:center;z-index:2;padding:0}.sf-ks2-node:after{content:"";height:9px;width:9px;border:1.5px solid #ead7ff;background:#b985f7;transform:rotate(45deg);box-shadow:0 0 0 2px #211b2b}.sf-ks2-node:active:after{background:white}.sf-ks2-guide{position:absolute;top:16px;bottom:0;border-left:1px dashed #d3b3ff;pointer-events:none;z-index:1}.sf-ks2-playhead{position:absolute;top:17px;bottom:0;border-left:1px solid #faf6ff;pointer-events:none;z-index:3;opacity:.75}.sf-ks2-playhead:before{content:"";position:absolute;left:-3px;top:-3px;width:5px;height:5px;background:#faf6ff;border-radius:1px}.sf-ks2-scrub{position:absolute;left:-4px;top:-5px;width:calc(100% + 8px);height:24px;margin:0;opacity:0;cursor:ew-resize;z-index:4}.sf-ks2-scrub:focus-visible{opacity:.18}.sf-ks2 button:focus-visible{outline:2px solid #ead7ff;outline-offset:1px;border-radius:3px}.sf-ks2-foot{display:flex;justify-content:space-between;align-items:center;margin-top:6px;color:#a394b5;font:9px ui-monospace,monospace}.sf-ks2-foot span:last-child{color:#806f94}
`}</style>
      <div className="sf-ks2-panel">
        <div className="sf-ks2-art">
          <output className="sf-ks2-time">{time.toFixed(2)} s</output>
          <svg viewBox="0 0 406 140" role="img" aria-label="CUT letterform arrival preview">
            {outlines.map((d, i) => {
              const t = clamp((time - (frames[i] - 0.3)) / 0.3, 0, 1),
                eased = 1 - Math.pow(1 - t, 3)
              return (
                <g
                  key={i}
                  style={{
                    transform: `translate(${i * 138}px, ${reduced ? 0 : (1 - eased) * 148}px)`,
                    opacity: reduced ? (t >= 1 ? 1 : 0) : 1,
                  }}
                >
                  <path d={d} fill="#f1e6ff" />
                </g>
              )
            })}
          </svg>
        </div>
        <div className="sf-ks2-controls">
          <div className="sf-ks2-editor">
            <div className="sf-ks2-labels">
              {letters.map((l) => (
                <span key={l}>{l}</span>
              ))}
            </div>
            <div className="sf-ks2-timeline" ref={area}>
              <div className="sf-ks2-ruler" aria-hidden="true">
                {[0, 0.4, 0.8, 1.2, 1.6].map((n) => (
                  <span key={n}>{n.toFixed(1)}</span>
                ))}
              </div>
              <input
                className="sf-ks2-scrub"
                type="range"
                min="0"
                max={END}
                step=".01"
                value={time}
                aria-label="Animation playhead"
                aria-valuetext={`${time.toFixed(2)} seconds`}
                onChange={(e) => {
                  setPlaying(false)
                  setTime(Number(e.target.value))
                }}
              />
              {frames.map((f, i) => (
                <div className="sf-ks2-row" key={i}>
                  <div
                    className="sf-ks2-duration"
                    style={{ left: `${((f - 0.3) / END) * 100}%`, width: `${(0.3 / END) * 100}%` }}
                  />
                  <button
                    type="button"
                    role="slider"
                    className="sf-ks2-node"
                    aria-label={`${letters[i]} arrival`}
                    aria-valuemin={0.3}
                    aria-valuemax={1.4}
                    aria-valuenow={f}
                    aria-valuetext={`${f.toFixed(2)} seconds. Drag to align; hold Alt to bypass snapping.`}
                    style={{ left: `${(f / END) * 100}%` }}
                    onPointerDown={(e) => {
                      if (e.button !== 0 || drag.current) return
                      drag.current = {
                        pointerId: e.pointerId,
                        index: i,
                        x: e.clientX,
                        value: f,
                        width: area.current!.getBoundingClientRect().width,
                      }
                      setPlaying(false)
                      e.currentTarget.setPointerCapture(e.pointerId)
                    }}
                    onPointerMove={(e) => {
                      const d = drag.current
                      if (d?.index === i && d.pointerId === e.pointerId)
                        update(i, d.value + ((e.clientX - d.x) / d.width) * END, !e.altKey)
                    }}
                    onPointerUp={(e) => stopDrag(e.pointerId)}
                    onPointerCancel={(e) => stopDrag(e.pointerId)}
                    onLostPointerCapture={(e) => stopDrag(e.pointerId)}
                    onKeyDown={(e) => {
                      if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
                      e.preventDefault()
                      update(
                        i,
                        e.key === "Home"
                          ? 0.3
                          : e.key === "End"
                          ? 1.4
                          : f + (e.key === "ArrowRight" ? 1 : -1) * (e.shiftKey ? 0.1 : 0.01),
                        false,
                      )
                    }}
                  />
                </div>
              ))}
              <div className="sf-ks2-playhead" style={{ left: `${(time / END) * 100}%` }} />
              {snap !== null && <div className="sf-ks2-guide" style={{ left: `${(snap / END) * 100}%` }} />}
            </div>
          </div>
          <div className="sf-ks2-foot">
            <span>{snap !== null ? "Aligned" : "Drag diamonds to align"}</span>
            <button
              type="button"
              className="sf-ks2-preview"
              onClick={() => {
                if (playing) {
                  setPlaying(false)
                  return
                }
                if (reduced) {
                  setTime(END)
                  return
                }
                setTime(0)
                timeRef.current = 0
                setPlaying(true)
              }}
              aria-label={reduced ? "Show end frame" : playing ? "Pause animation" : "Preview animation"}
            >
              <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" aria-hidden="true">
                {playing ? <path d="M2 1h2v8H2zm4 0h2v8H6z" /> : <path d="m2 1 7 4-7 4Z" />}
              </svg>
              {reduced ? "End frame" : playing ? "Pause" : "Preview"}
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

Where it works well

Synchronizing independent text or graphic entrances with an immediately inspectable animation consequence.

Reduced motion

Scrubbing shows discrete arrived/not-arrived letter states without translation; Preview becomes an End frame action. A live preference change cancels playback.

Things to know

Three authored letterform arrivals in a 1.6-second local composition. Each entrance lasts 300 ms and ends at its diamond; no general animation engine or export. Host pause freezes and resumes the current time.

More examples