Security droid
A tall angular guard robot with controlled patrol postures, a pointer-tracking sensor bar, and a visible alert state.
Open in workbenchview
variant
drive
state
The sensor bar tracks the pointer unless a controlled look is supplied.
Alert overrides the behaviour: it stands the frame to guard and shortens the scan, whatever the drive says.
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/security-droid.jsonNotes
- 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.
- Alert is visual state only; the component does not infer threats or start a timer.
- The tracked sensor translates inside a fixed protective bar so it remains mechanically legible.
Usage
import { SecurityDroid } from "@/components/ui/security-droid"
<SecurityDroid behavior="patrol" />
<SecurityDroid pose="guard" alert look={{ x: -0.4, y: 0 }} headAngle={-20} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| behavior | "patrol" | "alert" | "idle" | "static" | "patrol" | The beat it walks when it is not posed. Alert stands it to guard and shortens the scan. |
| speed | number | 0.35 | Cycles per second: one sweep of the head. |
| pose | "stand" | "patrol" | "guard" | — | Whole-frame stance and arm position. Omit and behavior picks one. |
| headAngle | number | — | Head rotation in degrees, clamped to −70..70. Omit to run behavior. |
| look | Vec2 | null | null | Controlled horizontal sensor aim; overrides tracking. |
| track | boolean | true | Follow the page pointer while look is null. |
| alert | boolean | false | Light the alert beacon, promote the sensor colour, and stand it to guard. |
| animate | boolean | true | Off parks the machine at phase and stops rendering. A reduced-motion preference does the same. |
| paused | boolean | false | Freeze where it stands. |
| phase | number | 0 | Seconds of offset, so a row of machines breaks step. |
| 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. |
| showGround | boolean | true | Draw 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. |
| label | string | — | Optional technical caption under the drawing. |
| 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/security-droid.tsx
"use client"
/**
* security-droid — a guard frame that walks its beat.
*
* Three stances and a head that turns. Uncontrolled it patrols, and going
* `alert` stands it to guard and shortens its scan. Supply `pose` or
* `headAngle` and that channel is yours.
*/
import * as React from "react"
import { usePointerTarget } from "@/hooks/use-pointer-target"
import { useRobotClock } from "@/hooks/use-robot-motion"
import { clamp, toRadians, type Vec2 } 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 SecurityDroidPose = "stand" | "patrol" | "guard"
export type SecurityDroidBehavior = "patrol" | "alert" | "idle" | "static"
export interface SecurityDroidProps
extends Omit<React.ComponentProps<"svg">, "color">,
RobotPaletteProps {
size?: RobotSize | number
variant?: RobotVariant
/** Where the camera stands. One droid, four projections. */
view?: RobotView
/** Stance. Omit and `behavior` picks one. */
pose?: SecurityDroidPose
/** Controlled head rotation in degrees. Omit to run `behavior`. */
headAngle?: number
/** What the droid does when it is not posed. */
behavior?: SecurityDroidBehavior
/** Cycles per second: one sweep of the head. */
speed?: number
animate?: boolean
paused?: boolean
phase?: number
look?: Vec2 | null
track?: boolean
/** Stands it to guard and shortens the scan, whatever `behavior` says. */
alert?: boolean
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 = 207
const BODY_DEEP = 12
const LIMB_DEEP = 7
const viewNames: Record<RobotView, string> = {
plan: "plan view",
front: "front elevation",
profile: "side elevation",
iso: "isometric view",
}
const poses: Record<SecurityDroidPose, { lean: number; leftLeg: number; rightLeg: number; arms: number }> = {
stand: { lean: 0, leftLeg: -2, rightLeg: 2, arms: 3 },
patrol: { lean: -4, leftLeg: -10, rightLeg: 11, arms: 14 },
guard: { lean: 2, leftLeg: 5, rightLeg: -5, arms: -18 },
}
function SecurityDroid({
size = "md",
variant = "solid",
view = NATIVE_VIEW,
pose,
headAngle,
behavior = "patrol",
speed = 0.35,
animate = true,
paused = false,
phase = 0,
look = null,
track = true,
alert = false,
signal = "idle",
showGround = true,
label,
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
className,
style,
...props
}: SecurityDroidProps) {
const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })
const width = resolveRobotSize(size)
const clock = useRobotClock({
speed,
animate: animate && behavior !== "static",
paused,
phase,
})
const scripted = securityDroidPose(alert ? "alert" : behavior, clock)
const stanceName = pose ?? scripted.pose
const stance = poses[stanceName] ?? poses.stand
const turn = finiteClamp(headAngle ?? scripted.head, -70, 70)
const svgRef = React.useRef<SVGSVGElement>(null)
const pointer = usePointerTarget(svgRef, {
enabled: track && !look,
within: "window",
persist: true,
toWorld: React.useCallback((unit: Vec2) => ({
x: clamp((unit.x - 0.5) * 2, -1, 1),
y: clamp((unit.y - 0.5) * 2, -1, 1),
}), []),
})
const gaze = look ?? pointer.target ?? { x: 0, y: 0 }
const sensorX = clamp(gaze.x, -1, 1) * 8
const shell = robotSurface("shell", variant, palette)
const machined = robotSurface("metal", variant, palette)
const cast = robotSurface("dark", variant, palette)
const signalColor = alert || 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 frame = 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={`Security droid, ${stanceName} pose${alert ? ", alert" : ""}, ${viewNames[view] ?? viewNames.front}`}
viewBox="0 0 180 240"
width={width}
height={px(width * 1.33)}
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 14 217 H 166 M 90 8 V 223" strokeDasharray="2 3" />
<path d="M 29 20 H 151 V 214 H 29 Z" strokeDasharray="2 3" />
</g>
)}
{showGround && <ellipse cx={90} cy={216} rx={48} 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 * 18, -69, side * LIMB_DEEP, [
{ angle: side < 0 ? stance.leftLeg : stance.rightLeg, length: 54 },
{ angle: side < 0 ? stance.leftLeg : stance.rightLeg, length: 56 },
])
return (
<g key={side} data-leg={side < 0 ? "left" : "right"}>
<path d={capsulePath(hip, knee, 7)} {...shell} />
<path d={capsulePath(knee, ankle, 6)} {...machined} />
</g>
)
})}
{([-1, 1] as const).map(side => {
const [shoulder, elbow, wrist] = chain(side * 36, -135, side * BODY_DEEP, [
{ angle: side * stance.arms, length: 53 },
{ angle: side * stance.arms, length: 40 },
])
return (
<g key={side} data-arm={side < 0 ? "left" : "right"}>
<path d={capsulePath(shoulder, elbow, 7)} {...shell} />
<path d={capsulePath(elbow, wrist, 6)} {...machined} />
</g>
)
})}
<path d={solid(32, BODY_DEEP, 151, 69)} {...cast} />
<path d={solid(24, BODY_DEEP - 2, 132, 79)} {...shell} />
<path d={solid(9, 8, 178, 148)} {...machined} />
<path d={solid(19, 15, 200, 160, turn * 0.12)} {...shell} />
</g>}
<Frame {...frame}>
<g data-frame data-view={view} transform={`translate(90 207) rotate(${stance.lean})`}>
{[-1, 1].map((side) => (
<g key={side} data-leg={side < 0 ? "left" : "right"} transform={`translate(${side * 18} -69) rotate(${side < 0 ? stance.leftLeg : stance.rightLeg})`}>
<rect x={-7} y={0} width={14} height={53} rx={5} {...cast} />
<circle cy={54} r={7.5} {...machined} />
<rect x={-6} y={54} width={12} height={56} rx={5} {...shell} />
<path d="M -8 108 H 15 Q 23 108 23 115 H -9 Z" {...cast} />
</g>
))}
<path d="M -34 -137 L -27 -69 Q 0 -56 27 -69 L 34 -137 L 20 -151 H -20 Z" {...cast} />
<path d="M -24 -132 L -18 -79 Q 0 -70 18 -79 L 24 -132 Z" {...shell} />
<path d="M -16 -121 H 16 V -91 H -16 Z" {...machined} />
<path d="M -10 -115 H 10 M -10 -106 H 10 M -10 -97 H 10" stroke={palette.dark} strokeWidth={2} />
<circle cx={0} cy={-81} r={7} fill={signalColor} />
{[-1, 1].map((side) => (
<g key={side} data-arm={side < 0 ? "left" : "right"} transform={`translate(${side * 36} -135) rotate(${side * stance.arms})`}>
<circle r={9} {...machined} />
<rect x={-7} y={0} width={14} height={52} rx={5} {...cast} />
<circle cy={53} r={7} {...machined} />
<rect x={-6} y={53} width={12} height={51} rx={5} {...shell} />
<path d="M -7 104 L -9 114 M -2 104 L -3 116 M 3 104 L 4 115 M 7 104 L 10 112" stroke={palette.dark} strokeWidth={2} strokeLinecap="round" />
</g>
))}
<rect x={-8} y={-160} width={16} height={18} rx={4} {...machined} />
<g data-head transform={`translate(${px(turn * 0.12)} -178) rotate(${px(turn * 0.07)})`}>
<path d="M -25 -15 H 25 L 20 17 H -20 Z" {...shell} />
<rect x={-19} y={-7} width={38} height={13} rx={4} {...cast} />
<rect data-sensor x={px(-6 + sensorX)} y={-4} width={12} height={7} rx={3.5} fill={signalColor} />
<path d="M -14 12 H 14" stroke={palette.metal} strokeWidth={2} />
{alert && <circle data-alert cx={20} cy={-12} r={4} fill={palette.accent} className="robocn-pulse" />}
</g>
</g>
</Frame>
{label && <text x={90} y={234} textAnchor="middle" fontFamily="ui-monospace, monospace" fontSize={6} fill={palette.foreground}>{label}</text>}
</svg>
)
}
/**
* The beat it walks when nobody is posing it. A patrol paces and sweeps wide;
* an alert stands to guard and scans in short, quick turns; idle stands easy
* and barely moves.
*/
export function securityDroidPose(behavior: SecurityDroidBehavior, clock: number) {
const t = Number.isFinite(clock) ? clock : 0
switch (behavior) {
case "alert":
return { pose: "guard" as SecurityDroidPose, head: Math.sin(t * Math.PI * 4) * 58 }
case "idle":
return { pose: "stand" as SecurityDroidPose, head: Math.sin(t * Math.PI * 0.5) * 10 }
case "static":
return { pose: "stand" as SecurityDroidPose, head: 0 }
default:
return { pose: "patrol" as SecurityDroidPose, head: Math.sin(t * Math.PI * 2) * 44 }
}
}
const finiteClamp = (value: number, min: number, max: number) =>
Number.isFinite(value) ? clamp(value, min, max) : 0
export { SecurityDroid }