Subframe
Components & interactionsSpring joystick control

Gimbal control

A tactile grip moves a pan-and-tilt viewfinder and returns to neutral on release.

Drag or hold arrow keys; release to return to center.

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

const limit = (x: number, y: number) => {
  const scale = 1 / Math.max(1, Math.hypot(x, y))
  return { x: x * scale, y: y * scale }
}
const neutral = { x: 0, y: 0 }

export default function SpringJoystick({ active = true }: { active?: boolean }) {
  const [point, setPoint] = useState(neutral)
  const [dragging, setDragging] = useState(false)
  const [instant, setInstant] = useState(false)
  const pad = useRef<HTMLButtonElement>(null)
  const pointer = useRef<number | null>(null)
  const keys = useRef(new Set<string>())
  const move = (x: number, y: number) => {
    const rect = pad.current?.getBoundingClientRect()
    if (!rect) return
    setPoint(
      limit(
        (x - rect.left - rect.width / 2) / (rect.width * 0.32),
        (y - rect.top - rect.height / 2) / (rect.height * 0.32),
      ),
    )
  }
  const release = useCallback(() => {
    const id = pointer.current
    pointer.current = null
    keys.current.clear()
    setDragging(false)
    setPoint(neutral)
    if (id !== null && pad.current?.hasPointerCapture(id)) pad.current.releasePointerCapture(id)
  }, [])
  useEffect(() => {
    if (!active) release()
  }, [active, release])
  useEffect(() => {
    const hide = () => {
      if (document.hidden) release()
    }
    window.addEventListener("blur", release)
    document.addEventListener("visibilitychange", hide)
    return () => {
      window.removeEventListener("blur", release)
      document.removeEventListener("visibilitychange", hide)
    }
  }, [release])
  const keyboardPoint = () =>
    setPoint(
      limit(
        (Number(keys.current.has("ArrowRight")) - Number(keys.current.has("ArrowLeft"))) * 0.75,
        (Number(keys.current.has("ArrowDown")) - Number(keys.current.has("ArrowUp"))) * 0.75,
      ),
    )
  const angle = (value: number) =>
    `${value < -0.01 ? "−" : "+"}${Math.abs(value * 30)
      .toFixed(0)
      .padStart(2, "0")}°`
  return (
    <div className="sf-gimbal" data-drag={dragging} data-instant={instant} data-active={active}>
      <style>{`
.sf-gimbal{height:100%;min-height:300px;display:grid;place-items:center;overflow:hidden;background:radial-gradient(ellipse at 40% 15%,#e2e5e6,#ccd1d2);font-family:Arial,sans-serif;color:#252c2e;-webkit-font-smoothing:antialiased}
.sf-gimbal *{box-sizing:border-box}.sf-gimbal button{-webkit-tap-highlight-color:transparent}
.sf-gimbal-case{position:relative;width:370px;padding:25px 25px 21px;border:1px solid #929b9f;border-radius:13px;background:linear-gradient(115deg,#f1f3f2 0%,#d8dddf 38%,#b9c0c2 100%);box-shadow:inset 0 1px 1px #fff,inset 1px 0 1px #ffffffbb,inset -1px -1px 1px #687176,0 3px #8b9599,0 4px 1px #677177,0 6px 4px #35404744,3px 14px 14px -6px #27323955,8px 28px 30px -16px #28383e77}
.sf-gimbal-case:before,.sf-gimbal-case:after{content:"";position:absolute;top:9px;width:6px;height:6px;border:1px solid #717b7d;border-radius:50%;background:linear-gradient(135deg,#dce2e2 41%,#757f83 43% 55%,#d4dada 58%);box-shadow:0 1px #fff8,inset 0 1px 1px #66727566}.sf-gimbal-case:before{left:10px}.sf-gimbal-case:after{right:10px}
.sf-gimbal-top{display:flex;align-items:center;justify-content:space-between;margin:0 0 17px;font-size:8px;letter-spacing:1.5px;font-weight:700;text-shadow:0 1px #fff8}
.sf-gimbal-body{display:grid;grid-template-columns:160px 128px;gap:29px;align-items:center}
.sf-gimbal-screen{height:130px;position:relative;border:4px solid #525d60;border-radius:7px;background:#131e22;box-shadow:0 1px #ffffffba,-1px -1px 1px #b3bcbf,inset 0 2px 5px #000;overflow:hidden;color:#b2d4d2}
.sf-gimbal-screen:after{content:"";position:absolute;inset:0;border:1px solid #142024;border-radius:3px;background:linear-gradient(130deg,#f1ffff18,transparent 45%,transparent 75%,#ffffff07);pointer-events:none}
.sf-gimbal-screen svg{position:absolute;inset:0;width:100%;height:100%}
.sf-gimbal-cross{position:absolute;left:calc(50% - 17px);top:calc(50% - 17px);width:34px;height:34px;transition:transform 450ms cubic-bezier(.2,1.25,.3,1);filter:drop-shadow(0 0 3px #9fe1de44)}
.sf-gimbal-screen-label{position:absolute;left:8px;top:8px;font:7px ui-monospace,monospace;letter-spacing:1px;display:flex;align-items:center;gap:5px}.sf-gimbal-screen-label i{width:3px;height:3px;border-radius:50%;background:#a5cdba;box-shadow:0 0 4px #addec37a}
.sf-gimbal-readouts{display:flex;justify-content:space-between;margin-top:12px;padding:0 1px;font:8px ui-monospace,SFMono-Regular,Consolas,monospace;font-variant-numeric:tabular-nums;color:#596468}.sf-gimbal-readouts span{display:flex;align-items:center;gap:8px}.sf-gimbal-readouts output{font-size:11px;color:#25373c;text-shadow:0 1px #fff8}
.sf-gimbal-pad{position:relative;width:128px;height:142px;border:1px solid #818b8e;border-radius:10px;background:linear-gradient(120deg,#a1aaad,#eef1ef);box-shadow:inset 1px 2px 2px #66727566,0 1px #fff9;padding:0;cursor:grab;touch-action:none;outline-offset:5px;perspective:450px;align-self:start}
.sf-gimbal-pad:active{cursor:grabbing}.sf-gimbal-pad:focus-visible{outline:2px solid #b4421e}
.sf-gimbal-well{position:absolute;inset:5px;border:1px solid #242b2d;border-radius:7px;background:linear-gradient(130deg,#303a3d,#0e161a);box-shadow:inset 1px 2px 6px #0009,0 1px 1px #eef5f4a8}
.sf-gimbal-fold{position:absolute;inset:13px 12px 14px;border-radius:9px;border:3px solid #283339;background:linear-gradient(130deg,#424d52,#162127 65%);box-shadow:inset 0 1px 1px #71818799,inset 0 -2px 2px #071014,0 2px 2px #071016bb;transition:transform 450ms cubic-bezier(.2,1.25,.3,1)}
.sf-gimbal-fold:nth-child(3){inset:23px 22px 24px;border-radius:8px}.sf-gimbal-fold:nth-child(4){inset:33px 32px 34px;border-radius:7px}
.sf-gimbal-grip{position:absolute;left:32px;top:34px;width:64px;height:68px;border-radius:10px 10px 12px 12px;background:linear-gradient(130deg,#fc9f5d,#e96c32 30%,#b13d18 88%);box-shadow:inset 0 1px 1px #ffe2be,inset 1px 0 1px #ffb57d,inset -1px -2px 1px #6f260fee,0 3px #84321f,0 4px 2px #081015cc,3px 10px 5px -2px #061115bd,9px 17px 12px -4px #061015b5;transition:transform 450ms cubic-bezier(.2,1.25,.3,1);pointer-events:none;transform-style:preserve-3d}
.sf-gimbal-grip:before{content:"";position:absolute;inset:6px 7px 9px;border-radius:6px;background:linear-gradient(150deg,#d05426,#ee783b 68%,#f7934d);box-shadow:inset 1px 2px 4px #73301244,inset -1px -1px 1px #ffb1776b}
.sf-gimbal-ribs{position:absolute;inset:18px 15px;display:flex;flex-direction:column;justify-content:space-between}.sf-gimbal-ribs i{height:2px;border-radius:1px;background:#a441207d;box-shadow:0 1px #ffb37a55}
.sf-gimbal[data-drag=true] .sf-gimbal-fold,.sf-gimbal[data-drag=true] .sf-gimbal-grip,.sf-gimbal[data-drag=true] .sf-gimbal-cross,.sf-gimbal[data-instant=true] *,.sf-gimbal[data-active=false] *{transition:none}
@media(prefers-reduced-motion:reduce){.sf-gimbal *{transition:none}}
@media(max-width:410px){.sf-gimbal-case{transform:scale(.85)}}
`}</style>
      <div className="sf-gimbal-case">
        <div className="sf-gimbal-top" aria-hidden="true">
          <span>CAMERA POSITION</span>
        </div>
        <div className="sf-gimbal-body">
          <div>
            <div className="sf-gimbal-screen" aria-hidden="true">
              <svg viewBox="0 0 152 122" fill="none">
                <path
                  d="M25 0V122M51 0V122M76 0V122M101 0V122M127 0V122M0 35H152M0 61H152M0 87H152"
                  stroke="#648486"
                  strokeOpacity=".17"
                  strokeWidth=".6"
                />
                <path
                  d="M76 16V106M20 61H132"
                  stroke="#739a9c"
                  strokeOpacity=".45"
                  strokeWidth=".7"
                  strokeDasharray="2 3"
                />
                <path d="M13 39V28H24M128 28H139V39M13 84V95H24M128 95H139V84" stroke="#799395" strokeWidth=".75" />
              </svg>
              <div className="sf-gimbal-screen-label">
                <i /> PAN / TILT
              </div>
              <div className="sf-gimbal-cross" style={{ transform: `translate(${point.x * 43}px,${point.y * 32}px)` }}>
                <svg viewBox="0 0 34 34" fill="none" stroke="currentColor">
                  <path d="M0 9V0H9M25 0H34V9M0 25V34H9M25 34H34V25M12 17H22M17 12V22" strokeWidth="1.2" />
                </svg>
              </div>
            </div>
            <div className="sf-gimbal-readouts">
              <span>
                PAN <output aria-live="off">{angle(point.x)}</output>
              </span>
              <span>
                TILT <output aria-live="off">{angle(-point.y)}</output>
              </span>
            </div>
          </div>
          <div>
            <button
              type="button"
              ref={pad}
              className="sf-gimbal-pad"
              aria-label="Pan and tilt joystick. Drag or hold arrow keys; release to center."
              onPointerDown={(event) => {
                if (event.button !== 0 || pointer.current !== null) return
                pointer.current = event.pointerId
                event.currentTarget.setPointerCapture(event.pointerId)
                setInstant(false)
                setDragging(true)
                move(event.clientX, event.clientY)
              }}
              onPointerMove={(event) => {
                if (pointer.current === event.pointerId) move(event.clientX, event.clientY)
              }}
              onPointerUp={release}
              onPointerCancel={release}
              onLostPointerCapture={release}
              onBlur={release}
              onKeyDown={(event) => {
                if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return
                event.preventDefault()
                setInstant(true)
                keys.current.add(event.key)
                keyboardPoint()
              }}
              onKeyUp={(event) => {
                if (!keys.current.has(event.key)) return
                event.preventDefault()
                keys.current.delete(event.key)
                keyboardPoint()
              }}
            >
              <span className="sf-gimbal-well" aria-hidden="true" />
              {[0.13, 0.3, 0.5].map((scale, index) => (
                <span
                  key={index}
                  className="sf-gimbal-fold"
                  aria-hidden="true"
                  style={{ transform: `translate(${point.x * 29 * scale}px,${point.y * 29 * scale}px)` }}
                />
              ))}
              <span
                className="sf-gimbal-grip"
                aria-hidden="true"
                style={{
                  transform: `translate(${point.x * 27}px,${point.y * 27}px) rotateX(${-point.y * 13}deg) rotateY(${
                    point.x * 13
                  }deg)`,
                }}
              >
                <span className="sf-gimbal-ribs">
                  {[0, 1, 2, 3, 4].map((i) => (
                    <i key={i} />
                  ))}
                </span>
              </span>
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

Where it works well

Move a virtual camera target with momentary two-axis input and a clear neutral position.

Reduced motion

No idle animation; reduced motion and keyboard input update immediately.

Things to know

Position demonstration only; does not control a real camera.

More examples