Belt drive
A toothed belt between two pulleys with an idler you can wind down to take up slack. The belt is routed along its real tangents, and its teeth march by arc length.
Open in workbenchview
variant
drive
idler
drive18T
driven30T
tension45%
Drag the belt along its run. Wind the idler down and the belt gets longer.
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/belt-drive.jsonNotes
- Not the conveyor: nothing rides on this belt, it transmits. The driven pulley turns at the ratio the tooth counts give, and the teeth are placed by arc length along the belt, so they stay in step with both pulleys.
- The path is the taut one — external tangents between the pulleys and a real wrap arc on each. Moving the idler changes the belt length, and the readout says by how much; the component computes the path a belt would take rather than holding one length and slackening.
- Geometry only: no tension, no tooth jump, no slip.
Usage
import { BeltDrive } from "@/components/ui/belt-drive"
<BeltDrive driveTeeth={18} drivenTeeth={30} tension={0.45} />
// Controlled, or a belt you can scrub.
<BeltDrive travel={1.5} />
<BeltDrive interactive onTravelChange={setTravel} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| view | "plan" | "front" | "profile" | "iso" | "front" | Where the camera stands. One drive, four projections: straight down, straight on, side elevation, or three-quarter from above. |
| travel | number | — | Controlled belt travel, in turns of the drive pulley. Omit it and the belt runs behavior. |
| behavior | "run" | "shuttle" | "static" | "run" | Run one way, or shuttle back and forth across four fifths of a turn. |
| speed | number | 0.35 | Drive turns per second, or shuttles 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 | false | Drag the belt along its run, or arrow-key it a twentieth of a turn at a time. |
| onTravelChange | (travel: number) => void | — | Belt travel in drive turns, throughout a drag or a key press. |
| driveTeeth | number | 18 | Teeth on the driving pulley, rounded and clamped to 10–48. The pitch radius follows. |
| drivenTeeth | number | 30 | Teeth on the driven pulley, rounded and clamped to 10–48. |
| tension | number | 0.45 | How far the idler is wound down its slot, 0–1. Lengthens the belt and takes more wrap. |
| showIdler | boolean | true | Include the tensioner in the loop. Without it the belt is a plain two-pulley run. |
| showPlate | boolean | true | Draw the back plate the shafts are carried in. |
| label | string | — | Caption below the ratio and belt-length readout. |
| 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/belt-drive.tsx
"use client"
/**
* belt-drive — a toothed belt between two pulleys, with a tensioner.
*
* Not the conveyor: nothing rides on this belt, it transmits. The path is the
* taut one — external tangents and real wrap angles from `transmission.ts` —
* so moving the idler lengthens the belt and changes how much of each pulley
* it holds. Teeth march by arc length, which is what makes the driven pulley
* turn at the ratio the tooth counts actually give rather than at a number
* typed into the drawing.
*/
import * as React from "react"
import { arrowStep, useRobotDrag, useRobotScalar } from "@/hooks/use-robot-motion"
import { clamp, type Vec2 } from "@/lib/robocn/kinematics"
import {
aboutPoint,
capsulePath,
px,
resolveRobotPalette,
resolveRobotSize,
robotCamera,
robotSurface,
type RobotPaletteProps,
type RobotSize,
type RobotVariant,
type RobotView,
} from "@/lib/robocn/style"
import { beltLayout, beltSample, gearPath } from "@/lib/robocn/transmission"
import { cn } from "@/lib/utils"
const VIEW_WIDTH = 200
const VIEW_HEIGHT = 150
const CENTRE = { x: 100, y: 74 }
/** Pitch radius per tooth. The belt pitch is `2π × MODULE`. */
const MODULE = 1.15
const DRIVE = { x: 52, y: 68 }
const DRIVEN = { x: 148, y: 68 }
const IDLER = { x: 100, top: 104, travel: 16, radius: 9 }
/** Turns of the drive pulley across the full width of the drawing. */
const DRAG_TURNS = 1.6
/** Belt travel, in drive turns per second, while slewing back to the loop. */
const SLEW_RATE = 1.4
/** Drawn face on, which is the camera it defaults to. */
const NATIVE_VIEW: RobotView = "front"
const PLATE_BACK = -12
const PULLEY_FRONT = 9
const fits: Record<RobotView, number> = { plan: 0.92, front: 1, profile: 0.92, iso: 0.88 }
const viewNames: Record<RobotView, string> = {
plan: "plan view",
front: "front elevation",
profile: "side elevation",
iso: "isometric view",
}
export type BeltDriveBehavior = "run" | "shuttle" | "static"
export interface BeltDriveProps
extends Omit<React.ComponentProps<"svg">, "color">,
RobotPaletteProps {
/** Controlled belt travel, in turns of the drive pulley. Omit to run `behavior`. */
travel?: number
/** What the belt does when `travel` is not supplied. */
behavior?: BeltDriveBehavior
/** Where the camera stands. One drive, four projections. */
view?: RobotView
/** Drive turns per second, or shuttles per second. */
speed?: number
animate?: boolean
paused?: boolean
phase?: number
/** Drag the belt along its run, or arrow-key it. */
interactive?: boolean
onTravelChange?: (travel: number) => void
/** Teeth on the driving pulley, clamped to 10–48. */
driveTeeth?: number
/** Teeth on the driven pulley, clamped to 10–48. */
drivenTeeth?: number
/** How far the idler is wound down its slot, 0–1. Lengthens the belt. */
tension?: number
showIdler?: boolean
showPlate?: boolean
label?: string
size?: RobotSize | number
variant?: RobotVariant
}
function BeltDrive({
travel,
behavior = "run",
view = NATIVE_VIEW,
speed = 0.35,
animate = true,
paused = false,
phase = 0,
interactive = false,
onTravelChange,
driveTeeth = 18,
drivenTeeth = 30,
tension = 0.45,
showIdler = true,
showPlate = true,
label,
size = "md",
variant = "solid",
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
className,
style,
role,
tabIndex,
onKeyDown,
onBlur,
...props
}: BeltDriveProps) {
const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })
const width = resolveRobotSize(size)
const controlled = travel !== undefined
const svgRef = React.useRef<SVGSVGElement>(null)
const [held, setHeld] = React.useState<number | null>(null)
const hold = controlled ? (Number.isFinite(travel) ? travel : 0) : held
const goal = React.useCallback((clock: number) => beltGoal(behavior, clock), [behavior])
const motion = useRobotScalar(goal, {
rate: Math.max(SLEW_RATE, Math.abs(speed) * 6),
hold,
speed,
animate: animate && !controlled && behavior !== "static",
paused,
phase,
})
const turns = Number.isFinite(motion.value) ? motion.value : 0
const apply = React.useCallback(
(next: number) => {
setHeld(next)
onTravelChange?.(next)
},
[onTravelChange, setHeld],
)
const press = React.useRef<{ from: number; at: number } | null>(null)
const dragging = useRobotDrag(svgRef, {
enabled: interactive,
onDrag: React.useCallback(
(unit: Vec2) => {
if (!press.current) {
press.current = { from: motion.value, at: unit.x }
return
}
apply(press.current.from + (unit.x - press.current.at) * DRAG_TURNS)
},
[apply, motion.value],
),
onDragEnd: React.useCallback(() => {
press.current = null
}, []),
})
const shell = robotSurface("shell", variant, palette)
const machined = robotSurface("metal", variant, palette)
const cast = robotSurface("dark", variant, palette)
const drive = teethOf(driveTeeth)
const driven = teethOf(drivenTeeth)
const driveRadius = drive * MODULE
const drivenRadius = driven * MODULE
const wind = Number.isFinite(tension) ? clamp(tension, 0, 1) : 0
const idlerY = IDLER.top + wind * IDLER.travel
const pulleys = [
{ x: DRIVE.x, y: DRIVE.y, radius: driveRadius },
{ x: DRIVEN.x, y: DRIVEN.y, radius: drivenRadius },
...(showIdler ? [{ x: IDLER.x, y: idlerY, radius: IDLER.radius }] : []),
]
const belt = beltLayout(pulleys)
const pitch = 2 * Math.PI * MODULE
const along = turns * 2 * Math.PI * driveRadius
const teeth = belt.length > 0 ? Math.min(96, Math.floor(belt.length / pitch)) : 0
const driveAngle = turns * 360
const drivenAngle = turns * 360 * (drive / driven)
const idlerAngle = -turns * 360 * (driveRadius / IDLER.radius)
const ratio = px(driven / drive)
// The drawing is the drive's own face, so it goes through `wall` where it
// already stands. The pulleys, the plate and the shafts have a depth that
// only reads once the camera comes round.
const camera = robotCamera(view)
const offAxis = view !== NATIVE_VIEW
const fit = fits[view] ?? 1
const face = aboutPoint(camera.wall(), CENTRE.x, CENTRE.y)
const Frame = (face ? "g" : React.Fragment) as React.FC<{
transform?: string
children?: React.ReactNode
}>
const frame = face ? { transform: face } : {}
const at = (dx: number, dy: number, depth = 0) => {
const p = camera.project(CENTRE.x - dx, CENTRE.y - dy, -depth)
return { x: CENTRE.x + p.x, y: CENTRE.y + p.y }
}
const cyl = (dx: number, dy: number, radius: number, from: number, to: number) =>
capsulePath(at(dx, dy, from), at(dx, dy, to), radius)
return (
<svg
ref={svgRef}
role={role ?? (interactive ? "slider" : "img")}
aria-label={`Belt drive, ${drive} to ${driven} teeth, ${ratio} to 1 reduction, ${viewNames[view] ?? viewNames.front}`}
aria-valuemin={interactive ? 0 : undefined}
aria-valuemax={interactive ? 360 : undefined}
aria-valuenow={interactive ? px(wrap360(driveAngle)) : undefined}
aria-valuetext={
interactive
? `drive ${px(wrap360(driveAngle))} degrees, driven ${px(wrap360(drivenAngle))} degrees`
: undefined
}
tabIndex={tabIndex ?? (interactive ? 0 : undefined)}
onKeyDown={(event) => {
onKeyDown?.(event)
if (!interactive || event.defaultPrevented) return
const delta = arrowStep(event.key, event.shiftKey ? 0.25 : 0.05, 0.5)
if (delta !== 0) apply(motion.value + delta)
else if (event.key === "Home") apply(0)
else if (event.key === "Escape") setHeld(null)
else return
event.preventDefault()
}}
onBlur={(event) => {
onBlur?.(event)
if (!dragging) setHeld(null)
}}
viewBox="0 0 200 150"
width={width}
height={px((width * VIEW_HEIGHT) / VIEW_WIDTH)}
className={cn(
"max-w-full select-none",
interactive &&
"cursor-grab touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]",
dragging && "cursor-grabbing",
className,
)}
style={{ color: palette.foreground, ...style }}
data-view={view}
{...props}
>
<g
transform={
fit === 1 ? undefined : `translate(${CENTRE.x} ${CENTRE.y}) scale(${fit}) translate(${-CENTRE.x} ${-CENTRE.y})`
}
>
{offAxis && (
<g data-solids>
{showPlate && (
<path
d={capsulePath(at(DRIVE.x, DRIVE.y, PLATE_BACK), at(DRIVEN.x, DRIVEN.y, PLATE_BACK), 46)}
{...cast}
/>
)}
<path d={cyl(DRIVE.x, DRIVE.y, driveRadius, PLATE_BACK + 2, PULLEY_FRONT)} {...machined} />
<path d={cyl(DRIVEN.x, DRIVEN.y, drivenRadius, PLATE_BACK + 2, PULLEY_FRONT)} {...machined} />
{showIdler && (
<path d={cyl(IDLER.x, idlerY, IDLER.radius, PLATE_BACK + 2, PULLEY_FRONT)} {...machined} />
)}
<path d={cyl(DRIVE.x, DRIVE.y, 5, PULLEY_FRONT, PULLEY_FRONT + 18)} {...cast} />
</g>
)}
<Frame {...frame}>
<g data-drive>
{showPlate && (
<g data-plate>
<path
d={capsulePath(DRIVE, DRIVEN, 46)}
{...cast}
fillOpacity={variant === "solid" ? 0.35 : undefined}
/>
{[DRIVE, DRIVEN].map((p) => (
<circle key={p.x} cx={p.x} cy={p.y} r={5} {...machined} />
))}
</g>
)}
{showIdler && (
<g data-slot>
<rect
x={IDLER.x - 4}
y={IDLER.top - 8}
width={8}
height={IDLER.travel + 16}
rx={4}
{...cast}
/>
<path
d={`M ${IDLER.x} ${IDLER.top - 4} V ${IDLER.top + IDLER.travel + 4}`}
stroke={palette.grid}
strokeWidth={0.6}
strokeDasharray="2 2"
/>
</g>
)}
<path data-belt d={belt.d} fill="none" stroke={palette.metal} strokeWidth={8} strokeLinejoin="round" />
<path d={belt.d} fill="none" stroke={palette.dark} strokeWidth={5.6} strokeLinejoin="round" />
<g data-teeth stroke={palette.metal} strokeWidth={1.3} strokeLinecap="round">
{Array.from({ length: teeth }, (_, i) => {
const on = beltSample(belt, i * pitch + along)
const inward = ((on.heading + 90) * Math.PI) / 180
return (
<path
key={i}
d={`M ${px(on.x)} ${px(on.y)} l ${px(Math.cos(inward) * 3.4)} ${px(Math.sin(inward) * 3.4)}`}
/>
)
})}
</g>
<g data-pulley="driven" transform={`translate(${DRIVEN.x} ${DRIVEN.y})`}>
<circle r={drivenRadius + 3} {...machined} />
<g transform={`rotate(${px(drivenAngle)})`}>
<path
d={gearPath(driven, drivenRadius, { addendum: MODULE * 0.5, dedendum: MODULE * 0.5, width: 0.28 })}
{...shell}
/>
<path d={`M 0 0 L ${px(drivenRadius - 4)} 0`} stroke={palette.dark} strokeWidth={1.2} />
</g>
<circle r={drivenRadius * 0.34} {...cast} />
<circle r={4} {...machined} />
</g>
<g data-pulley="drive" transform={`translate(${DRIVE.x} ${DRIVE.y})`}>
<circle r={driveRadius + 3} {...machined} />
<g transform={`rotate(${px(driveAngle)})`}>
<path
d={gearPath(drive, driveRadius, { addendum: MODULE * 0.5, dedendum: MODULE * 0.5, width: 0.28 })}
{...shell}
/>
<path d={`M 0 0 L ${px(driveRadius - 4)} 0`} stroke={palette.accent} strokeWidth={1.6} />
</g>
<circle r={driveRadius * 0.36} {...cast} />
<circle r={4} {...machined} />
</g>
{showIdler && (
<g data-idler transform={`translate(${IDLER.x} ${px(idlerY)})`}>
<circle r={IDLER.radius} {...cast} />
<g transform={`rotate(${px(idlerAngle)})`}>
<path d={`M 0 0 L ${IDLER.radius - 2} 0`} stroke={palette.metal} strokeWidth={1.2} />
</g>
<circle r={3} {...machined} />
</g>
)}
{variant === "blueprint" && (
<g fill="none" stroke={palette.grid} strokeWidth={0.5} strokeDasharray="2 3" opacity={0.7}>
<circle cx={DRIVE.x} cy={DRIVE.y} r={px(driveRadius)} />
<circle cx={DRIVEN.x} cy={DRIVEN.y} r={px(drivenRadius)} />
<path d={`M ${DRIVE.x} ${DRIVE.y} H ${DRIVEN.x}`} />
</g>
)}
</g>
</Frame>
</g>
<g fontFamily="ui-monospace, monospace" textAnchor="middle" fill={palette.foreground}>
<text x={100} y={140} fontSize={5}>
{`${drive}T:${driven}T / ${ratio}:1 / BELT ${px(belt.length)}`}
</text>
{label && (
<text x={100} y={147} fontSize={4.5}>
{label}
</text>
)}
</g>
</svg>
)
}
/** Belt travel in drive turns at `clock`. `shuttle` runs it back and forth. */
export function beltGoal(behavior: BeltDriveBehavior, clock: number) {
if (behavior === "static" || !Number.isFinite(clock)) return 0
if (behavior === "shuttle") return Math.sin(clock * Math.PI * 2) * 0.8
return clock
}
const teethOf = (teeth: number) =>
Number.isFinite(teeth) ? clamp(Math.round(teeth), 10, 48) : 18
const wrap360 = (value: number) => (Number.isFinite(value) ? ((value % 360) + 360) % 360 : 0)
export { BeltDrive }