Robot loader
A pick-and-place cycle as a loading indicator. Indeterminate on its own; give it a value and the out-tray fills in proportion.
mode
Install
pnpm dlx shadcn@latest add https://robocn.dev/r/robot-loader.jsonNotes
- Sets `aria-busy` and an `aria-label` that carries the percentage, so it announces as a progress indicator.
Usage
<RobotLoader label="Deploying" />
<RobotLoader value={62} capacity={5} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| cycle | number | 2.6 | Seconds per pick-and-place cycle. |
| value | number | — | 0–100 fills the out-tray in proportion. Omit for indeterminate. |
| capacity | number | 4 | Parts in a full tray. |
| label | string | — | Caption under the cell. |
| 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. |
| 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-loader.tsx
"use client"
/**
* robot-loader — a pick-and-place cycle as a loading indicator.
*
* An arm moves parts from the in-tray to the out-tray on a fixed cycle. Left
* indeterminate it just keeps working; give it `value` and the trays fill up
* in proportion, so the same component covers "busy" and "62% done".
*/
import * as React from "react"
import { useEasedPoint } from "@/hooks/use-robot-arm"
import { clamp, lerp, solveChain2, type Vec2 } from "@/lib/robocn/kinematics"
import {
capsulePath,
px,
resolveRobotPalette,
resolveRobotSize,
robotSurface,
type RobotPaletteProps,
type RobotSize,
type RobotVariant,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"
const VIEW_WIDTH = 132
const VIEW_HEIGHT = 76
const FLOOR = 12
const PICK: Vec2 = { x: -34, y: 12 }
const PLACE: Vec2 = { x: 34, y: 12 }
const LIFT = 34
const LINKS = [26, 22]
export interface RobotLoaderProps
extends Omit<React.ComponentProps<"svg">, "color">,
RobotPaletteProps {
/** Seconds per pick-and-place cycle. */
cycle?: number
/** 0..100 fills the out-tray in proportion; omit for an indeterminate loader. */
value?: number
/** Parts in a full tray. */
capacity?: number
variant?: RobotVariant
size?: RobotSize | number
paused?: boolean
/** Caption under the cell. */
label?: string
}
function RobotLoader({
cycle = 2.6,
value,
capacity = 4,
variant = "solid",
size = "md",
paused = false,
label,
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
className,
style,
...props
}: RobotLoaderProps) {
const palette = resolveRobotPalette({
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
})
const width = resolveRobotSize(size)
const height = (width * VIEW_HEIGHT) / VIEW_WIDTH
// The path is already eased keyframe to keyframe, so the point loop only has
// to follow it; a high feed rate keeps it on the scripted timing.
const path = React.useCallback(
(clock: number) => cyclePoint((clock % cycle) / cycle),
[cycle],
)
const eased = useEasedPoint(path, cyclePoint(0), {
speed: 600,
paused,
animate: true,
})
const phase = (eased.clock % cycle) / cycle
const carrying = phase > 0.28 && phase < 0.72
const grip = carrying || (phase > 0.22 && phase < 0.78) ? 0.15 : 0.75
const joints = solveChain2({ x: 0, y: 16 }, eased.point, LINKS, { bend: "up" })
const done = value === undefined ? null : clamp(value, 0, 100) / 100
const placed =
done === null
? Math.min(capacity, Math.floor(eased.clock / cycle) % (capacity + 1))
: Math.round(done * capacity)
const shell = robotSurface("shell", variant, palette, 1)
const metalSurface = robotSurface("metal", variant, palette, 1)
const darkSurface = robotSurface("dark", variant, palette, 1)
return (
<svg
role="img"
aria-label={
done === null
? "Robot arm moving parts, working"
: `Robot arm moving parts, ${Math.round(done * 100)} percent complete`
}
aria-busy={done === null || done < 1}
viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
width={width}
height={height}
className={cn("select-none overflow-hidden", className)}
style={{ color: palette.foreground, ...style }}
{...props}
>
<g transform={`translate(${VIEW_WIDTH / 2} ${VIEW_HEIGHT - FLOOR}) scale(1 -1)`}>
{[PICK, PLACE].map((tray, index) => (
<g key={index}>
<rect
x={px(tray.x - 14)}
y={0}
width={28}
height={5}
rx={1.4}
{...darkSurface}
/>
{Array.from({
length: index === 0 ? capacity - placed : placed,
}).map((_, slot) => (
<rect
key={slot}
x={px(tray.x - 4.5)}
y={px(5.5 + slot * 5.4)}
width={9}
height={4.6}
rx={1}
fill={palette.accent}
opacity={0.9}
/>
))}
</g>
))}
<rect x={-11} y={-1} width={22} height={5} rx={1.6} {...darkSurface} />
<path d="M -7 4 L 7 4 L 5 16 L -5 16 Z" {...shell} />
<path d={capsulePath(joints[0], joints[1], 3)} {...shell} />
<path d={capsulePath(joints[1], joints[2], 2.2)} {...metalSurface} />
<circle cx={px(joints[1].x)} cy={px(joints[1].y)} r={3.2} fill={palette.dark} />
<circle cx={0} cy={16} r={4} fill={palette.dark} />
<g transform={`translate(${px(joints[2].x)} ${px(joints[2].y)})`}>
{[-1, 1].map((side) => (
<rect
key={side}
x={px(side * (1.4 + grip * 2.6) - 0.7)}
y={-5}
width={1.6}
height={5}
rx={0.6}
{...metalSurface}
/>
))}
{carrying ? (
<rect x={-4.5} y={-5.4} width={9} height={4.6} rx={1} fill={palette.accent} />
) : null}
</g>
{label ? (
<g transform={`translate(0 -8) scale(1 -1)`}>
<text
textAnchor="middle"
fontSize={4.2}
fontFamily="ui-monospace, monospace"
letterSpacing="0.4"
fill={palette.grid}
>
{label}
</text>
</g>
) : null}
</g>
</svg>
)
}
/** Smoothstep, so each leg of the cycle starts and stops like a servo. */
const ease = (t: number) => t * t * (3 - 2 * t)
/** Where the gripper is at `t`, one full pick-and-place cycle over 0..1. */
function cyclePoint(t: number): Vec2 {
const leg = (from: Vec2, to: Vec2, start: number, end: number): Vec2 => {
const k = ease(clamp((t - start) / (end - start), 0, 1))
return { x: lerp(from.x, to.x, k), y: lerp(from.y, to.y, k) }
}
const overPick = { x: PICK.x, y: LIFT }
const overPlace = { x: PLACE.x, y: LIFT }
if (t < 0.18) return leg({ x: 0, y: LIFT }, overPick, 0, 0.18)
if (t < 0.28) return leg(overPick, PICK, 0.18, 0.28)
if (t < 0.38) return leg(PICK, overPick, 0.28, 0.38)
if (t < 0.6) return leg(overPick, overPlace, 0.38, 0.6)
if (t < 0.7) return leg(overPlace, PLACE, 0.6, 0.7)
if (t < 0.8) return leg(PLACE, overPlace, 0.7, 0.8)
return leg(overPlace, { x: 0, y: LIFT }, 0.8, 1)
}
export { RobotLoader }