Robot arm
The articulated arm: any number of links, eight end effectors, four paint variants, four mounts. Give it a target, a behaviour, or a set of joint angles.
variant
tool
motion
mount
colour
links3
Install
pnpm dlx shadcn@latest add https://robocn.dev/r/robot-arm.jsonUsage
import { RobotArm } from "@/components/ui/robot-arm"
export function Cell() {
return (
<RobotArm
links={[1, 0.82, 0.34]}
tool="welder"
behavior="pointer"
size="lg"
showEnvelope
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| links | number[] | [1, 0.82, 0.34] | Relative link lengths, shoulder outward. Scaled to fill reach, so these are proportions. |
| reach | number | 64 | Total stretch in world units. The view is 132 × 118 with the base at (0, 0). |
| target | Vec2 | ((clock: number) => Vec2) | null | null | Where the tool tip should be. A function of elapsed seconds scripts a path. |
| angles | number[] | — | Drive it forward instead: one degree value per joint. Overrides target and behavior. |
| behavior | "pointer" | "orbit" | "sweep" | "idle" | "static" | "idle" | What moves the tip when no target is given. |
| bend | "up" | "down" | "up" | Which way the elbow breaks. |
| tool | "gripper" | "welder" | "painter" | "cutter" | "scanner" | "vacuum" | "magnet" | "none" | "gripper" | End effector. |
| grip | number | — | Jaw opening, 0 closed to 1 wide. Defaults to reacting to `active`. |
| active | boolean | — | Tool running — sparks, spray, a spinning blade. Defaults to whether the tip is moving. |
| mount | "floor" | "ceiling" | "wall-left" | "wall-right" | "floor" | Where the machine is bolted down. |
| 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. |
| thickness | number | 1 | Limb weight multiplier, independent of size. |
| speed | number | — | Tip travel in world units per second. |
| animate | boolean | true | Off snaps to a fixed goal or samples a scripted path at phase, then stops. Reduced-motion preference does the same. |
| paused | boolean | false | Freeze in place. |
| phase | number | 0 | Seconds of offset, so a row of machines breaks step. |
| label | string | — | Stencilled onto the base plate. |
| showEnvelope | boolean | false | Dashed arcs for the inner and outer limits of the workspace. |
| showAngles | boolean | — | Joint readouts. On by default in the blueprint variant. |
| showBase / showCable / showGrid | boolean | — | Turn individual parts of the drawing off. |
| onPose | (pose: RobotArmPose) => void | — | Every solved pose: joints, tip, angles, and whether it is moving. |
| color | string | var(--robot-shell) | Body panels — the colour the machine reads as. |
| accent | string | var(--robot-accent) | Status colour: tip light, live tool, readouts. |
| metal | string | var(--robot-metal) | Bare machined parts: collars, bolts, tool bodies. |
| dark | string | var(--robot-dark) | Cast joints, base, shadow side. |
| palette | Partial<RobotPalette> | — | Override any subset of roles at once, including glow and grid. |
Source
src/components/ui/robot-arm.tsx
"use client"
/**
* robot-arm — an articulated industrial arm drawn as SVG.
*
* Any number of links, eight end effectors, four paint styles, four mounts.
* The pose comes from the shared kinematics core, so the same target produces
* the same arm here and in `robot-arm-3d`.
*/
import * as React from "react"
import { usePointerTarget } from "@/hooks/use-pointer-target"
import {
useRobotArm,
type RobotArmPose,
type RobotTarget,
} from "@/hooks/use-robot-arm"
import {
add2,
chainMinReach,
chainReach,
forwardChain2,
normalize2,
scale2,
sub2,
toDegrees,
type Bend,
type Vec2,
} from "@/lib/robocn/kinematics"
import {
capsulePath,
labelTransform,
linkRole,
px,
mountTransform,
resolveRobotPalette,
resolveRobotSize,
robotSurface,
type RobotBehavior,
type RobotMount,
type RobotPalette,
type RobotPaletteProps,
type RobotSize,
type RobotTool,
type RobotVariant,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"
/* World units. Geometry is authored once in here and only ever scaled. */
const VIEW_WIDTH = 132
const VIEW_HEIGHT = 118
const FLOOR = 14
const SHOULDER_LIFT = 13
const DEFAULT_REACH = 64
export interface RobotArmProps
extends Omit<React.ComponentProps<"svg">, "color" | "target">,
RobotPaletteProps {
/** Relative link lengths, shoulder outward. Scaled to fill `reach`. */
links?: number[]
/** Total stretch in world units; the view is 132 x 118 with the base centred. */
reach?: number
/**
* Controlled tool-tip goal in world units, base at (0, 0). Pass a function
* of elapsed seconds to script a path.
*/
target?: RobotTarget
/**
* Drive the arm forward instead: one angle per link, in degrees, each
* relative to the previous segment. Overrides `target` and `behavior`.
*/
angles?: number[]
/** What drives the tip when `target` is null. */
behavior?: RobotBehavior
/** Which way the elbow breaks. */
bend?: Bend
tool?: RobotTool
/** Jaw opening, 0 closed to 1 wide. Defaults to reacting to `active`. */
grip?: number
/** Tool running: sparks, spray, a spinning blade. Defaults to "while moving". */
active?: boolean
variant?: RobotVariant
size?: RobotSize | number
/** Limb weight multiplier. */
thickness?: number
mount?: RobotMount
/** Tip travel in world units per second. */
speed?: number
animate?: boolean
paused?: boolean
/** Seconds of phase offset, so a row of arms breaks step. */
phase?: number
/** Stencilled onto the base plate. */
label?: string
showBase?: boolean
showCable?: boolean
/** Dashed arcs for the inner and outer limits of the workspace. */
showEnvelope?: boolean
/** Joint angle readouts. On by default in the blueprint variant. */
showAngles?: boolean
showGrid?: boolean
/** Called whenever the pose changes — handy for wiring readouts. */
onPose?: (pose: RobotArmPose) => void
}
function RobotArm({
links = [1, 0.82, 0.34],
reach = DEFAULT_REACH,
target = null,
angles,
behavior = "idle",
bend = "up",
tool = "gripper",
grip,
active,
variant = "solid",
size = "md",
thickness = 1,
mount = "floor",
speed,
animate = true,
paused = false,
phase = 0,
label,
showBase = true,
showCable = true,
showEnvelope = false,
showAngles,
showGrid,
onPose,
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
className,
style,
...props
}: RobotArmProps) {
const uid = React.useId().replace(/[:]/g, "")
const palette = resolveRobotPalette({
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
})
const width = resolveRobotSize(size)
const height = (width * VIEW_HEIGHT) / VIEW_WIDTH
const blueprint = variant === "blueprint"
const annotate = showAngles ?? blueprint
const gridded = showGrid ?? blueprint
// Link ratios are scaled to the requested reach, so `links` stays a shape
// description and `reach` stays the only length anyone has to think about.
const scaled = React.useMemo(() => {
const total = links.reduce((sum, link) => sum + Math.max(link, 0.01), 0)
return links.map((link) => (Math.max(link, 0.01) / total) * reach)
}, [links, reach])
const root = React.useMemo<Vec2>(() => ({ x: 0, y: SHOULDER_LIFT }), [])
const svgRef = React.useRef<SVGSVGElement>(null)
const pointer = usePointerTarget(svgRef, {
enabled: behavior === "pointer" && !paused,
toWorld: React.useCallback((unit: Vec2) => toWorldUnits(unit, mount), [mount]),
})
const pose = useRobotArm({
links: scaled,
root,
target: behavior === "pointer" ? (target ?? pointer.target) : target,
behavior,
bend,
speed,
animate,
paused,
phase,
})
React.useEffect(() => {
onPose?.(pose)
}, [onPose, pose])
// Forward kinematics wins when angles are supplied: the arm is then driven
// joint by joint, which is what a control panel wants.
const driven = React.useMemo(
() => (angles ? forwardChain2(root, angles, scaled) : null),
[angles, root, scaled],
)
const engaged = active ?? (driven ? false : pose.moving)
const joints = driven ?? pose.joints
const tip = joints[joints.length - 1]
const wrist = joints[joints.length - 2] ?? root
const heading = Math.atan2(tip.y - wrist.y, tip.x - wrist.x)
const weight = thickness * (reach / DEFAULT_REACH)
const rootWeight = 3.1 * weight
const tipWeight = 1.9 * weight
return (
<svg
ref={svgRef}
role="img"
aria-label={`Robotic arm with ${scaled.length} links and a ${tool} end effector`}
viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
width={width}
height={height}
className={cn("select-none overflow-hidden", className)}
style={{ color: palette.foreground, ...style }}
{...props}
>
<defs>
<filter id={`${uid}-glow`} x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="1.6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<pattern
id={`${uid}-grid`}
width="8"
height="8"
patternUnits="userSpaceOnUse"
>
<path
d="M 8 0 L 0 0 0 8"
fill="none"
stroke={palette.grid}
strokeWidth="0.3"
opacity="0.35"
/>
</pattern>
</defs>
{gridded ? (
<rect
width={VIEW_WIDTH}
height={VIEW_HEIGHT}
fill={`url(#${uid}-grid)`}
/>
) : null}
<g transform={mountTransform(mount, VIEW_WIDTH, VIEW_HEIGHT, FLOOR)}>
{showEnvelope ? (
<Envelope root={root} links={scaled} palette={palette} />
) : null}
{showBase ? (
<Base
palette={palette}
variant={variant}
weight={weight}
label={label}
mount={mount}
accentLit={engaged}
/>
) : null}
{showCable ? (
<Cable joints={joints} palette={palette} weight={weight} />
) : null}
{scaled.map((_, index) => (
<Limb
key={index}
a={joints[index]}
b={joints[index + 1]}
radius={
rootWeight +
((tipWeight - rootWeight) * index) / Math.max(1, scaled.length - 1)
}
role={linkRole(index)}
variant={variant}
palette={palette}
weight={weight}
/>
))}
{joints.slice(1, -1).map((joint, index) => (
<Joint
key={index}
at={joint}
radius={(rootWeight + 0.9) * (1 - index * 0.12)}
variant={variant}
palette={palette}
weight={weight}
/>
))}
<Joint
at={joints[0]}
radius={rootWeight + 1.8}
variant={variant}
palette={palette}
weight={weight}
shoulder
/>
<g
transform={`translate(${px(tip.x)} ${px(tip.y)}) rotate(${px(toDegrees(heading))})`}
>
<ToolHead
tool={tool}
palette={palette}
variant={variant}
weight={weight}
grip={grip ?? (engaged ? 0.18 : 0.7)}
engaged={engaged}
glowId={`${uid}-glow`}
/>
</g>
{annotate ? (
<Annotations
joints={joints}
angles={angles ?? pose.angles}
palette={palette}
mount={mount}
weight={weight}
/>
) : null}
</g>
</svg>
)
}
/* -------------------------------------------------------------------------- */
/* parts */
/* -------------------------------------------------------------------------- */
function Limb({
a,
b,
radius,
role,
variant,
palette,
weight,
}: {
a: Vec2
b: Vec2
radius: number
role: ReturnType<typeof linkRole>
variant: RobotVariant
palette: RobotPalette
weight: number
}) {
const surface = robotSurface(role, variant, palette, weight)
const inner = normalize2(sub2(b, a), { x: 1, y: 0 })
const shrink = scale2(inner, radius * 0.55)
return (
<g>
<path d={capsulePath(a, b, radius)} {...surface} />
{variant === "solid" ? (
// A thin lighter core reads as a machined channel down the limb.
<path
d={capsulePath(add2(a, shrink), sub2(b, shrink), radius * 0.34)}
fill={role === "shell" ? palette.metal : palette.dark}
opacity={0.55}
/>
) : null}
</g>
)
}
function Joint({
at,
radius,
variant,
palette,
weight,
shoulder = false,
}: {
at: Vec2
radius: number
variant: RobotVariant
palette: RobotPalette
weight: number
shoulder?: boolean
}) {
const surface = robotSurface("dark", variant, palette, weight)
return (
<g transform={`translate(${px(at.x)} ${px(at.y)})`}>
<circle r={px(radius)} {...surface} stroke={palette.dark} strokeWidth={0.5 * weight} />
<circle
r={px(radius * 0.45)}
fill={variant === "solid" ? palette.metal : "none"}
stroke={palette.metal}
strokeWidth={0.5 * weight}
opacity={0.9}
/>
{shoulder ? (
<circle r={px(radius * 0.16)} fill={palette.metal} opacity={0.8} />
) : null}
</g>
)
}
function Base({
palette,
variant,
weight,
label,
mount,
accentLit,
}: {
palette: RobotPalette
variant: RobotVariant
weight: number
label?: string
mount: RobotMount
accentLit: boolean
}) {
const dark = robotSurface("dark", variant, palette, weight)
const shell = robotSurface("shell", variant, palette, weight)
return (
<g>
<rect
x={-17}
y={-2}
width={34}
height={5.5}
rx={2}
{...dark}
stroke={palette.dark}
strokeWidth={0.6 * weight}
/>
{[-13.5, -7, 7, 13.5].map((x) => (
<circle key={x} cx={x} cy={0.8} r={1.1} fill={palette.metal} opacity={0.85} />
))}
<path
d={`M -11 3 L 11 3 L 8 ${SHOULDER_LIFT} L -8 ${SHOULDER_LIFT} Z`}
{...shell}
/>
<rect
x={-8.5}
y={SHOULDER_LIFT - 3.4}
width={17}
height={2.2}
rx={1}
fill={palette.accent}
opacity={accentLit ? 1 : 0.55}
/>
{label ? (
<g transform={`translate(0 6.5) ${labelTransform(mount)}`}>
<text
textAnchor="middle"
fontSize={3.6}
fontFamily="ui-monospace, monospace"
letterSpacing="0.4"
fill={palette.metal}
>
{label}
</text>
</g>
) : null}
</g>
)
}
/** Loom running from the base to the wrist, sagging under its own weight. */
function Cable({
joints,
palette,
weight,
}: {
joints: Vec2[]
palette: RobotPalette
weight: number
}) {
const start = { x: -6, y: SHOULDER_LIFT - 1 }
const end = joints[joints.length - 2] ?? joints[0]
const mid = joints[Math.max(1, Math.floor(joints.length / 2))]
return (
<path
d={`M ${px(start.x)} ${px(start.y)} Q ${px(mid.x - 5)} ${px(mid.y - 8)} ${px(end.x)} ${px(end.y)}`}
fill="none"
stroke={palette.dark}
strokeWidth={1.1 * weight}
strokeLinecap="round"
opacity={0.75}
/>
)
}
function Envelope({
root,
links,
palette,
}: {
root: Vec2
links: number[]
palette: RobotPalette
}) {
const outer = chainReach(links)
const inner = chainMinReach(links)
return (
<g
fill="none"
stroke={palette.grid}
strokeWidth={0.4}
strokeDasharray="2 2"
opacity={0.7}
>
<circle cx={px(root.x)} cy={px(root.y)} r={px(outer)} />
{inner > 0.5 ? <circle cx={px(root.x)} cy={px(root.y)} r={px(inner)} /> : null}
</g>
)
}
/**
* End effectors. Drawn in the wrist frame: +x runs out along the last link, so
* a tool never has to know how the arm got there.
*/
function ToolHead({
tool,
palette,
variant,
weight,
grip,
engaged,
glowId,
}: {
tool: RobotTool
palette: RobotPalette
variant: RobotVariant
weight: number
grip: number
engaged: boolean
glowId: string
}) {
const metal = robotSurface("metal", variant, palette, weight)
const dark = robotSurface("dark", variant, palette, weight)
const jaw = 1.6 + grip * 3.4
return (
<g>
<rect x={-1.4} y={-3} width={4.4} height={6} rx={1.2} {...dark} />
<rect x={2.4} y={-2.2} width={1.6} height={4.4} rx={0.6} {...metal} />
{tool === "gripper" ? (
<g>
{[1, -1].map((side) => (
<path
key={side}
d={`M 4 ${side * 1.2} L ${7.4} ${side * jaw} L ${9.6} ${side * (jaw - 0.5)} L ${9.6} ${side * (jaw + 1.1)} L ${6.6} ${side * (jaw + 1.4)} L 4 ${side * 2.6} Z`}
{...metal}
/>
))}
</g>
) : null}
{tool === "welder" ? (
<g>
<path d="M 4 -1.6 L 9 -0.5 L 9 0.5 L 4 1.6 Z" {...metal} />
{engaged ? (
<g filter={`url(#${glowId})`}>
<circle cx={10.2} cy={0} r={1.5} fill={palette.glow} />
{[0, 1, 2, 3].map((i) => (
<circle
key={i}
className="robocn-spark"
cx={10}
cy={(i - 1.5) * 0.9}
r={0.5}
fill={palette.glow}
style={{ animationDelay: `${i * -0.17}s` }}
/>
))}
</g>
) : null}
</g>
) : null}
{tool === "painter" ? (
<g>
<path d="M 4 -1.9 L 8.6 -0.8 L 8.6 0.8 L 4 1.9 Z" {...metal} />
<rect x={0.4} y={-4.6} width={2.6} height={2.4} rx={0.8} {...metal} />
{engaged ? (
<g>
<path
d="M 8.8 -0.7 L 15 -3.4 L 15 3.4 L 8.8 0.7 Z"
fill={palette.accent}
opacity={0.16}
/>
{[0, 1, 2, 3, 4, 5].map((i) => (
<circle
key={i}
className="robocn-spray"
cx={9}
cy={(i % 3) - 1}
r={0.45}
fill={palette.accent}
style={{ animationDelay: `${i * -0.14}s` }}
/>
))}
</g>
) : null}
</g>
) : null}
{tool === "cutter" ? (
<g transform="translate(7.4 0)">
<g
className={engaged ? "robocn-spin" : undefined}
style={{ transformOrigin: "0px 0px" }}
>
<circle r={3.4} {...metal} />
<circle
r={3.4}
fill="none"
stroke={palette.dark}
strokeWidth={0.8}
strokeDasharray="1.4 1.4"
/>
</g>
<circle r={0.9} fill={palette.dark} />
</g>
) : null}
{tool === "scanner" ? (
<g>
<circle cx={6.4} cy={0} r={2.6} fill="none" stroke={palette.accent} strokeWidth={0.9} />
{engaged ? (
<g className="robocn-scan" style={{ transformOrigin: "6.4px 0px" }}>
<path
d="M 6.4 0 L 14 -4 L 14 4 Z"
fill={palette.accent}
opacity={0.18}
/>
</g>
) : null}
</g>
) : null}
{tool === "vacuum" ? (
<g>
<rect x={4} y={-1.4} width={2.4} height={2.8} rx={0.6} {...metal} />
<path d="M 6.4 -3.4 L 9.8 -2 L 9.8 2 L 6.4 3.4 Z" {...dark} />
<ellipse cx={10} cy={0} rx={0.9} ry={3.2} fill={palette.dark} opacity={0.9} />
</g>
) : null}
{tool === "magnet" ? (
<g>
<path
d="M 4.2 -3.4 A 4 4 0 0 1 4.2 3.4 L 4.2 1.4 A 2 2 0 0 0 4.2 -1.4 Z"
{...metal}
/>
<rect x={3.4} y={-3.6} width={1.4} height={2.2} fill={palette.accent} />
<rect x={3.4} y={1.4} width={1.4} height={2.2} fill={palette.dark} />
</g>
) : null}
<circle
cx={-1.8}
cy={0}
r={engaged ? 1.15 : 0.85}
fill={palette.glow}
className={engaged ? "robocn-pulse" : undefined}
filter={engaged ? `url(#${glowId})` : undefined}
/>
</g>
)
}
function Annotations({
joints,
angles,
palette,
mount,
weight,
}: {
joints: Vec2[]
angles: number[]
palette: RobotPalette
mount: RobotMount
weight: number
}) {
const tip = joints[joints.length - 1]
return (
<g>
{joints.slice(0, -1).map((joint, index) => (
<g
key={index}
transform={`translate(${px(joint.x + 5)} ${px(joint.y + 5)}) ${labelTransform(mount)}`}
>
<text
fontSize={3.4}
fontFamily="ui-monospace, monospace"
fill={palette.grid}
>
{`J${index + 1} ${angles[index]?.toFixed(0) ?? 0}°`}
</text>
</g>
))}
<g
stroke={palette.accent}
strokeWidth={0.4 * weight}
opacity={0.8}
fill="none"
>
<path d={`M ${px(tip.x - 5)} ${px(tip.y)} H ${px(tip.x + 5)}`} />
<path d={`M ${px(tip.x)} ${px(tip.y - 5)} V ${px(tip.y + 5)}`} />
<circle cx={px(tip.x)} cy={px(tip.y)} r={2.6} strokeDasharray="1.2 1.2" />
</g>
<g transform={`translate(${px(tip.x + 6)} ${px(tip.y - 6)}) ${labelTransform(mount)}`}>
<text
fontSize={3.2}
fontFamily="ui-monospace, monospace"
fill={palette.accent}
>
{`${tip.x.toFixed(0)}, ${tip.y.toFixed(0)}`}
</text>
</g>
</g>
)
}
/* -------------------------------------------------------------------------- */
/* pointer mapping */
/* -------------------------------------------------------------------------- */
/** Inverse of {@link mountTransform}: a point in the box, in robot coordinates. */
function toWorldUnits(unit: Vec2, mount: RobotMount): Vec2 {
const sx = unit.x * VIEW_WIDTH
const sy = unit.y * VIEW_HEIGHT
switch (mount) {
case "ceiling":
return { x: sx - VIEW_WIDTH / 2, y: sy - FLOOR }
case "wall-left":
return { x: VIEW_HEIGHT / 2 - sy, y: sx - FLOOR }
case "wall-right":
return { x: VIEW_HEIGHT / 2 - sy, y: VIEW_WIDTH - FLOOR - sx }
default:
return { x: sx - VIEW_WIDTH / 2, y: VIEW_HEIGHT - FLOOR - sy }
}
}
export { RobotArm, VIEW_HEIGHT as ROBOT_ARM_VIEW_HEIGHT, VIEW_WIDTH as ROBOT_ARM_VIEW_WIDTH }