For the complete index, see /llms.txt. A Markdown version of any documentation page is available by appending .md to its URL or by sending an Accept: text/markdown header.

Browse documentation

Soccer ball

A truncated icosahedron inflated onto the sphere: twelve pentagons and twenty hexagons built from the solid, culled by their own normals, and rolled without slipping so the panels turn because it travelled.

Open in workbench
ROLL / 32 PANELS / 0°SOCCER / 04
view
variant
motion
panels
panels
12 pentagons, 20 hexagons
built from
an icosahedron cut at a third of every edge
rolled to
running

Drag left and right: the panels turn because the ball travelled, not alongside it — θ = s / r, and rolling it back unrolls them. Take bend with the plan camera to see the other equation: a standing spin axis puts ω × v sideways, and the ball leaves the dashed line it was struck along.

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/robot-soccer-ball.json

Notes

  • Solved: the ball itself. Truncating a regular icosahedron at exactly a third of every edge puts all sixty vertices the same distance from the centre, which is what makes the Archimedean solid; pushed onto the sphere and subdivided along great circles, the panels bulge the way an inflated one's do. Each carries its own normal, so the far side is culled and a panel over the horizon is clipped onto the limb rather than folded across the front.
  • Solved: the roll. `θ = s / r`, so the panels come round *because* the ball travelled, and rolling it backwards unrolls them. That holds under the controlled prop and under your finger, not only in the loop.
  • Illustrated: the air, as everywhere in this family. `bend` holds the Magnus term at its release value, so the kick is one quadratic; a real ball's spin decays and its drag curves the path, and neither is modelled.

Usage

import { RobotSoccerBall } from "@/components/ui/robot-soccer-ball"

// A standing spin axis puts the Magnus term sideways.
<RobotSoccerBall behavior="bend" view="plan" />

// Or roll it yourself — the panels turn exactly as far as it moved.
<RobotSoccerBall travel={0.72} onTravelChange={setTravel} interactive />

Props

PropTypeDefaultDescription
travelnumber—How far across it has rolled, 0 to 1. Supplying it stops the loop, and the turn still follows the travel.
onTravelChange(travel: number) => void—Fires while it is dragged or keyed, so interaction works in controlled mode too.
behavior"roll" | "bend" | "juggle" | "spin" | "static""roll"What it does with nobody driving it.
interactivebooleanfalseHand it to a person: drag left and right to roll it, or focus it and use the arrow keys.
showPathbooleantrueOn `bend`, draw the kick and the same kick with the spin taken out.
panelsbooleantrueDraw the 32 panels, culled by their own normals and clipped onto the limb.
showGroundbooleantrueDraw the ground line and the contact shadow.
labelstring—Optional technical caption under the drawing.
view"plan" | "front" | "profile" | "iso""profile"Where the camera stands. One ball, four projections: straight down, straight on, side elevation, or three-quarter from above.
speednumber0.35Cycles per second.
animatebooleantrueOff parks the machine at phase and stops rendering. A reduced-motion preference does the same.
pausedbooleanfalseFreeze where it stands.
phasenumber0Seconds 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.
colorstringvar(--robot-shell)Body panels — the colour the machine reads as.
accentstringvar(--robot-accent)Status colour: tip light, live tool, readouts.
metalstringvar(--robot-metal)Bare machined parts: collars, bolts, tool bodies.
darkstringvar(--robot-dark)Cast joints, base, shadow side.
palettePartial<RobotPalette>—Override any subset of roles at once, including glow and grid.

Source

src/components/ui/robot-soccer-ball.tsx
"use client"

/**
 * robot-soccer-ball — a truncated icosahedron, inflated.
 *
 * The panels are not drawn. Truncate a regular icosahedron at exactly one third
 * of every edge and all sixty vertices land the same distance from the centre;
 * that is the Archimedean solid, twelve pentagons where the old vertices were
 * and twenty hexagons on the old faces. Push the vertices out onto the sphere,
 * subdivide each edge along its great circle, and the panels bulge the way an
 * inflated ball's do. Each one carries its own normal, so the far side of the
 * ball is culled and a panel straddling the horizon is clipped onto the limb
 * rather than folded across the front.
 *
 * `roll` is the honest one: the ball's turn is `θ = s / r`, so the panels come
 * round *because* it travelled, and rolling it backwards unrolls them. Grab it
 * and drag, and the same relation holds under your finger. `bend` is the other
 * equation — a vertical spin axis, so `ω × v` points sideways and the ball
 * leaves the line it was struck along; the dashed line is the same kick with
 * the spin taken out, so the gap between them is the bend.
 *
 * The maths is in `src/lib/robocn/sport.ts` — pure, no React, tested on its
 * own. No air: nothing decays, and the Magnus term is held at its release
 * value, which makes the whole flight one quadratic.
 */

import * as React from "react"

import { arrowStep, useRobotClock, useRobotDrag } from "@/hooks/use-robot-motion"
import { clamp, lerp, type Vec2, type Vec3 } from "@/lib/robocn/kinematics"
import {
  applyFrame,
  clipToLimb,
  dribbleAt,
  flightAt,
  rollTurns,
  soccerPanels,
  sphereSilhouette,
  spinFrame,
  type SpherePanel,
} from "@/lib/robocn/sport"
import {
  boxCorners,
  fitTransform,
  px,
  resolveRobotPalette,
  resolveRobotSize,
  robotCamera,
  robotSurface,
  type RobotCamera,
  type RobotPaletteProps,
  type RobotSize,
  type RobotVariant,
  type RobotView,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"

/** What the ball does with nobody driving it. Always includes `static`. */
export type SoccerBallBehavior = "roll" | "bend" | "juggle" | "spin" | "static"

const VIEW_WIDTH = 210
const VIEW_HEIGHT = 200
const NATIVE_VIEW: RobotView = "profile"

/** The bottom strip the readout sits in; the drawing is fitted above it. */
const TEXT_ROOM = 30
const RADIUS = 21
/** How far the ball rolls each way, and where a struck ball starts. */
const TRAVEL = 60
const KICK_FROM: Vec3 = { x: 0, y: 0, z: 62 }
const KICK_SPEED = 150
const KICK_LAUNCH = 34
const KICK_GRAVITY = 168
const KICK_TIME = 1
/** Eight turns a second about a standing axis: that is what bends a free kick. */
const KICK_SPIN = { rate: 8, axis: { x: 0, y: 1, z: 0 } }
const MAGNUS = 0.0048
const SPIN_TURNS = 1.1
/**
 * The ground is a *plane*, so it is drawn as one. A line along a single world
 * axis collapses to a stick the moment the camera looks down that axis — which
 * is exactly what a side elevation does — where a patch foreshortens into a
 * parallelogram from every angle and never degenerates.
 */
const GROUND_PATCH: Vec2[] = [
  { x: -92, y: 88 },
  { x: 92, y: 88 },
  { x: 92, y: -88 },
  { x: -92, y: -88 },
]
/**
 * What every camera has to fit: the roll both ways, the kick from strike to
 * landing, the juggle's apex, and the ball's own radius around all of it.
 */
const ENVELOPE = boxCorners({ x: -92, y: 0, z: -92 }, { x: 92, y: 102, z: 92 })

const kickOptions = {
  speed: KICK_SPEED,
  launch: KICK_LAUNCH,
  gravity: KICK_GRAVITY,
  magnus: MAGNUS,
  from: KICK_FROM,
}

const viewNames: Record<RobotView, string> = {
  plan: "plan view",
  front: "front elevation",
  profile: "side elevation",
  iso: "isometric view",
}

const descriptions: Record<SoccerBallBehavior, string> = {
  roll: "rolling without slipping",
  bend: "bending off the line it was struck along",
  juggle: "being juggled",
  spin: "held up and turning",
  static: "held still",
}

/** Which way the camera lies from the ball: the gradient of its own depth. */
function viewDirection(camera: RobotCamera): Vec3 {
  return { x: camera.depth(1, 0, 0), y: camera.depth(0, 1, 0), z: camera.depth(0, 0, 1) }
}

export interface SoccerPose {
  /** Centre of the ball; `y` is the height of its underside above the floor. */
  position: Vec3
  /** Revolutions turned about `axis`. */
  turns: number
  axis: Vec3
  airborne: boolean
}

const ROLL_AXIS: Vec3 = { x: 0, y: 0, z: -1 }
const REST: SoccerPose = {
  position: { x: 0, y: 0, z: 0 },
  turns: 0,
  axis: ROLL_AXIS,
  airborne: false,
}

/** Where a ball that has rolled `drift` sits, and how far round it has gone. */
export const rolledTo = (drift: number): SoccerPose => ({
  position: { x: drift, y: 0, z: 0 },
  turns: rollTurns(drift, RADIUS),
  axis: ROLL_AXIS,
  airborne: false,
})

/**
 * Every behaviour is a pure function of the clock, exported so motion is tested
 * by sampling it rather than by faking animation frames.
 */
export function soccerPose(behavior: SoccerBallBehavior, clock: number): SoccerPose {
  if (!Number.isFinite(clock)) return REST
  switch (behavior) {
    case "roll":
      // Rolling both ways, and the turn follows the travel in both directions.
      return rolledTo(TRAVEL * Math.sin(Math.PI * 2 * clock))
    case "bend": {
      const along = ((clock % 1) + 1) % 1
      const flight = flightAt(along * KICK_TIME, { ...kickOptions, spin: KICK_SPIN })
      return {
        position: flight.position,
        turns: flight.turns,
        axis: KICK_SPIN.axis,
        airborne: true,
      }
    }
    case "juggle": {
      const state = dribbleAt(clock, { apex: TRAVEL * 0.9 })
      return {
        position: { x: 0, y: state.height, z: 0 },
        turns: clock * SPIN_TURNS * 0.4,
        axis: { x: 1, y: 0.2, z: 0 },
        airborne: true,
      }
    }
    case "spin":
      return {
        position: { x: 0, y: RADIUS * 0.9, z: 0 },
        turns: clock * SPIN_TURNS,
        axis: { x: 0.24, y: 1, z: 0.1 },
        airborne: true,
      }
    default:
      return REST
  }
}

export interface RobotSoccerBallProps
  extends Omit<React.ComponentProps<"svg">, "color">,
    RobotPaletteProps {
  /**
   * Controlled roll, 0 at the far left to 1 at the far right. Supplying it
   * stops the loop — and the ball still turns exactly as far as it moved.
   */
  travel?: number
  onTravelChange?: (travel: number) => void
  behavior?: SoccerBallBehavior
  showGround?: boolean
  /**
   * Draw the kick, and the same kick with the spin taken out. It comes off the
   * moment someone takes the ball in hand — a rolled ball is not on that arc,
   * and drawing a trajectory the object is not following is a lie.
   */
  showPath?: boolean
  panels?: boolean
  /** Where the camera stands. `plan` is where a bend reads. */
  view?: RobotView
  speed?: number
  phase?: number
  paused?: boolean
  animate?: boolean
  interactive?: boolean
  label?: string
  size?: RobotSize | number
  variant?: RobotVariant
}

function RobotSoccerBall({
  travel,
  onTravelChange,
  behavior = "roll",
  showGround = true,
  showPath = true,
  panels = true,
  view = NATIVE_VIEW,
  speed = 0.35,
  phase = 0,
  paused = false,
  animate = true,
  interactive = false,
  label,
  size = "md",
  variant = "solid",
  color,
  accent,
  metal,
  dark,
  glow,
  grid,
  palette: paletteOverride,
  className,
  style,
  role,
  tabIndex,
  onKeyDown,
  onBlur,
  "aria-label": ariaLabel,
  ...props
}: RobotSoccerBallProps) {
  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })
  const width = resolveRobotSize(size)
  const svgRef = React.useRef<SVGSVGElement>(null)
  const [held, setHeld] = React.useState<number | null>(null)
  const controlled = travel !== undefined

  const clock = useRobotClock({
    speed,
    animate: animate && !controlled && held === null && behavior !== "static",
    paused,
    phase,
  })
  const pinned = controlled
    ? Number.isFinite(travel) ? clamp(travel as number, 0, 1) : 0.5
    : held
  const pose =
    pinned === null ? soccerPose(behavior, clock) : rolledTo(lerp(-TRAVEL, TRAVEL, pinned))

  const apply = React.useCallback(
    (next: number) => {
      const bounded = Math.round(clamp(next, 0, 1) * 1000) / 1000
      setHeld(bounded)
      onTravelChange?.(bounded)
    },
    [onTravelChange],
  )
  const dragging = useRobotDrag(svgRef, {
    enabled: interactive,
    onDrag: React.useCallback((unit: Vec2) => apply(unit.x), [apply]),
    onDragEnd: React.useCallback(() => setHeld(null), []),
  })

  const camera = robotCamera(view)
  const fitted = fitTransform(ENVELOPE, camera, VIEW_WIDTH, VIEW_HEIGHT - TEXT_ROOM)
  const look = viewDirection(camera)
  const to = (point: Vec3): Vec2 => camera.project(point.x, point.y, point.z)
  const line = (points: readonly Vec2[], close = false) =>
    `${points.map((p, i) => `${i ? "L" : "M"} ${px(p.x)} ${px(p.y)}`).join(" ")}${close ? " Z" : ""}`

  const centre: Vec3 = {
    x: pose.position.x,
    y: pose.position.y + RADIUS,
    z: pose.position.z,
  }
  const place = (point: Vec3): Vec3 => ({
    x: centre.x + point.x,
    y: centre.y + point.y,
    z: centre.z + point.z,
  })

  const frame = spinFrame(pose.axis, pose.turns)
  const outline = sphereSilhouette(RADIUS, look, 96).map((p) => to(place(p)))

  // Only the panels the camera can see, limb-most first so a clipped sliver
  // paints under the panel it belongs beside rather than over it.
  const facing = PANELS.map((panel) => ({
    panel,
    towards: dot(applyFrame(frame, panel.centre), look),
  }))
    .filter((entry) => entry.towards > -0.08)
    .sort((a, b) => a.towards - b.towards)

  const sample = (withSpin: boolean) =>
    Array.from({ length: 29 }, (_, index) =>
      to(
        flightAt((index / 28) * KICK_TIME, {
          ...kickOptions,
          spin: withSpin ? KICK_SPIN : { rate: 0, axis: KICK_SPIN.axis },
        }).position,
      ),
    )

  const shell = robotSurface("shell", variant, palette)
  const cast = robotSurface("dark", variant, palette)
  const seamPaint = {
    stroke: variant === "wire" ? palette.grid : palette.dark,
    strokeWidth: variant === "solid" ? 0.9 : 0.7,
    strokeLinejoin: "round" as const,
  }

  const rolled = Math.round(pose.turns * 360)
  const percent = Math.round(clamp((pose.position.x + TRAVEL) / (2 * TRAVEL), 0, 1) * 100)

  return (
    <svg
      ref={svgRef}
      role={role ?? (interactive ? "slider" : "img")}
      aria-label={
        ariaLabel ??
        `Robot soccer ball, ${descriptions[behavior] ?? descriptions.static}, ${rolled} degrees round, ${viewNames[view] ?? viewNames.profile}`
      }
      aria-valuemin={interactive ? 0 : undefined}
      aria-valuemax={interactive ? 1 : undefined}
      aria-valuenow={interactive ? px(percent / 100) : undefined}
      aria-valuetext={interactive ? `${rolled} degrees round` : undefined}
      tabIndex={tabIndex ?? (interactive ? 0 : undefined)}
      onKeyDown={(event) => {
        onKeyDown?.(event)
        if (!interactive || event.defaultPrevented) return
        const delta = arrowStep(event.key, 0.04, 0.2)
        if (delta !== 0) apply(percent / 100 + delta)
        else if (event.key === "Home") apply(0)
        else if (event.key === "End") apply(1)
        else return
        event.preventDefault()
      }}
      onBlur={(event) => {
        onBlur?.(event)
        if (!dragging) setHeld(null)
      }}
      viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
      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 }}
      {...props}
    >
      <g data-view={view} data-behavior={behavior} transform={fitted || undefined}>
        {showGround && (
          <>
            <path
              data-ground
              d={line(GROUND_PATCH.map((corner) => to({ x: corner.x, y: 0, z: corner.y })), true)}
              fill={variant === "solid" ? palette.dark : "none"}
              fillOpacity={0.07}
              stroke={palette.dark}
              strokeWidth={1}
              opacity={0.45}
            />
            <ellipse
              data-shadow
              cx={px(to({ x: centre.x, y: 0, z: centre.z }).x)}
              cy={px(to({ x: centre.x, y: 0, z: centre.z }).y)}
              rx={px(RADIUS * 0.92)}
              ry={px(2 + (RADIUS - 5) * camera.flatten)}
              fill={palette.dark}
              opacity={px(clamp(0.2 - pose.position.y * 0.002, 0.05, 0.2))}
            />
          </>
        )}

        {showPath && behavior === "bend" && pinned === null && (
          <>
            <path
              data-datum
              d={line(sample(false))}
              fill="none"
              stroke={palette.grid}
              strokeWidth={0.8}
              strokeDasharray="3 3"
              opacity={0.75}
            />
            <path
              data-path
              d={line(sample(true))}
              fill="none"
              stroke={palette.metal}
              strokeWidth={1.3}
              strokeLinecap="round"
              opacity={0.85}
            />
          </>
        )}

        <g data-ball>
          <path data-shell d={line(outline, true)} {...shell} />

          {panels &&
            facing.map(({ panel }, index) => (
              <path
                key={index}
                data-panel={panel.kind}
                d={line(
                  panel.vertices.map((vertex) =>
                    to(place(scaled(clipToLimb(applyFrame(frame, vertex), look), RADIUS))),
                  ),
                  true,
                )}
                {...(panel.kind === "pentagon" ? cast : shell)}
                {...seamPaint}
              />
            ))}

          {variant === "solid" && (
            <ellipse
              data-highlight
              cx={px(to(place({ x: -RADIUS * 0.34, y: RADIUS * 0.46, z: RADIUS * 0.5 })).x)}
              cy={px(to(place({ x: -RADIUS * 0.34, y: RADIUS * 0.46, z: RADIUS * 0.5 })).y)}
              rx={6}
              ry={4}
              fill={palette.glow}
              opacity={0.14}
            />
          )}
        </g>
      </g>

      <g fontFamily="ui-monospace, monospace" textAnchor="middle" fill={palette.foreground}>
        <text x={VIEW_WIDTH / 2} y={VIEW_HEIGHT - 15} fontSize={5.5}>
          {`${behavior.toUpperCase()} / 32 PANELS / ${rolled}°`}
        </text>
        {label && (
          <text x={VIEW_WIDTH / 2} y={VIEW_HEIGHT - 6} fontSize={4.8} opacity={0.75}>
            {label}
          </text>
        )}
      </g>
    </svg>
  )
}

const dot = (a: Vec3, b: Vec3) => a.x * b.x + a.y * b.y + a.z * b.z
const scaled = (v: Vec3, s: number): Vec3 => ({ x: v.x * s, y: v.y * s, z: v.z * s })

/** Cut once: the solid is the same one every time. */
const PANELS: SpherePanel[] = soccerPanels(3)

export { RobotSoccerBall }