Orb droid
A spherical rolling companion with a separately stabilized head, segmented drive shell, tracking optic, and antenna options.
Open in workbenchview
variant
antenna
drive
The drive sphere turns and the cap stays upright. Move the pointer to aim the optic.
Rolling is the mechanism: one clock drives the shell continuously and the head only as far as a real gimbal would let it sway.
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/orb-droid.jsonNotes
- Uncontrolled it rolls: the shell turns continuously and the head holds level, which is the mechanism a ball robot is built around. Supply bodyAngle or headAngle and that one channel is yours; supply both and the clock stops.
- A ball is the same circle from every angle, so the body needs no second drawing; the panelling on it is elevation artwork that foreshortens with the camera. The head is a dome, which only reads as one off the front.
- Body and head transforms are independent, so a rolling shell does not drag the stabilized cap around with it.
- Pointer tracking is isolated to this client component and can be disabled or overridden.
- The shell, head and optic carry data-body, data-head and data-optic hooks, so an outer animation loop can drive all three through the DOM without re-rendering the component.
Usage
import { OrbDroid } from "@/components/ui/orb-droid"
<OrbDroid behavior="roll" antenna="twin" />
<OrbDroid bodyAngle={72} headAngle={-18} look={{ x: 0.5, y: -0.2 }} />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 | "roll" | "rock" | "survey" | "static" | "roll" | What it does when neither angle is supplied: roll turns the shell and holds the head level, rock stays put, survey sweeps the optic. |
| speed | number | 0.3 | Cycles per second: one revolution, one rock, one sweep. |
| bodyAngle | number | — | Controlled rotation of the segmented drive sphere in degrees. Omit to run behavior. |
| headAngle | number | — | Head steering in degrees, clamped to −65..65. Omit to run behavior. |
| look | Vec2 | null | null | Controlled optic aim in −1..1; overrides pointer tracking. |
| track | boolean | true | Follow the page pointer while look is null. |
| antenna | "single" | "twin" | "none" | "twin" | Communications mast configuration. |
| 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/orb-droid.tsx
"use client"
/**
* orb-droid — a ball with the drive inside it.
*
* The shell turns and the head does not: a stabilised platform on a rolling
* body is the whole mechanism, so the two are separate channels driven from
* one clock. Supply `bodyAngle` or `headAngle` and that channel is yours;
* leave both and it runs `behavior`.
*/
import * as React from "react"
import { usePointerTarget } from "@/hooks/use-pointer-target"
import { useRobotClock } from "@/hooks/use-robot-motion"
import { clamp, type Vec2 } from "@/lib/robocn/kinematics"
import {
aboutPoint,
capsulePath,
circleFootprint,
extrudedPath,
px,
resolveRobotPalette,
resolveRobotSize,
robotCamera,
robotSurface,
type RobotPaletteProps,
type RobotSize,
type RobotVariant,
type RobotView,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"
/** The droid is drawn straight on; that is the camera it defaults to. */
const NATIVE_VIEW: RobotView = "front"
/** Where it stands in the frame, and the heights the elevation implied. */
const CENTRE = 95
const GROUND = 164
const BODY_RADIUS = 53
const HEAD_TOP = 126
const HEAD_FLOOR = 84
const HEAD_RADIUS = 36
/** How far the camera pulls back so the machine still fits a frame that was
* drawn for one view. One in the view it was drawn in. */
const fits: Record<RobotView, number> = { plan: 0.82, front: 1, profile: 0.95, iso: 0.93 }
const viewNames: Record<RobotView, string> = {
plan: "plan view",
front: "front elevation",
profile: "side elevation",
iso: "isometric view",
}
export type OrbDroidBehavior = "roll" | "rock" | "survey" | "static"
export interface OrbDroidProps
extends Omit<React.ComponentProps<"svg">, "color">,
RobotPaletteProps {
size?: RobotSize | number
variant?: RobotVariant
/** Where the camera stands. One droid, four projections. */
view?: RobotView
/** Controlled head rotation in degrees. Omit to run `behavior`. */
headAngle?: number
/** Controlled shell rotation in degrees. Omit to run `behavior`. */
bodyAngle?: number
/** What the droid does when neither angle is supplied. */
behavior?: OrbDroidBehavior
/** Cycles per second: one revolution, one rock, one sweep. */
speed?: number
animate?: boolean
paused?: boolean
phase?: number
look?: Vec2 | null
track?: boolean
antenna?: "single" | "twin" | "none"
signal?: "idle" | "ready" | "warning"
showGround?: boolean
label?: string
}
function OrbDroid({
size = "md",
variant = "solid",
view = NATIVE_VIEW,
headAngle,
bodyAngle,
behavior = "roll",
speed = 0.3,
animate = true,
paused = false,
phase = 0,
look = null,
track = true,
antenna = "twin",
signal = "ready",
showGround = true,
label,
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
className,
style,
...props
}: OrbDroidProps) {
const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })
const width = resolveRobotSize(size)
// Both channels pinned means nothing reads the clock, so do not run one.
const controlled = headAngle !== undefined && bodyAngle !== undefined
const clock = useRobotClock({
speed,
animate: animate && !controlled && behavior !== "static",
paused,
phase,
})
const scripted = orbDroidPose(behavior, clock)
const headTurn = finiteClamp(headAngle ?? scripted.head, -65, 65)
const bodyTurn = finite(bodyAngle ?? scripted.body)
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 eye = { x: clamp(gaze.x, -1, 1) * 4, y: clamp(gaze.y, -1, 1) * 2.5 }
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
const clipId = `orb-${React.useId().replace(/:/g, "")}`
// A ball is the same circle from every angle, so the body needs no second
// drawing; everything painted on it is elevation artwork and goes through
// `wall`. The head is a dome, which only reads as one off the front.
const camera = robotCamera(view)
const offAxis = view !== NATIVE_VIEW
const fit = fits[view] ?? 1
const face = aboutPoint(camera.wall(), CENTRE, GROUND, fit)
const Frame = (face ? "g" : React.Fragment) as React.FC<{
transform?: string
children?: React.ReactNode
}>
const frame = face ? { transform: face } : {}
const rise = (y: number) => GROUND - y
const at = (x: number, y: number, deep = 0) =>
camera.project(-x, rise(y), -deep)
return (
<svg
ref={svgRef}
role="img"
aria-label={`Orb droid, body rotation ${Math.round(bodyTurn)} degrees, ${viewNames[view] ?? viewNames.front}`}
viewBox="0 0 190 190"
width={width}
height={width}
className={cn("max-w-full select-none", className)}
style={{ color: palette.foreground, ...style }}
{...props}
>
<defs><clipPath id={clipId}><circle cx={95} cy={111} r={53} /></clipPath></defs>
{variant === "blueprint" && (
<g fill="none" stroke={palette.grid} strokeWidth={0.5} opacity={0.45}>
<path d="M 16 164 H 174 M 95 18 V 174" strokeDasharray="2 3" />
<circle cx={95} cy={111} r={68} strokeDasharray="2 3" />
</g>
)}
{showGround && <ellipse cx={95} cy={166} rx={57} ry={7} fill={palette.dark} opacity={0.14} />}
{offAxis && <g data-solids transform={`translate(${CENTRE} ${GROUND}) scale(${fit})`}>
<circle cx={px(at(0, 111).x)} cy={px(at(0, 111).y)} r={BODY_RADIUS} {...shell} />
<path
d={extrudedPath(circleFootprint(0, 0, HEAD_RADIUS, 14), camera, HEAD_TOP, HEAD_FLOOR)}
{...machined}
/>
{antenna !== "none" && (
<path d={capsulePath(at(22, 48), at(27, 24), 1.5)} fill={palette.dark} stroke="none" />
)}
</g>}
<Frame {...frame}>
<g data-body data-view={view} transform={`rotate(${px(bodyTurn)} 95 111)`}>
<circle cx={95} cy={111} r={53} {...shell} />
<g clipPath={`url(#${clipId})`} fill="none" stroke={palette.dark} strokeWidth={1.4} opacity={0.75}>
<path d="M 42 111 H 148 M 95 58 V 164" />
<ellipse cx={95} cy={111} rx={22} ry={53} />
<ellipse cx={95} cy={111} rx={53} ry={22} />
<path d="M 57 74 L 133 148 M 133 74 L 57 148" />
</g>
{[0, 90, 180, 270].map((angle) => (
<g key={angle} transform={`rotate(${angle} 95 111) translate(95 72)`}>
<rect x={-8} y={-5} width={16} height={10} rx={2.5} {...machined} />
<circle r={2.6} fill={angle === 0 ? signalColor : palette.dark} />
</g>
))}
</g>
<g data-head transform={`translate(${px(headTurn * 0.13)} 0) rotate(${px(headTurn * 0.16)} 95 67)`}>
<path d="M 59 75 Q 60 43 95 38 Q 130 43 131 75 Q 95 88 59 75 Z" {...machined} />
<path d="M 61 72 Q 95 82 129 72" fill="none" stroke={palette.dark} strokeWidth={2} />
<rect x={75} y={51} width={40} height={19} rx={7} {...cast} />
{/* One node over the whole optic, so a caller driving gaze from an
animation loop writes one transform instead of six attributes. */}
<g data-optic>
<circle cx={px(95 + eye.x)} cy={px(60 + eye.y)} r={7.5} fill={palette.accent} />
<circle data-eye cx={px(95 + eye.x)} cy={px(60 + eye.y)} r={3.5} fill={palette.dark} />
<circle cx={px(97 + eye.x)} cy={px(58 + eye.y)} r={1.4} fill={palette.metal} />
</g>
<circle cx={68} cy={66} r={4} fill={signalColor} className={signal === "ready" ? "robocn-pulse" : undefined} />
{antenna !== "none" && (
<g data-antenna={antenna} stroke={palette.dark} strokeLinecap="round">
<path d="M 117 48 L 122 24" strokeWidth={1.8} />
<circle cx={122} cy={22} r={2.6} fill={signalColor} strokeWidth={1} />
{antenna === "twin" && <path d="M 108 43 L 106 30" strokeWidth={1.4} />}
</g>
)}
</g>
</Frame>
{label && <text x={95} y={184} textAnchor="middle" fontFamily="ui-monospace, monospace" fontSize={6} fill={palette.foreground}>{label}</text>}
</svg>
)
}
/**
* What the shell and the head are doing at `clock`, in degrees. Rolling turns
* the shell continuously while the head holds level but for the sway a real
* gimbal would not quite take out; rocking is the same machine stationary; a
* survey barely moves the shell and sweeps the optic across the room.
*/
export function orbDroidPose(behavior: OrbDroidBehavior, clock: number) {
const t = Number.isFinite(clock) ? clock : 0
const turn = Math.PI * 2 * t
switch (behavior) {
case "rock":
return { body: Math.sin(turn) * 30, head: Math.sin(turn + 0.7) * -11 }
case "survey":
// Triangle rather than sine: a scan holds its rate across the sweep and
// only slows at the two ends, which is what a search pattern looks like.
return { body: Math.sin(turn * 0.5) * 7, head: (Math.abs(((t * 0.5) % 1) * 4 - 2) - 1) * 58 }
case "static":
return { body: 0, head: 0 }
default:
return { body: t * 360, head: Math.sin(turn) * 7 }
}
}
const finite = (value: number) => Number.isFinite(value) ? value : 0
const finiteClamp = (value: number, min: number, max: number) => clamp(finite(value), min, max)
export { OrbDroid }