For the complete index, see /llms.txt. A Markdown version of any documentation page is available by appending .md to its URL or by sending an Accept: text/markdown header.

Browse documentation

Delta

A parallel delta in isometric, solved with the closed-form delta IK. Change the geometry and the arms behave the way that machine would.

Open in workbench
14, -56, 0M1 157°M2 177°M3 138°
view
motion
spin
32
tilt
0.45
bicep
17
Theming

Set a role and the same CSS goes in your own app — every robot under it follows.

Install

bunx --bun shadcn@latest add https://robocn.dev/r/delta-arm.json

Usage

<DeltaArm
  geometry={{ base: 62, platform: 18, upper: 17, lower: 58 }}
  behavior="orbit"
  spin={32}
  tilt={0.45}
/>

Props

PropTypeDefaultDescription
view"plan" | "front" | "profile" | "iso""iso"Where the camera stands. One machine, four projections: straight down, straight on, side elevation, or three-quarter from above.
geometryPartial<DeltaGeometry>{ base: 62, platform: 18, upper: 17, lower: 58 }Triangle sides and arm lengths. Short biceps keep the elbows inside the plate.
targetVec3 | ((clock: number) => Vec3) | null—Platform position: x and z across the workspace, y below the plate (negative).
heightnumber—Height the platform holds when a behaviour drives it.
spin / tiltnumber32 / 0.45Viewing angle: rotation about the vertical, then how far the view tips.
showTargetboolean—Platform readout, and a warning when a target falls outside the workspace.
variant"solid" | "outline" | "blueprint" | "wire""solid"How the machine is painted. Geometry never changes between variants.
size"xs" | "sm" | "md" | "lg" | "xl" | number"md"Rendered width in pixels, or a step on the scale.
thicknessnumber1Limb weight multiplier, independent of size.
speednumber—Tip travel in world units per second.
animatebooleantrueOff snaps to a fixed goal or samples a scripted path at phase, then stops. Reduced-motion preference does the same.
pausedbooleanfalseFreeze in place.
phasenumber0Seconds of offset, so a row of machines breaks step.
interactivebooleanfalsePress and drag inside the frame to send the tool there; release and it eases back into its behaviour. Hover is not available on a touch screen, so this is how one drives it.
onTargetChange(target: Vec2 | null) => void—The dragged goal in world units, and null on release.
colorstringvar(--robot-shell)Body panels — the colour the machine reads as.
accentstringvar(--robot-accent)Status colour: tip light, live tool, readouts.
metalstringvar(--robot-metal)Bare machined parts: collars, bolts, tool bodies.
darkstringvar(--robot-dark)Cast joints, base, shadow side.
palettePartial<RobotPalette>—Override any subset of roles at once, including glow and grid.

Source

src/components/ui/delta-arm.tsx
"use client"

/**
 * delta-arm — a parallel delta robot, drawn in isometric.
 *
 * Three motors on a fixed plate drive three biceps; three pairs of passive
 * forearms keep the platform level wherever it goes. Solved with the closed-
 * form delta IK rather than an approximation, so the geometry is the real
 * thing — change `geometry` and the arms behave the way that machine would.
 */

import * as React from "react"

import { useEasedPoint, type RobotTarget } from "@/hooks/use-robot-arm"
import { usePointerTarget } from "@/hooks/use-pointer-target"
import { useRobotDrag } from "@/hooks/use-robot-motion"
import {
  isometric,
  isometricDepth,
  solveDelta,
  type DeltaGeometry,
  type Vec2,
  type Vec3,
} from "@/lib/robocn/kinematics"
import {
  capsulePath,
  px,
  resolveRobotPalette,
  resolveRobotSize,
  robotCamera,
  robotSurface,
  type RobotBehavior,
  type RobotPaletteProps,
  type RobotSize,
  type RobotTool,
  type RobotVariant,
  type RobotView,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"

const VIEW_WIDTH = 132
const VIEW_HEIGHT = 118
const PLATE_Y = 30
/** The machine is drawn in isometric, on its own free `spin` and `tilt`. */
const NATIVE_VIEW: RobotView = "iso"

const viewNames: Record<RobotView, string> = {
  plan: "plan view",
  front: "front elevation",
  profile: "side elevation",
  iso: "isometric view",
}

/**
 * Proportions of a printer-style delta: short biceps on a wide plate, long
 * forearms. Short biceps keep the elbows inside the plate's footprint, which
 * is what makes the machine read as a delta rather than a splayed tripod.
 */
const defaultGeometry: DeltaGeometry = {
  base: 62,
  platform: 18,
  upper: 17,
  lower: 58,
}

export interface DeltaArmProps
  extends Omit<React.ComponentProps<"svg">, "color" | "target">,
    RobotPaletteProps {
  /** Triangle sides and arm lengths, in world units. */
  geometry?: Partial<DeltaGeometry>
  /**
   * Controlled platform position: `x` and `z` run across the workspace, `y`
   * is how far below the plate it hangs (negative).
   */
  target?: Vec3 | ((clock: number) => Vec3) | null
  behavior?: RobotBehavior
  /** Height the platform holds when a behaviour drives it. */
  height?: number
  tool?: RobotTool
  /**
   * Where the camera stands. `iso` is the machine's own axonometric, which
   * `spin` and `tilt` steer; the other three are the shared orthographic
   * cameras, and ignore both.
   */
  view?: RobotView
  active?: boolean
  variant?: RobotVariant
  size?: RobotSize | number
  thickness?: number
  /** Viewing angle: rotation about the vertical, then how far the view tips. */
  spin?: number
  tilt?: number
  speed?: number
  animate?: boolean
  paused?: boolean
  phase?: number
  label?: string
  showPlate?: boolean
  /** Mark the platform centre with a crosshair and a readout. */
  showTarget?: boolean
  /**
   * Press and drag the frame to send the head there; release and it goes back
   * to its behaviour. Touch devices have no hover, so this is how they drive it.
   */
  interactive?: boolean
  /** The dragged goal in world units, and null on release. */
  onTargetChange?: (target: Vec2 | null) => void
}

function DeltaArm({
  geometry: geometryOverride,
  target = null,
  behavior = "orbit",
  height,
  tool = "vacuum",
  view = NATIVE_VIEW,
  active,
  variant = "solid",
  size = "md",
  thickness = 1,
  spin = 32,
  tilt = 0.45,
  speed,
  animate = true,
  paused = false,
  phase = 0,
  label,
  showPlate = true,
  showTarget,
  interactive = false,
  onTargetChange,
  color,
  accent,
  metal,
  dark,
  glow,
  grid,
  palette: paletteOverride,
  className,
  style,
  ...props
}: DeltaArmProps) {
  const palette = resolveRobotPalette({
    color,
    accent,
    metal,
    dark,
    glow,
    grid,
    palette: paletteOverride,
  })
  const geometry = { ...defaultGeometry, ...geometryOverride }
  const width = resolveRobotSize(size)
  const viewHeight = (width * VIEW_HEIGHT) / VIEW_WIDTH
  const weight = thickness
  const rest = height ?? -(geometry.upper + geometry.lower) * 0.74
  const swing = geometry.base * 0.22
  const annotate = showTarget ?? variant === "blueprint"

  const svgRef = React.useRef<SVGSVGElement>(null)
  const toWorld = React.useCallback(
    (unit: Vec2) => ({
      x: (unit.x - 0.5) * VIEW_WIDTH * 0.6,
      y: -(unit.y * VIEW_HEIGHT - PLATE_Y),
    }),
    [],
  )
  const pointer = usePointerTarget(svgRef, {
    enabled: behavior === "pointer" && !paused,
    toWorld,
  })

  // A press outranks the behaviour: the platform goes where it is put, and
  // eases back into the cycle when it is let go.
  const [held, setHeld] = React.useState<Vec2 | null>(null)
  const dragging = useRobotDrag(svgRef, {
    enabled: interactive,
    onDrag: React.useCallback((unit) => {
      const to = { x: toWorld(unit).x, y: Math.min(-8, toWorld(unit).y) }
      setHeld(to)
      onTargetChange?.(to)
    }, [toWorld, onTargetChange, setHeld]),
    onDragEnd: React.useCallback(() => {
      setHeld(null)
      onTargetChange?.(null)
    }, [onTargetChange, setHeld]),
  })

  // The platform moves in a plane, so it eases like any other point; only the
  // depth axis is driven by the behaviour.
  const planar = React.useMemo<RobotTarget>(() => {
    if (held) return held
    if (typeof target === "function") {
      return (clock: number) => {
        const point = target(clock)
        return { x: point.x, y: point.y }
      }
    }
    if (target) return { x: target.x, y: target.y }
    if (behavior === "pointer" && pointer.target) {
      return { x: pointer.target.x, y: Math.min(-8, pointer.target.y) }
    }
    if (behavior === "static") return { x: 0, y: rest }
    return (clock: number) => ({
      x: Math.cos(clock * (behavior === "sweep" ? 0.7 : 1)) * swing,
      y:
        rest +
        (behavior === "idle" ? Math.sin(clock * 0.8) * 2 : Math.sin(clock * 1.6) * 5),
    })
  }, [held, target, behavior, pointer.target, rest, swing])

  const eased = useEasedPoint(
    planar,
    { x: 0, y: rest },
    { speed: speed ?? 70, animate, paused, phase },
  )

  const depth = React.useMemo(() => {
    if (typeof target === "function") return target(eased.clock).z
    if (target) return target.z
    if (behavior === "static" || behavior === "pointer") return 0
    return Math.sin(eased.clock * (behavior === "sweep" ? 0.9 : 1)) * swing
  }, [target, behavior, eased.clock, swing])

  const platform: Vec3 = { x: eased.point.x, y: eased.point.y, z: depth }
  const pose = solveDelta(platform, geometry)
  const engaged = active ?? eased.moving
  // Everything the machine has is already a world point, so a view is only a
  // change of projection: `iso` keeps the free axonometric that `spin` and
  // `tilt` drive, and the other three take the shared orthographic camera.
  // Isometric coordinates run z toward the viewer and the group draws y up.
  const camera = robotCamera(view)
  const orthographic = view !== NATIVE_VIEW
  const project = (v: Vec3): Vec2 => {
    if (!orthographic) return isometric(v, { spin, tilt })
    const point = camera.project(v.x, v.y, -v.z)
    return { x: point.x, y: -point.y }
  }
  // Both report distance from the camera; the axonometric one counts the
  // other way, so it is negated to give one "nearer draws later" ordering.
  const toward = (v: Vec3) =>
    orthographic ? camera.depth(v.x, v.y, -v.z) : -isometricDepth(v, { spin })

  // Far arms first: the plate and platform then overlap them correctly.
  const ordered = pose.arms
    .map((arm, index) => ({ arm, index }))
    .sort((a, b) => toward(a.arm.anchor) - toward(b.arm.anchor))

  const shell = robotSurface("shell", variant, palette, weight)
  const metalSurface = robotSurface("metal", variant, palette, weight)
  const darkSurface = robotSurface("dark", variant, palette, weight)
  // The plate is drawn as an open frame — the biceps swing out past its edges,
  // and a solid slab would swallow them.
  const plate = `${triangle(geometry.base, { x: 0, y: 0, z: 0 }, project)} Z ${triangle(geometry.base * 0.62, { x: 0, y: 0, z: 0 }, project)} Z`
  const plateRim = `${triangle(geometry.base, { x: 0, y: -3, z: 0 }, project)} Z ${triangle(geometry.base * 0.62, { x: 0, y: -3, z: 0 }, project)} Z`
  const deck = triangle(geometry.platform, platform, project)

  return (
    <svg
      role="img"
      aria-label={`Delta robot with three arms holding a ${tool}, ${viewNames[view] ?? viewNames.iso}`}
      viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
      width={width}
      height={viewHeight}
      ref={svgRef}
      className={cn(
        "select-none overflow-hidden",
        interactive && "cursor-grab touch-none",
        dragging && "cursor-grabbing",
        className,
      )}
      style={{ color: palette.foreground, ...style }}
      {...props}
    >
      <g data-view={view} transform={`translate(${VIEW_WIDTH / 2} ${PLATE_Y}) scale(1 -1)`}>
        {showPlate ? (
          <g>
            {/* Rim first, so the frame sits on a visible thickness. */}
            <path d={plateRim} fillRule="evenodd" {...darkSurface} />
            <path d={plate} fillRule="evenodd" {...metalSurface} />
          </g>
        ) : null}

        {ordered.map(({ arm, index }) => {
          const anchor = project(arm.anchor)
          const elbow = project(arm.elbow)
          const corner = project(arm.platform)
          // Forearms come in parallel pairs — that is what keeps the platform
          // level — so draw both rods, offset across the arm's plane.
          const spread = perpendicularOffset(elbow, corner, 1.6 * weight)
          return (
            <g key={index}>
              <rect
                x={px(anchor.x - 5)}
                y={px(anchor.y - 3.4)}
                width={10}
                height={6.8}
                rx={1.8}
                {...darkSurface}
              />
              <circle cx={px(anchor.x)} cy={px(anchor.y)} r={1.5} fill={palette.metal} />
              <path d={capsulePath(anchor, elbow, 2.6 * weight)} {...shell} />
              {[1, -1].map((side) => (
                <path
                  key={side}
                  d={capsulePath(
                    { x: elbow.x + spread.x * side, y: elbow.y + spread.y * side },
                    { x: corner.x + spread.x * side, y: corner.y + spread.y * side },
                    0.9 * weight,
                  )}
                  {...metalSurface}
                />
              ))}
              <circle
                cx={px(elbow.x)}
                cy={px(elbow.y)}
                r={px(1.9 * weight)}
                fill={palette.dark}
              />
            </g>
          )
        })}

        <path d={`${deck} Z`} {...darkSurface} />
        <g transform={`translate(${px(project(platform).x)} ${px(project(platform).y)})`}>
          {tool === "vacuum" ? (
            <path d="M -3 0 L 3 0 L 1.6 -5 L -1.6 -5 Z" {...darkSurface} />
          ) : null}
          {tool === "gripper"
            ? [-1, 1].map((side) => (
                <rect
                  key={side}
                  x={px(side * 2.4 - 0.7)}
                  y={-5.5}
                  width={1.4}
                  height={5}
                  rx={0.5}
                  {...metalSurface}
                />
              ))
            : null}
          <circle
            r={px(1.5 * weight)}
            cy={-5.4}
            fill={palette.glow}
            className={engaged ? "robocn-pulse" : undefined}
          />
        </g>

        {annotate ? (
          <g fontFamily="ui-monospace, monospace" fontSize={3.4}>
            <g
              transform={`translate(${px(project(platform).x + 7)} ${px(project(platform).y - 2)}) scale(1 -1)`}
            >
              <text fill={pose.reachable ? palette.accent : palette.shell}>
                {pose.reachable
                  ? `${platform.x.toFixed(0)}, ${platform.y.toFixed(0)}, ${platform.z.toFixed(0)}`
                  : "out of workspace"}
              </text>
            </g>
            {pose.arms.map((arm, index) => (
              <g
                key={index}
                transform={`translate(${px(project(arm.anchor).x - 4)} ${px(project(arm.anchor).y + 7)}) scale(1 -1)`}
              >
                <text fill={palette.grid}>{`M${index + 1} ${arm.angle.toFixed(0)}°`}</text>
              </g>
            ))}
          </g>
        ) : null}

        {label ? (
          <g transform={`translate(0 ${-VIEW_HEIGHT + PLATE_Y + 10}) scale(1 -1)`}>
            <text
              textAnchor="middle"
              fontSize={3.8}
              fontFamily="ui-monospace, monospace"
              letterSpacing="0.4"
              fill={palette.grid}
            >
              {label}
            </text>
          </g>
        ) : null}
      </g>
    </svg>
  )
}

/** Projected outline of an equilateral triangle of side `side`, centred on `at`. */
function triangle(side: number, at: Vec3, project: (v: Vec3) => Vec2) {
  const radius = side / Math.sqrt(3)
  return [0, 1, 2]
    .map((i) => {
      // The motors sit at the edge midpoints, 60 degrees off the corners —
      // offset the corners to match, or the plate looks bolted on crooked.
      const angle = (i * 2 * Math.PI) / 3 + Math.PI / 2
      const point = project({
        x: at.x + Math.cos(angle) * radius,
        y: at.y,
        z: at.z + Math.sin(angle) * radius,
      })
      return `${i === 0 ? "M" : "L"} ${px(point.x)} ${px(point.y)}`
    })
    .join(" ")
}

function perpendicularOffset(a: Vec2, b: Vec2, amount: number): Vec2 {
  const dx = b.x - a.x
  const dy = b.y - a.y
  const length = Math.hypot(dx, dy) || 1
  return { x: (-dy / length) * amount, y: (dx / length) * amount }
}

export { DeltaArm }