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

Infantry droid

A mechanical field unit with skeletal and armored frames, four controlled poses, and non-projectile utility equipment.

Open in workbench
INFANTRY / 19
view
variant
frame
module
drive

The head follows the pointer; click to put it on guard.

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/infantry-droid.json

Notes

  • Front elevation is the drawing it always had. A humanoid drawn from the front says nothing about its own depth: off that axis the torso, pelvis and head are boxes and the limbs are tubes set through the body, at the same joint angles the pose already solved.
  • Equipment deliberately stays at utility pack, survey scanner, and protective shield silhouettes.
  • Light and heavy frames share the same pose contract for easy replacement.

Usage

import { InfantryDroid } from "@/components/ui/infantry-droid"

<InfantryDroid frame="heavy" pose="guard" equipment="shield" signal="warning" />

Props

PropTypeDefaultDescription
view"plan" | "front" | "profile" | "iso""front"Where the camera stands. One droid, four projections: straight down, straight on, side elevation, or three-quarter from above.
frame"light" | "heavy""light"Skeletal scout or armored field silhouette.
pose"stand" | "march" | "guard" | "disabled""stand"Controlled whole-body posture.
equipment"none" | "pack" | "scanner" | "shield""none"Visible utility module carried by the frame.
headAnglenumber0Head rotation in degrees, clamped to −75..75.
behavior"patrol" | "alert" | "idle" | "static""patrol"Patrol marches and looks left and right; alert stands to guard and scans in short turns; idle stands easy.
speednumber0.2Sweeps per second.
animatebooleantrueOff parks the machine at phase and stops rendering. A reduced-motion preference does the same.
pausedbooleanfalseFreeze where it stands.
phasenumber0Seconds of offset, so a row of machines breaks step.
interactivebooleantrueThe head follows the pointer, and a click puts the frame on guard — or stands it down again.
onPoseChange(pose: InfantryDroidPose) => void—The stance a click asked for.
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.
showGroundbooleantrueDraw the contact line or shadow beneath the droid.
signal"idle" | "ready" | "warning"—Status-lamp state using neutral, accent, or shell colour; each component documents its visual default in the demo.
labelstring—Optional technical caption under the drawing.
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/infantry-droid.tsx
"use client"

import * as React from "react"

import { usePointerTarget } from "@/hooks/use-pointer-target"
import { useRobotClock } from "@/hooks/use-robot-motion"

import { clamp, toRadians } from "@/lib/robocn/kinematics"
import {
  aboutPoint,
  capsulePath,
  extrudedPath,
  px,
  resolveRobotPalette,
  resolveRobotSize,
  robotCamera,
  robotSurface,
  roundedFootprint,
  type RobotPaletteProps,
  type RobotSize,
  type RobotVariant,
  type RobotView,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"

export type InfantryDroidFrame = "light" | "heavy"
export type InfantryDroidPose = "stand" | "march" | "guard" | "disabled"
export type InfantryDroidEquipment = "none" | "pack" | "scanner" | "shield"
export type InfantryDroidBehavior = "patrol" | "alert" | "idle" | "static"

export interface InfantryDroidProps
  extends Omit<React.ComponentProps<"svg">, "color">,
    RobotPaletteProps {
  size?: RobotSize | number
  variant?: RobotVariant
  frame?: InfantryDroidFrame
  /** Stance. Omit and `behavior` picks one. */
  /** Where the camera stands. One droid, four projections. */
  view?: RobotView
  pose?: InfantryDroidPose
  /** Head turn in degrees. Omit and it looks around on its own. */
  headAngle?: number
  /** What it does on its own: walk a beat, scan for you, or stand easy. */
  behavior?: InfantryDroidBehavior
  /** Sweeps per second. */
  speed?: number
  animate?: boolean
  paused?: boolean
  phase?: number
  /** The head follows the pointer, and a click puts it on guard. */
  interactive?: boolean
  onPoseChange?: (pose: InfantryDroidPose) => void
  equipment?: InfantryDroidEquipment
  signal?: "idle" | "ready" | "warning"
  showGround?: boolean
  label?: string
}

/** The droid is drawn standing straight on; that is the camera it defaults to. */
const NATIVE_VIEW: RobotView = "front"
/** Where it stands in the frame, and the depths a front elevation never had
 *  to give: how far the body, limbs and head reach through the picture. */
const CENTRE = 90
const GROUND = 198
const BODY_DEEP = 10
const LIMB_DEEP = 6

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

const poses: Record<InfantryDroidPose, { lean: number; leftLeg: number; rightLeg: number; arm: number }> = {
  stand: { lean: 0, leftLeg: -2, rightLeg: 2, arm: 5 },
  march: { lean: -5, leftLeg: -17, rightLeg: 18, arm: 24 },
  guard: { lean: 3, leftLeg: 6, rightLeg: -6, arm: -28 },
  disabled: { lean: 24, leftLeg: -23, rightLeg: 32, arm: 44 },
}

function InfantryDroid({
  size = "md",
  variant = "solid",
  view = NATIVE_VIEW,
  frame = "light",
  pose,
  headAngle,
  behavior = "patrol",
  speed = 0.2,
  animate = true,
  paused = false,
  phase = 0,
  interactive = true,
  onPoseChange,
  equipment = "none",
  signal = "idle",
  showGround = true,
  label,
  color,
  accent,
  metal,
  dark,
  glow,
  grid,
  palette: paletteOverride,
  className,
  style,
  onPointerDown,
  ...props
}: InfantryDroidProps) {
  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })
  const width = resolveRobotSize(size)
  const svgRef = React.useRef<SVGSVGElement>(null)
  const clock = useRobotClock({ speed, animate: animate && behavior !== "static", paused, phase })
  const [guarding, setGuarding] = React.useState(false)
  const pointer = usePointerTarget(svgRef, {
    enabled: interactive && headAngle === undefined && !paused,
    toWorld: React.useCallback((unit: { x: number; y: number }) => ({
      x: (unit.x - 0.5) * 2,
      y: (unit.y - 0.5) * 2,
    }), []),
  })
  const scripted = infantryDroidPose(guarding ? "alert" : behavior, clock)
  const stanceName = pose ?? scripted.pose
  const stance = poses[stanceName] ?? poses.stand
  const turn = finiteClamp(
    headAngle ?? (pointer.target ? clamp(pointer.target.x, -1, 1) * 70 : scripted.head),
    -75,
    75,
  )
  const heavy = frame === "heavy"
  const shell = robotSurface("shell", variant, palette)
  const machined = robotSurface("metal", variant, palette)
  const cast = robotSurface("dark", variant, palette)
  const signalColor = signal === "warning" ? palette.shell : signal === "ready" ? palette.accent : palette.metal
  // The drawing is a front elevation, so it goes through `wall` where the
  // droid stands and comes out untouched straight on. A humanoid drawn from
  // the front says nothing about its own depth: the torso, pelvis and head
  // become boxes, and the limbs tubes set through the body.
  const camera = robotCamera(view)
  const offAxis = view !== NATIVE_VIEW
  const face = aboutPoint(camera.wall(), CENTRE, GROUND)
  const Frame = (face ? "g" : React.Fragment) as React.FC<{
    transform?: string
    children?: React.ReactNode
  }>
  const faceProps = face ? { transform: face } : {}
  /** A point in the frame, leaned with the body and set `deep` toward us. */
  const at = (x: number, y: number, deep = 0) => {
    const tilt = toRadians(stance.lean)
    return camera.project(
      -(x * Math.cos(tilt) - y * Math.sin(tilt)),
      -(x * Math.sin(tilt) + y * Math.cos(tilt)),
      -deep,
    )
  }
  /** The joints of a limb: each segment turned by its own angle, in order. */
  const chain = (
    ox: number,
    oy: number,
    deep: number,
    segments: readonly { angle: number; length: number }[],
  ) => {
    let x = ox
    let y = oy
    const joints = [at(x, y, deep)]
    for (const segment of segments) {
      const a = toRadians(segment.angle)
      x -= Math.sin(a) * segment.length
      y += Math.cos(a) * segment.length
      joints.push(at(x, y, deep))
    }
    return joints
  }
  /** A box between two heights above the ground, `deep` either side. */
  const solid = (halfWidth: number, deep: number, top: number, bottom: number, x = 0) =>
    extrudedPath(
      roundedFootprint(halfWidth, deep, Math.min(halfWidth, deep) * 0.4, 4).map(point => ({
        x: point.x - x,
        y: point.y,
      })),
      camera, top, bottom,
    )


  return (
    <svg
      ref={svgRef}
      role="img"
      aria-label={`${heavy ? "Heavy" : "Light"} infantry droid, ${stanceName} pose, ${viewNames[view] ?? viewNames.front}`}
      onPointerDown={(event) => {
        onPointerDown?.(event)
        if (!interactive || event.defaultPrevented) return
        const next = !guarding
        setGuarding(next)
        onPoseChange?.(next ? "guard" : "stand")
      }}
      viewBox="0 0 180 230"
      width={width}
      height={px(width * 1.28)}
      className={cn("max-w-full select-none", className)}
      style={{ color: palette.foreground, ...style }}
      {...props}
    >
      {variant === "blueprint" && (
        <g fill="none" stroke={palette.grid} strokeWidth={0.5} opacity={0.45}>
          <path d="M 12 208 H 168 M 90 10 V 215" strokeDasharray="2 3" />
          <circle cx={90} cy={113} r={78} strokeDasharray="2 3" />
        </g>
      )}
      {showGround && <ellipse cx={90} cy={207} rx={46} ry={6} fill={palette.dark} opacity={0.14} />}
      {offAxis && <g data-solids transform={`translate(${CENTRE} ${GROUND})`}>
        {([-1, 1] as const).map(side => {
          const [hip, knee, ankle] = chain(side * (heavy ? 17 : 13), -61, side * LIMB_DEEP, [
            { angle: side < 0 ? stance.leftLeg : stance.rightLeg, length: 42 },
            { angle: side < 0 ? stance.leftLeg : stance.rightLeg, length: 45 },
          ])
          return (
            <g key={side} data-leg={side < 0 ? "left" : "right"}>
              <path d={capsulePath(hip, knee, 6)} {...shell} />
              <path d={capsulePath(knee, ankle, 5)} {...machined} />
            </g>
          )
        })}
        {([-1, 1] as const).map(side => {
          const [shoulder, elbow, wrist] = chain(side * (heavy ? 35 : 25), -116, side * BODY_DEEP, [
            { angle: side * stance.arm, length: 43 },
            { angle: side * stance.arm, length: 38 },
          ])
          return (
            <g key={side} data-arm={side < 0 ? "left" : "right"}>
              <path d={capsulePath(shoulder, elbow, 6)} {...shell} />
              <path d={capsulePath(elbow, wrist, 5)} {...machined} />
            </g>
          )
        })}
        <path d={solid(heavy ? 32 : 22, BODY_DEEP, 126, 61)} {...shell} />
        <path d={solid(heavy ? 26 : 15, BODY_DEEP - 2, 117, 74)} {...cast} />
        <path d={solid(7, 6, 153, 126)} {...machined} />
        <path d={solid(16, 13, 172, 140, turn * 0.11)} {...shell} />
      </g>}
      <Frame {...faceProps}>
      <g data-frame={frame} data-view={view} transform={`translate(90 198) rotate(${stance.lean})`}>
        {[-1, 1].map((side) => (
          <g key={side} data-leg={side < 0 ? "left" : "right"} transform={`translate(${side * (heavy ? 17 : 13)} -61) rotate(${side < 0 ? stance.leftLeg : stance.rightLeg})`}>
            <rect x={-6} y={0} width={12} height={41} rx={4} {...(heavy ? shell : machined)} />
            <circle cy={42} r={heavy ? 8 : 6} {...cast} />
            <rect x={-5} y={42} width={10} height={45} rx={4} {...machined} />
            <path d="M -7 85 H 12 Q 19 85 19 91 H -8 Z" {...(heavy ? shell : cast)} />
          </g>
        ))}

        <path d={heavy ? "M -34 -126 L -29 -61 Q 0 -49 29 -61 L 34 -126 Z" : "M -23 -119 L -18 -62 Q 0 -52 18 -62 L 23 -119 Z"} {...shell} />
        <path d={heavy ? "M -27 -117 H 27 L 20 -74 H -20 Z" : "M -16 -109 H 16 L 12 -72 H -12 Z"} {...cast} />
        <circle cy={-62} r={heavy ? 10 : 7} {...machined} />
        <rect x={-7} y={-138} width={14} height={18} rx={4} {...machined} />

        {[-1, 1].map((side) => (
          <g key={side} data-arm={side < 0 ? "left" : "right"} transform={`translate(${side * (heavy ? 35 : 25)} -116) rotate(${side * stance.arm})`}>
            <circle r={heavy ? 9 : 6.5} {...cast} />
            <rect x={-6} y={0} width={12} height={42} rx={4} {...(heavy ? shell : machined)} />
            <circle cy={43} r={6} {...cast} />
            <rect x={-5} y={43} width={10} height={38} rx={4} {...machined} />
            <path d="M -5 82 L -6 91 M 0 82 V 93 M 5 82 L 6 91" stroke={palette.dark} strokeWidth={2} strokeLinecap="round" />
          </g>
        ))}

        <g data-head transform={`translate(${px(turn * 0.11)} -153) rotate(${px(turn * 0.06)})`}>
          <path d={heavy ? "M -25 -17 H 25 L 20 18 H -20 Z" : "M -20 -14 H 20 L 15 15 H -15 Z"} {...(heavy ? shell : machined)} />
          <rect x={heavy ? -18 : -14} y={-7} width={heavy ? 36 : 28} height={9} rx={3} {...cast} />
          <circle cx={px(turn * 0.08)} cy={-2.5} r={3.5} fill={signalColor} />
          {!heavy && <path d="M 12 -8 L 24 -15" stroke={palette.dark} strokeWidth={2} />}
        </g>

        {equipment !== "none" && (
          <g data-equipment={equipment}>
            {equipment === "pack" && <rect x={heavy ? -38 : -29} y={-111} width={12} height={35} rx={4} {...cast} />}
            {equipment === "scanner" && (
              <g transform="translate(28 -95)">
                <path d="M 0 0 L 22 -9 V 9 Z" {...machined} />
                <circle cx={20} r={3} fill={palette.accent} />
              </g>
            )}
            {equipment === "shield" && <path d="M 34 -118 Q 59 -106 55 -68 Q 46 -51 34 -45 Z" {...shell} />}
          </g>
        )}
      </g>
      </Frame>
      {label && <text x={90} y={224} textAnchor="middle" fontFamily="ui-monospace, monospace" fontSize={6} fill={palette.foreground}>{label}</text>}
    </svg>
  )
}

const finiteClamp = (value: number, min: number, max: number) =>
  Number.isFinite(value) ? clamp(value, min, max) : 0

export { InfantryDroid }

/**
 * The beat it walks when nobody is posing it. A patrol marches and looks left
 * and right as it goes; an alert stands to guard and scans in short, quick
 * turns; idle stands easy and barely moves.
 */
export function infantryDroidPose(behavior: InfantryDroidBehavior, clock: number) {
  const t = Number.isFinite(clock) ? clock : 0
  switch (behavior) {
    case "alert":
      return { pose: "guard" as InfantryDroidPose, head: Math.sin(t * Math.PI * 4) * 55 }
    case "idle":
      return { pose: "stand" as InfantryDroidPose, head: Math.sin(t * Math.PI * 0.6) * 12 }
    case "static":
      return { pose: "stand" as InfantryDroidPose, head: 0 }
    default:
      return { pose: "march" as InfantryDroidPose, head: Math.sin(t * Math.PI * 2) * 38 }
  }
}