Attendant droid
A plated humanoid attendant with a fixed faceplate and vocoder grille, four etiquette poses, and plating levels that strip the body back to its exposed loom.
Open in workbenchview
variant
plating
build
face
hands
neck
drive
The head turns to the pointer, and a click moves it on to its next courtesy.
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/attendant-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.
- Heavier and fully plated where the protocol droid is slim and jointed; the two share the collection prop contract.
- An original attendant archetype rather than a character replica.
Usage
import { AttendantDroid } from "@/components/ui/attendant-droid"
<AttendantDroid pose="present" plating="partial" headAngle={18} speaking />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. |
| pose | "attention" | "bow" | "present" | "alarm" | — | Etiquette posture. Omit and the behaviour works through them. |
| headAngle | number | — | Head rotation in degrees, clamped to −45..45. Omit and it turns to whoever it is addressing. |
| plating | "full" | "partial" | "bare" | "full" | Body covering. Partial keeps the chest plate; bare exposes the torso and limb looms. |
| build | "slim" | "standard" | "heavy" | "standard" | Frame width. Scales limbs and torso across only — every joint keeps its height, so poses are unchanged. |
| face | "grille" | "visor" | "lamps" | "grille" | Faceplate treatment: paired optics, a full visor band, or oversized lamps. |
| hands | "fingers" | "clamp" | "mitt" | "fingers" | End effector on each arm. |
| collar | boolean | true | Draw the raised collar around the neck. |
| speaking | boolean | — | Light the vocoder grille. Omit and it lights while the droid is talking. |
| behavior | "attend" | "fret" | "idle" | "static" | "attend" | Attend works the room a courtesy at a time; fret alternates alarm and apology with the head whipping about; idle waits politely. |
| speed | number | 0.3 | Poses per second. |
| 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. |
| interactive | boolean | true | The head turns to the pointer, and a click takes it on to its next pose. `onPoseChange` reports either way. |
| 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/attendant-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 AttendantDroidPose = "attention" | "bow" | "present" | "alarm"
export type AttendantDroidPlating = "full" | "partial" | "bare"
export type AttendantDroidBuild = "slim" | "standard" | "heavy"
export type AttendantDroidFace = "grille" | "visor" | "lamps"
export type AttendantDroidHands = "fingers" | "clamp" | "mitt"
export type AttendantDroidSignal = "idle" | "ready" | "warning"
export type AttendantDroidBehavior = "attend" | "fret" | "idle" | "static"
/** The poses an unattended attendant works through, in order. */
const poseOrder: AttendantDroidPose[] = ["attention", "present", "bow", "alarm"]
export interface AttendantDroidProps
extends Omit<React.ComponentProps<"svg">, "color">,
RobotPaletteProps {
size?: RobotSize | number
variant?: RobotVariant
/** Etiquette posture. Omit and `behavior` works through them. */
/** Where the camera stands. One droid, four projections. */
view?: RobotView
pose?: AttendantDroidPose
/** Head rotation in degrees, clamped to −45..45. Omit and it turns to whoever it is addressing. */
headAngle?: number
/** What it does unattended: work the room, wring its hands, or wait politely. */
behavior?: AttendantDroidBehavior
/** Poses per second. */
speed?: number
animate?: boolean
paused?: boolean
phase?: number
/** The head turns to the pointer, and a click moves it on to its next pose. */
interactive?: boolean
onPoseChange?: (pose: AttendantDroidPose) => void
/** How much of the body covering is fitted. `bare` strips the limb plates back to the loom. */
plating?: AttendantDroidPlating
/** How heavily the frame is built. Scales limb and torso width, never the height. */
build?: AttendantDroidBuild
/** Faceplate treatment. */
face?: AttendantDroidFace
/** End effector on each arm. */
hands?: AttendantDroidHands
/** Draw the raised collar around the neck. */
collar?: boolean
/** Light the vocoder grille. Omit and it lights while the droid is talking. */
speaking?: boolean
signal?: AttendantDroidSignal
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 = 85
const GROUND = 200
const BODY_DEEP = 9
const LIMB_DEEP = 5
const viewNames: Record<RobotView, string> = {
plan: "plan view",
front: "front elevation",
profile: "side elevation",
iso: "isometric view",
}
const poses: Record<
AttendantDroidPose,
{ lean: number; leftArm: number; rightArm: number; leftFore: number; rightFore: number; knee: number }
> = {
attention: { lean: 0, leftArm: 5, rightArm: -5, leftFore: 0, rightFore: 0, knee: 0 },
bow: { lean: 17, leftArm: 20, rightArm: -14, leftFore: -22, rightFore: -34, knee: 6 },
present: { lean: -2, leftArm: 12, rightArm: -58, leftFore: -8, rightFore: -66, knee: 0 },
alarm: { lean: -8, leftArm: -96, rightArm: 96, leftFore: -54, rightFore: 54, knee: -5 },
}
function AttendantDroid({
size = "md",
variant = "solid",
view = NATIVE_VIEW,
pose,
headAngle,
behavior = "attend",
speed = 0.3,
animate = true,
paused = false,
phase = 0,
interactive = true,
onPoseChange,
plating = "full",
build = "standard",
face = "grille",
hands = "fingers",
collar = true,
speaking,
signal = "ready",
showGround = true,
label,
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
className,
style,
onPointerDown,
...props
}: AttendantDroidProps) {
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 })
// A click takes the performance on a step and holds it there.
const [asked, setAsked] = React.useState<number | null>(null)
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 = attendantDroidPose(behavior, clock)
const shown = pose ?? (asked === null ? scripted.pose : poseOrder[asked % poseOrder.length])
const turn = finiteClamp(
headAngle ?? (pointer.target ? clamp(pointer.target.x, -1, 1) * 40 : scripted.head),
-45,
45,
)
const talking = speaking ?? scripted.speaking
const stance = poses[shown] ?? poses.attention
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
// Build widens the frame on one axis only, so every pose and joint still lines up.
const girth = build === "heavy" ? 1.3 : build === "slim" ? 0.78 : 1
const limbPlated = plating === "full"
const torsoPlated = plating !== "bare"
// 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 facing = aboutPoint(camera.wall(), CENTRE, GROUND)
const Frame = (facing ? "g" : React.Fragment) as React.FC<{
transform?: string
children?: React.ReactNode
}>
const faceProps = facing ? { transform: facing } : {}
/** 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={`Attendant droid, ${shown} pose, ${build} build, ${plating} plating, ${viewNames[view] ?? viewNames.front}`}
onPointerDown={(event) => {
onPointerDown?.(event)
if (!interactive || event.defaultPrevented) return
const next = (asked === null ? poseOrder.indexOf(shown) : asked) + 1
setAsked(next)
onPoseChange?.(poseOrder[next % poseOrder.length])
}}
viewBox="0 0 170 230"
width={width}
height={px(width * 1.35)}
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 85 8 V 214 M 16 208 H 154" strokeDasharray="2 3" />
<path d="M 34 22 H 136 V 206 H 34 Z" strokeDasharray="2 3" />
</g>
)}
{showGround && <ellipse cx={85} cy={202} rx={40} ry={5.5} 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 * 14, -70, side * LIMB_DEEP, [
{ angle: side * stance.knee, length: 33 },
{ angle: side * stance.knee, length: 29 },
])
return (
<g key={side} data-leg={side < 0 ? "left" : "right"}>
<path d={capsulePath(hip, knee, px(8 * girth))} {...shell} />
<path d={capsulePath(knee, ankle, px(7 * girth))} {...machined} />
</g>
)
})}
{([-1, 1] as const).map(side => {
const [shoulder, elbow, wrist] = chain(side * px(30 * girth), -117, side * BODY_DEEP, [
{ angle: side === -1 ? stance.leftArm : stance.rightArm, length: 37 },
{ angle: (side === -1 ? stance.leftArm + stance.leftFore : stance.rightArm + stance.rightFore), length: 32 },
])
return (
<g key={side} data-arm={side < 0 ? "left" : "right"}>
<path d={capsulePath(shoulder, elbow, px(6.5 * girth))} {...shell} />
<path d={capsulePath(elbow, wrist, 5.5)} {...machined} />
</g>
)
})}
<path d={solid(px(23 * girth), BODY_DEEP, 120, 70)} {...shell} />
<path d={solid(px(18 * girth), BODY_DEEP - 1, 78, 62)} {...cast} />
<path d={solid(7, 6, 134, 118)} {...machined} />
<path d={solid(16, 13, 156, 128, turn * 0.14)} {...shell} />
</g>}
<Frame {...faceProps}>
<g data-frame data-build={build} data-view={view} transform={`translate(85 200) rotate(${px(stance.lean)})`}>
{[-1, 1].map((side) => (
<g data-leg={side === -1 ? "left" : "right"} key={side} transform={`translate(${side * 14} -70) rotate(${px(side * stance.knee)})`}>
<rect x={px(-8 * girth)} y={0} width={px(16 * girth)} height={32} rx={6} {...(limbPlated ? shell : cast)} />
<circle data-joint cy={33} r={px(7 * girth)} {...cast} />
<rect x={px(-7 * girth)} y={33} width={px(14 * girth)} height={29} rx={5} {...(limbPlated ? machined : cast)} />
{!limbPlated && (
<g data-loom="leg" fill="none" stroke={palette.metal} strokeWidth={1.2} opacity={0.8}>
<path d="M -3 5 V 27 M 3 5 V 27 M -3 37 V 58 M 3 37 V 58" />
</g>
)}
<path d="M -9 62 H 13 Q 21 62 21 70 H -10 Z" {...shell} />
</g>
))}
<g data-plating={plating}>
<path
d={`M ${px(-28 * girth)} -124 L ${px(-23 * girth)} -82 Q 0 -72 ${px(23 * girth)} -82 L ${px(28 * girth)} -124 Z`}
{...shell}
/>
<path
d={`M ${px(-21 * girth)} -80 Q 0 -70 ${px(21 * girth)} -80 L ${px(19 * girth)} -68 Q 0 -60 ${px(-19 * girth)} -68 Z`}
{...cast}
/>
{torsoPlated ? (
<g data-chest-plate>
<path d="M -17 -116 H 17 V -90 Q 0 -82 -17 -90 Z" {...machined} />
<g stroke={palette.dark} strokeWidth={1.2} fill="none" opacity={0.7}>
<path d="M -11 -110 H 11 M -11 -103 H 11 M -11 -96 H 11" />
</g>
</g>
) : (
<g data-loom="torso" fill="none" strokeLinecap="round">
<path d="M -11 -114 C -3 -104 -9 -94 -1 -84" stroke={palette.accent} strokeWidth={2.2} />
<path d="M 0 -116 C 8 -105 2 -95 10 -85" stroke={palette.metal} strokeWidth={2} />
<path d="M 10 -114 C 2 -104 10 -94 4 -84" stroke={palette.dark} strokeWidth={2} />
</g>
)}
<circle data-joint cy={-68} r={7.5} {...cast} />
</g>
{[-1, 1].map((side) => {
const shoulder = side === -1 ? stance.leftArm : stance.rightArm
const forearm = side === -1 ? stance.leftFore : stance.rightFore
return (
<g data-arm={side === -1 ? "left" : "right"} key={side} transform={`translate(${px(side * 30 * girth)} -117) rotate(${px(shoulder)})`}>
<circle data-joint r={px(8.5 * girth)} {...cast} />
<rect x={px(-6.5 * girth)} y={0} width={px(13 * girth)} height={36} rx={5} {...(limbPlated ? shell : cast)} />
<g transform={`translate(0 37) rotate(${px(forearm)})`}>
<circle data-joint r={6.5} {...cast} />
<rect x={px(-5.5 * girth)} y={0} width={px(11 * girth)} height={35} rx={4} {...(limbPlated ? machined : cast)} />
{!limbPlated && (
<path d="M -2.5 4 V 30 M 2.5 4 V 30" fill="none" stroke={palette.metal} strokeWidth={1.1} opacity={0.8} />
)}
<circle cy={37} r={4.6} {...cast} />
<g data-hand={hands}>
{hands === "fingers" && (
<path d="M -5 41 Q 0 49 5 41 M 0 41 V 50" fill="none" stroke={palette.metal} strokeWidth={2} strokeLinecap="round" />
)}
{hands === "clamp" && (
<g {...machined}>
<path d="M -5 40 L -8 51 L -3 50 L -1 41 Z" />
<path d="M 5 40 L 8 51 L 3 50 L 1 41 Z" />
</g>
)}
{hands === "mitt" && <path d="M -6 40 H 6 Q 8 40 8 46 Q 0 53 -8 46 Q -8 40 -6 40 Z" {...machined} />}
</g>
</g>
</g>
)
})}
<g data-head transform={`translate(${px(turn * 0.14)} -134) rotate(${px(turn * 0.1)})`}>
<rect x={-8} y={0} width={16} height={12} rx={4} {...cast} />
{collar && <path data-collar d="M -16 4 Q 0 12 16 4 L 18 12 Q 0 20 -18 12 Z" {...machined} />}
<path d="M -19 -20 Q -19 -30 0 -30 Q 19 -30 19 -20 V 2 Q 0 10 -19 2 Z" {...shell} />
<path d="M -15 -20 H 15 V -2 Q 0 4 -15 -2 Z" {...cast} />
<g data-face={face}>
{face === "visor" ? (
<rect x={-13} y={-17} width={26} height={8} rx={3} fill={signalColor} />
) : (
[-7.5, 7.5].map((x) => (
<g key={x}>
<circle cx={x} cy={-13} r={face === "lamps" ? 6 : 4.6} fill={signalColor} />
<circle cx={px(x + 1.2)} cy={-14.2} r={1.3} fill={palette.metal} />
</g>
))
)}
</g>
<rect
data-vocoder
x={-6}
y={-7}
width={12}
height={6}
rx={1.5}
fill={talking ? palette.glow : palette.metal}
stroke={palette.dark}
strokeWidth={0.6}
opacity={talking ? 1 : 0.75}
/>
<path d="M -4 -6 V -2 M 0 -6 V -2 M 4 -6 V -2" stroke={palette.dark} strokeWidth={0.8} fill="none" />
<path d="M -19 -24 H 19" stroke={palette.dark} strokeWidth={1.1} fill="none" opacity={0.6} />
</g>
</g>
</Frame>
{label && (
<text x={85} 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
/** What each behavior is doing at `clock`. Pure, so tests can read it. */
export function attendantDroidPose(behavior: AttendantDroidBehavior, clock: number) {
const t = Number.isFinite(clock) ? clock : 0
const cycle = ((t % 1) + 1) % 1
switch (behavior) {
case "fret":
return { pose: cycle < 0.5 ? "alarm" : "bow", head: Math.sin(t * Math.PI * 6) * 34, speaking: true } as const
case "idle":
return { pose: "attention", head: Math.sin(t * Math.PI * 0.6) * 12, speaking: false } as const
case "static":
return { pose: "attention", head: 0, speaking: false } as const
default: {
// Work the room: hold each courtesy for a beat, and talk through it.
const step = Math.floor(cycle * poseOrder.length) % poseOrder.length
return {
pose: poseOrder[step],
head: Math.sin(t * Math.PI * 1.2) * 26,
speaking: cycle % 0.25 < 0.16,
}
}
}
}
export { AttendantDroid }