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

Radial bloom

A hub of telescoping rams pointed outward in one plane: closed it is an even star, driven out it is a ragged burst. Four guided stages per ram, and a vector of strokes drives each ram on its own.

Open in workbench
BLOOM / 12R
view
variant
drive
rams
12
pitch
13°
spin
0°

Drag out from the hub to pull the array open, or arrow-key it. Let go and it eases back into the behaviour.

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/radial-bloom.json

Notes

  • One ram is four concentric stages: a fixed sleeve and three that slide, each moving a third of the tip's travel. Six world units of overlap remain between consecutive stages at full extension, so no stage ever leaves the one guiding it.
  • The rams have different strokes on purpose — twelve identical telescopes cannot nest around one hub. Closed, every tip sits at the same radius; it is the extension that is ragged.
  • Rams alternate above and below the hub plane by pitch, so plan view is unchanged and the array opens into two cones as the camera tips. At pitch 0 the tipped views collapse to a line, which is what a flat array seen edge-on is.
  • Stage positions, overlaps and every projection are solved. The hub's face detail and the collars at each stage mouth are drawn, not driven.
  • The three moving stages are one painted member stepping down through three diameters, not four differently coloured parts: a telescope reads as one spoke that gets thinner, and the collar at each mouth is what says where the joints are.
  • Non-finite strokes render that ram closed rather than clamped onto the ring — a missing reading is not a zero reading.

Usage

import { RadialBloom } from "@/components/ui/radial-bloom"

<RadialBloom rams={12} />

// The whole stroke, open and closed.
<RadialBloom behavior="bloom" />

// Controlled as one array, as a vector of rams, or as a dial you can pull open.
<RadialBloom extension={0.8} />
<RadialBloom strokes={[1, 0.4, 0.9, 0.2, 0.7, 0.55]} />
<RadialBloom interactive onExtensionChange={setExtension} />

Props

PropTypeDefaultDescription
view"plan" | "front" | "profile" | "iso""plan"Where the camera stands. One array, four projections: straight down, straight on, side elevation, or three-quarter from above.
extensionnumber—Controlled extension of the whole array, 0 closed to 1 open. Supplying it stops the loop.
strokesnumber[]—Controlled extension per ram, 0 to 1. Its length sets the ram count, so a six-value vector builds a six-ram hub.
behavior"bloom" | "ripple" | "index" | "flutter" | "static""flutter"The default holds station near full travel with a small dither, which is the array working. bloom runs the whole stroke open and closed, ripple sends a travelling wave round the ring, index drives one ram at a time with the rest parked back.
ramsnumber12How many rams on the hub, clamped to 0–24. Ignored when strokes is supplied.
pitchnumber13Half-angle of the two ranks either side of the hub plane, in degrees, clamped to 0–40. Zero is a genuinely flat array.
spinnumber0Turn of the whole array about its own axis, in degrees. Independent of view.
speednumber0.3Opens and closes, or passes round the ring, 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.
interactivebooleanfalseDrag out from the hub to pull the array open; arrows step 5%, with shift 15%, Home closes and End opens.
onExtensionChange(extension: number) => void—Fired throughout a drag or a key press, in controlled mode too.
signal"idle" | "ready" | "warning""ready"Hub lamp: metal when idle, accent when ready, shell for a warning.
showEnvelopeboolean—Dashed circles at the closed and full reach radii. Defaults on in blueprint and off elsewhere.
showGroundbooleantrueDraw the contact shadow under the hub.
labelstring—Caption under the array.
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/radial-bloom.tsx
"use client"

/**
 * radial-bloom — a hub of telescoping rams that open into a star.
 *
 * A single ram cannot show three times its own retracted length: the moving
 * part would have to leave its sleeve, and a part that has left its sleeve is
 * not guided any more. So each ram here is four concentric stages — one fixed
 * sleeve bolted to the hub rim and three that slide, each carrying the next.
 * Every stage travels a third of the tip's travel, which keeps six world units
 * of overlap between consecutive stages at full extension. The machine is
 * honest at every stroke, not just at the ends.
 *
 * The rams are deliberately not interchangeable. `REACH` gives each one its own
 * stroke, so the array closes to an even star and opens into a ragged one. That
 * is the whole mechanism: extension is what varies around the ring, not length.
 *
 * The array is modelled once in world units — x starboard, y up, z toward the
 * tail — and pushed through `robotCamera(view)`. Plan view is the identity
 * projection, so the star is the drawing it was designed as; the ranks tilt
 * `±pitch` out of the hub plane and only separate once the camera tips over.
 *
 * Design note: docs/radial-bloom.md.
 */

import * as React from "react"

import { arrowStep, useRobotDrag, useRobotScalar } from "@/hooks/use-robot-motion"
import { clamp, toRadians, type Vec2, type Vec3 } from "@/lib/robocn/kinematics"
import {
  circleFootprint,
  extrudedPath,
  px,
  resolveRobotPalette,
  resolveRobotSize,
  robotCamera,
  robotSurface,
  slabPath,
  type RobotPaletteProps,
  type RobotRole,
  type RobotSize,
  type RobotVariant,
  type RobotView,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"

const VIEW = 220
/** Hub centre, and the origin every ram is measured from. */
const CENTRE = 110
const HUB_R = 19
const HUB_TOP = 5
const HUB_BOTTOM = -5
/** Where a ram's stages start, and how long each one is. */
const STAGE_R0 = 18
const STAGE_LENGTH = 22
const STAGES = 4
/** The painted nose cap is wider than its bore, so it never retracts flush. */
const NOSE = 3
/** Tip travel at full stroke. Stage k moves `TRAVEL / (STAGES - 1)`. */
const TRAVEL = 48
/** Radius of a fully closed and a fully open ram. */
const TIP_CLOSED = STAGE_R0 + STAGE_LENGTH + NOSE
const TIP_OPEN = TIP_CLOSED + TRAVEL
/** Half-widths and half-thicknesses, outward. Each stage slides inside the last. */
const STAGE_HALF_W = [4.4, 3.4, 2.4, 1.6]
const STAGE_HALF_T = [2.9, 2.2, 1.6, 1.1]
/**
 * One painted member stepping down through three diameters, off a machined
 * sleeve. Only the sleeve is a different role, and only because it is the one
 * stage that does not move — that distinction is the mechanism. Alternating all
 * four banded the spoke into blocks, which a telescope does not do, and it
 * destroyed the radial read at 150px, which is the only thing this silhouette
 * has. The collar at each mouth is what says where the joints are.
 */
const STAGE_ROLE: RobotRole[] = ["metal", "shell", "shell", "shell"]
const MAX_RAMS = 24
/** The ground the contact shadow falls on. Only reads once the camera tips. */
const GROUND = -34
/** Extension per second while a released array eases back into its behaviour. */
const BLOOM_RATE = 0.9
/** Where a parked machine sits. */
const NEUTRAL = 0.55

/**
 * Per-ram stroke ratios. Not decoration: twelve identical telescopes cannot nest
 * around one hub, so the rams are built to different strokes, and that is what
 * makes the open star ragged while the closed one stays even.
 */
const REACH = [1, 0.74, 0.93, 0.66, 0.98, 0.81, 0.7, 0.96, 0.62, 0.88, 0.77, 0.91]

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

export type BloomBehavior = "bloom" | "ripple" | "index" | "flutter" | "static"

export interface RadialBloomProps
  extends Omit<React.ComponentProps<"svg">, "color">,
    RobotPaletteProps {
  /** Controlled extension of the whole array, 0 closed to 1 open. */
  extension?: number
  /** Controlled extension per ram. Its length sets the ram count. */
  strokes?: readonly number[]
  /**
   * What the array does when neither `extension` nor `strokes` is supplied.
   * The default holds station near full travel, which is the array working;
   * `bloom` is the one that runs the whole stroke.
   */
  behavior?: BloomBehavior
  /** How many rams on the hub, 0 to 24. */
  rams?: number
  /** Half-angle of the two ranks either side of the hub plane, in degrees. */
  pitch?: number
  /** Turn of the whole array about its own axis, in degrees. */
  spin?: number
  /** Where the camera stands. One array, four projections. */
  view?: RobotView
  /** Cycles per second: one open and close, one pass round the ring. */
  speed?: number
  animate?: boolean
  paused?: boolean
  phase?: number
  /** Drag out from the hub to work the array by hand, or arrow-key it. */
  interactive?: boolean
  onExtensionChange?: (extension: number) => void
  /** Hub lamp: idle is dead, ready is live, warning takes the shell colour. */
  signal?: "idle" | "ready" | "warning"
  /** Dashed circle at the array's full reach. Defaults on in blueprint. */
  showEnvelope?: boolean
  showGround?: boolean
  label?: string
  size?: RobotSize | number
  variant?: RobotVariant
}

function RadialBloom({
  extension,
  strokes,
  behavior = "flutter",
  rams = 12,
  pitch = 13,
  spin = 0,
  view = "plan",
  speed = 0.3,
  animate = true,
  paused = false,
  phase = 0,
  interactive = false,
  onExtensionChange,
  signal = "ready",
  showEnvelope,
  showGround = true,
  label,
  size = "md",
  variant = "solid",
  color,
  accent,
  metal,
  dark,
  glow,
  grid,
  palette: paletteOverride,
  className,
  style,
  role,
  tabIndex,
  onKeyDown,
  onBlur,
  ...props
}: RadialBloomProps) {
  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 vector = strokes && strokes.length > 0 ? strokes.slice(0, MAX_RAMS) : null
  const count = vector ? vector.length : clamp(Math.round(finite(rams, 12)), 0, MAX_RAMS)
  const controlled = extension !== undefined || vector !== null
  const hold = controlled
    ? vector
      ? mean(vector)
      : clamp(finite(extension, 0), 0, 1)
    : held

  const goal = React.useCallback((clock: number) => bloomGoal(behavior, clock), [behavior])
  const motion = useRobotScalar(goal, {
    rate: BLOOM_RATE,
    hold,
    speed,
    animate: animate && !controlled && behavior !== "static",
    paused,
    phase,
  })
  const open = clamp(motion.value, 0, 1)

  const apply = React.useCallback(
    (next: number) => {
      const bounded = clamp(finite(next, 0), 0, 1)
      setHeld(bounded)
      onExtensionChange?.(bounded)
    },
    [onExtensionChange],
  )
  const dragging = useRobotDrag(svgRef, {
    enabled: interactive,
    // Distance out from the hub is the stroke, measured the way the array is
    // drawn: the plan-view radius, whichever camera is actually looking at it.
    onDrag: React.useCallback(
      (unit: Vec2) => {
        const dx = unit.x * VIEW - CENTRE
        const dy = unit.y * VIEW - CENTRE
        apply((Math.hypot(dx, dy) - TIP_CLOSED) / TRAVEL)
      },
      [apply],
    ),
    onDragEnd: React.useCallback(() => setHeld(null), []),
  })

  const camera = robotCamera(view)
  const turn = finite(spin, 0)
  const rank = clamp(finite(pitch, 13), 0, 40)
  const readout = Math.round(open * 100)

  const sleeve = robotSurface("dark", variant, palette)
  const machined = robotSurface("metal", variant, palette)
  const shell = robotSurface("shell", variant, palette)
  const bolt = robotSurface("dark", variant, palette, 0.5)
  const surfaces: Record<RobotRole, ReturnType<typeof robotSurface>> = {
    shell,
    metal: machined,
    dark: sleeve,
    accent: robotSurface("accent", variant, palette),
  }
  const lamp = signal === "warning" ? palette.shell : signal === "idle" ? palette.metal : palette.accent

  // Every ram is the same geometry at its own azimuth, rank and stroke. Stages
  // are convex slabs, so one `slabPath` per stage is exact from every camera.
  const ramsDrawn = Array.from({ length: count }, (_, index) => {
    const azimuth = turn + (index * 360) / Math.max(1, count)
    const tilt = index % 2 === 0 ? rank : -rank
    const frame = ramFrame(azimuth, tilt)
    const stroke = vector
      ? clamp(finite(vector[index], 0), 0, 1)
      : ramStroke(behavior, open, motion.clock, index, count)
    const travel = TRAVEL * REACH[index % REACH.length] * stroke
    const stages = Array.from({ length: STAGES }, (_, k) => {
      const offset = (travel * k) / (STAGES - 1)
      const from = STAGE_R0 + offset
      const to = from + STAGE_LENGTH + (k === STAGES - 1 ? NOSE : 0)
      const halfWidth = STAGE_HALF_W[k]
      const halfThick = STAGE_HALF_T[k]
      return {
        role: STAGE_ROLE[k],
        body: slab(stageOutline(from, to, halfWidth, k === STAGES - 1), frame, camera, halfThick),
        // The mouth each stage presents to the one inside it.
        collar:
          k === STAGES - 1
            ? null
            : slab(stageOutline(to - 2.6, to, halfWidth + 0.7, false), frame, camera, halfThick + 0.5),
      }
    })
    return {
      index,
      // Draw the far rams before the hub and the near ones after it.
      depth: camera.depth(frame.u.x * 46, frame.u.y * 46, frame.u.z * 46),
      stages,
    }
  })

  const hubDepth = camera.depth(0, HUB_TOP, 0)
  const far = ramsDrawn.filter((ram) => ram.depth <= hubDepth)
  const near = ramsDrawn.filter((ram) => ram.depth > hubDepth)
  const shadow = camera.project(0, GROUND, 0)
  const envelope = showEnvelope ?? variant === "blueprint"

  const Ram = ({ ram }: { ram: (typeof ramsDrawn)[number] }) => (
    <g data-ram={ram.index}>
      {ram.stages.map((stage, k) => (
        <React.Fragment key={k}>
          <path
            d={stage.body}
            {...surfaces[stage.role]}
            {...(k === STAGES - 1 ? { "data-blade": "" } : null)}
          />
          {stage.collar && <path d={stage.collar} {...machined} />}
        </React.Fragment>
      ))}
    </g>
  )

  return (
    <svg
      ref={svgRef}
      role={role ?? (interactive ? "slider" : "img")}
      aria-label={`Radial bloom, ${count} rams at ${readout}% extension, ${viewNames[view] ?? viewNames.plan}`}
      aria-valuemin={interactive ? 0 : undefined}
      aria-valuemax={interactive ? 100 : undefined}
      aria-valuenow={interactive ? readout : undefined}
      aria-valuetext={interactive ? `${readout}% extension` : undefined}
      tabIndex={tabIndex ?? (interactive ? 0 : undefined)}
      onKeyDown={(event) => {
        onKeyDown?.(event)
        if (!interactive || event.defaultPrevented) return
        const delta = arrowStep(event.key, event.shiftKey ? 0.15 : 0.05, 0.25)
        if (delta !== 0) apply(open + 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} ${VIEW}`}
      width={width}
      height={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}
    >
      {variant === "blueprint" && (
        <g fill="none" stroke={palette.grid} strokeWidth={0.5} opacity={0.45}>
          {camera.lift > 0.01 ? (
            <path d={`M 10 ${px(CENTRE - GROUND * camera.lift)} H 210`} strokeDasharray="2 3" />
          ) : (
            <path d={`M 10 ${CENTRE} H 210 M ${CENTRE} 10 V 210`} strokeDasharray="2 3" />
          )}
        </g>
      )}

      {showGround && (
        <ellipse
          cx={px(CENTRE + shadow.x)}
          cy={px(CENTRE + shadow.y)}
          rx={px(HUB_R * 1.5)}
          ry={px(HUB_R * 1.5 * camera.flatten)}
          fill={palette.dark}
          opacity={0.14}
        />
      )}

      <g data-bloom transform={`translate(${CENTRE} ${CENTRE})`}>
        {envelope && (
          <g data-envelope transform={camera.plane(0)} fill="none" stroke={palette.grid} strokeWidth={0.6}>
            <circle r={TIP_OPEN} strokeDasharray="2 4" />
            <circle r={TIP_CLOSED} strokeDasharray="1 5" />
          </g>
        )}

        {far.map((ram) => (
          <Ram key={ram.index} ram={ram} />
        ))}

        <g data-hub>
          <path d={extrudedPath(circleFootprint(0, 0, HUB_R, 18), camera, HUB_TOP, HUB_BOTTOM)} {...shell} />
          <g transform={camera.plane(HUB_TOP, turn)}>
            <circle r={HUB_R - 2} {...shell} />
            <circle r={7.5} {...machined} />
            {Array.from({ length: 8 }, (_, i) => {
              const angle = (i * Math.PI) / 4
              return (
                <circle
                  key={i}
                  cx={px(Math.cos(angle) * 13.5)}
                  cy={px(Math.sin(angle) * 13.5)}
                  r={1.1}
                  {...bolt}
                />
              )
            })}
            <path d={`M 0 ${px(-HUB_R + 1)} V ${px(-HUB_R + 4.5)}`} stroke={palette.grid} strokeWidth={0.7} />
            <circle r={4.2} {...bolt} />
            <circle data-lamp r={2.6} fill={lamp} opacity={variant === "wire" ? 1 : 0.92} />
            <circle r={4.4} fill="none" stroke={palette.glow} strokeWidth={0.6} opacity={0.4} />
          </g>
        </g>

        {near.map((ram) => (
          <Ram key={ram.index} ram={ram} />
        ))}
      </g>

      {variant === "blueprint" && (
        <text
          x={12}
          y={16}
          textAnchor="start"
          fontFamily="ui-monospace, monospace"
          fontSize={6}
          fill={palette.foreground}
        >
          {`${readout}% · ${count}R · ${px(TIP_OPEN)}u`}
        </text>
      )}
      {label && (
        <text
          x={CENTRE}
          y={VIEW - 6}
          textAnchor="middle"
          fontFamily="ui-monospace, monospace"
          fontSize={6}
          fill={palette.foreground}
        >
          {label}
        </text>
      )}
    </svg>
  )
}

/** A ram's own axes: `u` out along it, `v` across it, `n` through its thickness. */
function ramFrame(azimuth: number, tilt: number) {
  const a = toRadians(finite(azimuth, 0))
  const p = toRadians(finite(tilt, 0))
  const ca = Math.cos(a)
  const sa = Math.sin(a)
  const cp = Math.cos(p)
  const sp = Math.sin(p)
  return {
    u: { x: sa * cp, y: sp, z: -ca * cp },
    v: { x: ca, y: 0, z: sa },
    n: { x: sp * sa, y: -cp, z: -sp * ca },
  }
}

type RamFrame = ReturnType<typeof ramFrame>

/**
 * One stage's outline in ram coordinates: `x` out along the ram, `y` across it.
 * The last stage carries the chisel — two facets of unequal length, so the point
 * sits off the centre line the way a machined nose does.
 */
function stageOutline(from: number, to: number, halfWidth: number, nose: boolean): Vec2[] {
  if (!nose) {
    return [
      { x: from, y: halfWidth },
      { x: to, y: halfWidth },
      { x: to, y: -halfWidth },
      { x: from, y: -halfWidth },
    ]
  }
  const span = Math.max(1, to - from)
  return [
    { x: from, y: halfWidth },
    { x: to - Math.min(7, span * 0.4), y: halfWidth * 0.84 },
    { x: to, y: halfWidth * 0.18 },
    { x: to, y: -halfWidth * 0.18 },
    { x: to - Math.min(11, span * 0.62), y: -halfWidth * 0.96 },
    { x: from, y: -halfWidth },
  ]
}

/** A stage as the solid it is: its outline swept `±halfThick` through the ram. */
function slab(
  outline: readonly Vec2[],
  frame: RamFrame,
  camera: ReturnType<typeof robotCamera>,
  halfThick: number,
) {
  const corners: Vec3[] = outline.flatMap((point) => [
    ramPoint(frame, point.x, point.y, halfThick),
    ramPoint(frame, point.x, point.y, -halfThick),
  ])
  return slabPath(corners, camera)
}

const ramPoint = (frame: RamFrame, r: number, w: number, t: number): Vec3 => ({
  x: frame.u.x * r + frame.v.x * w + frame.n.x * t,
  y: frame.u.y * r + frame.v.y * w + frame.n.y * t,
  z: frame.u.z * r + frame.v.z * w + frame.n.z * t,
})

const finite = (value: number | undefined, fallback: number) =>
  Number.isFinite(value) ? (value as number) : fallback

const mean = (values: readonly number[]) => {
  const usable = values.map((value) => clamp(finite(value, 0), 0, 1))
  return usable.length ? usable.reduce((total, value) => total + value, 0) / usable.length : 0
}

/**
 * How far open the array wants to be at `clock`. The behaviours that spread the
 * rams apart hold a steady setpoint here and do their work in
 * {@link ramStroke} — the array's extension and its distribution are two
 * different things, and only the first is what a person grabs.
 */
export function bloomGoal(behavior: BloomBehavior, clock: number): number {
  if (!Number.isFinite(clock)) return NEUTRAL
  const t = ((clock % 1) + 1) % 1
  switch (behavior) {
    case "bloom":
      return (1 - Math.cos(t * Math.PI * 2)) / 2
    case "ripple":
      return 0.72
    case "index":
      return 0.78
    case "flutter":
      return 0.9
    default:
      return NEUTRAL
  }
}

/**
 * How ram `index` of `count` shares in an array open by `extension`. Pure, so
 * the cycle is testable without faking animation frames.
 */
export function ramStroke(
  behavior: BloomBehavior,
  extension: number,
  clock: number,
  index: number,
  count: number,
): number {
  const open = Number.isFinite(extension) ? clamp(extension, 0, 1) : 0
  const n = Math.max(1, Math.round(Number.isFinite(count) ? count : 1))
  const i = Number.isFinite(index) ? ((Math.round(index) % n) + n) % n : 0
  const t = Number.isFinite(clock) ? clock : 0
  switch (behavior) {
    case "ripple": {
      const wave = (1 - Math.cos((t - i / n) * Math.PI * 2)) / 2
      return clamp(open * (0.3 + 0.7 * wave), 0, 1)
    }
    case "index": {
      const active = Math.floor((((t % 1) + 1) % 1) * n)
      return clamp(active === i ? open : open * 0.2, 0, 1)
    }
    case "flutter":
      return clamp(open * (1 + Math.sin((t * 3 + i * 0.41) * Math.PI * 2) * 0.16), 0, 1)
    default:
      return open
  }
}

export { RadialBloom }