Solenoid valve
A helical coil pulls a sprung plunger across a two- or three-port valve gallery. The plunger, spring and visible flow route all answer the same controlled position.
Open in workbenchview
variant
motion
ports
Drag the plunger or use the arrow keys; release it and the valve returns to its duty cycle.
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/solenoid-valve.jsonNotes
- The winding is sampled as a fixed-envelope helix. Flow switching and field loops are explanatory geometry; no fluid force, pressure, coil heating or magnetic flux density is calculated.
Usage
import { SolenoidValve } from "@/components/ui/solenoid-valve"
<SolenoidValve behavior="pulse" ports={3} normally="closed" view="iso" />
<SolenoidValve position={0.7} interactive onPositionChange={setPosition} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| position | number | — | Controlled plunger stroke from 0 released to 1 pulled. |
| behavior | "cycle" | "pulse" | "static" | "cycle" | Automatic plunger duty cycle when position is omitted. |
| ports | 2 | 3 | 2 | Number of visible valve ports. |
| normally | "open" | "closed" | "closed" | Whether the released flow route is open or closed. |
| interactive | boolean | false | Drag or arrow-key the plunger. |
| onPositionChange | (position: number) => void | — | Reports pointer and keyboard changes. |
| showField | boolean | true | Show qualitative activation loops around the coil. |
| view | "plan" | "front" | "profile" | "iso" | "profile" | Where the camera stands. One solenoid valve, four projections: straight down, straight on, side elevation, or three-quarter from above. |
| 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. |
| 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/solenoid-valve.tsx
"use client"
import * as React from "react"
import { arrowStep, useRobotDrag, useRobotScalar } from "@/hooks/use-robot-motion"
import { coilWinding } from "@/lib/robocn/electromagnetism"
import { clamp } from "@/lib/robocn/kinematics"
import {
aboutPoint,
extrudedPath,
px,
resolveRobotPalette,
resolveRobotSize,
robotCamera,
robotSurface,
roundedFootprint,
type RobotPaletteProps,
type RobotSize,
type RobotVariant,
type RobotView,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"
export type SolenoidValveBehavior = "cycle" | "pulse" | "static"
export interface SolenoidValveProps
extends Omit<React.ComponentProps<"svg">, "color">,
RobotPaletteProps {
position?: number
onPositionChange?: (position: number) => void
ports?: 2 | 3
normally?: "open" | "closed"
behavior?: SolenoidValveBehavior
speed?: number
phase?: number
paused?: boolean
animate?: boolean
interactive?: boolean
showField?: boolean
label?: string
view?: RobotView
size?: RobotSize | number
variant?: RobotVariant
}
const viewNames: Record<RobotView, string> = {
plan: "plan view",
front: "front elevation",
profile: "side elevation",
iso: "isometric view",
}
const winding = coilWinding({ turns: 9, length: 64, radius: 12, samplesPerTurn: 5 })
const windingPath = winding.map((point, index) => `${index ? "L" : "M"} ${px(82 + point.x)} ${px(79 + point.y)}`).join(" ")
function SolenoidValve({
position,
onPositionChange,
ports = 2,
normally = "closed",
behavior = "cycle",
speed = 0.35,
phase = 0,
paused = false,
animate = true,
interactive = false,
showField = true,
label,
view = "profile",
size = "md",
variant = "solid",
color,
accent,
metal,
dark,
glow,
grid,
palette: paletteOverride,
className,
style,
role,
tabIndex,
onKeyDown,
onBlur,
"aria-label": ariaLabel,
...props
}: SolenoidValveProps) {
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 = position !== undefined
const supplied = controlled && Number.isFinite(position) ? clamp(position, 0, 1) : controlled ? 0 : held
const goal = React.useCallback((clock: number) => solenoidValveGoal(behavior, clock), [behavior])
const motion = useRobotScalar(goal, {
hold: supplied,
rate: 2.5,
speed,
phase,
paused,
animate: animate && !controlled && behavior !== "static",
})
const value = clamp(motion.value, 0, 1)
const apply = React.useCallback((next: number) => {
const bounded = Math.round(clamp(next, 0, 1) * 100) / 100
setHeld(bounded)
onPositionChange?.(bounded)
}, [onPositionChange])
const dragging = useRobotDrag(svgRef, {
enabled: interactive,
onDrag: React.useCallback((unit) => apply((unit.x - 0.28) / 0.48), [apply]),
onDragEnd: React.useCallback(() => setHeld(null), []),
})
const open = normally === "open" ? value < 0.5 : value >= 0.5
const shift = px(value * 42)
const shell = robotSurface("shell", variant, palette)
const machined = robotSurface("metal", variant, palette)
const cast = robotSurface("dark", variant, palette)
const camera = robotCamera(view)
const face = aboutPoint(camera.wall(0, 90), 120, 105, view === "plan" ? 0.92 : 1)
const springStart = 151 + value * 42
const spring = Array.from({ length: 9 }, (_, index) => {
const x = springStart + ((201 - springStart) * index) / 8
const y = index === 0 || index === 8 ? 79 : 79 + (index % 2 ? -7 : 7)
return `${index ? "L" : "M"} ${px(x)} ${y}`
}).join(" ")
return (
<svg
ref={svgRef}
role={role ?? (interactive ? "slider" : "img")}
aria-label={ariaLabel ?? `Solenoid valve, ${Math.round(value * 100)} percent, ${viewNames[view]}`}
aria-valuemin={interactive ? 0 : undefined}
aria-valuemax={interactive ? 1 : undefined}
aria-valuenow={interactive ? px(value) : undefined}
aria-valuetext={interactive ? `${Math.round(value * 100)} percent` : undefined}
tabIndex={tabIndex ?? (interactive ? 0 : undefined)}
onKeyDown={(event) => {
onKeyDown?.(event)
if (!interactive || event.defaultPrevented) return
const delta = arrowStep(event.key, 0.05, 0.15)
if (delta) apply(value + 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 240 160"
width={width}
height={px(width * 2 / 3)}
className={cn("max-w-full select-none", interactive && "touch-none cursor-grab focus-visible:outline-2", dragging && "cursor-grabbing", className)}
style={{ color: palette.foreground, ...style }}
{...props}
>
{variant === "blueprint" && <path d="M 12 128 H 228 M 120 18 V 138" stroke={palette.grid} strokeWidth={0.6} strokeDasharray="3 4" />}
{view !== "profile" && (
<g transform="translate(120 116)" opacity={0.72}>
<path d={extrudedPath(roundedFootprint(102, 17, 4), camera, 72, 0)} {...shell} />
</g>
)}
<g data-view={view} transform={face || undefined}>
<rect x={17} y={45} width={206} height={70} rx={9} {...shell} />
<path d="M 28 58 H 210 M 28 101 H 210" stroke={palette.dark} strokeWidth={2} opacity={0.45} />
<rect x={42} y={57} width={81} height={44} rx={5} {...cast} />
<path data-coil d={windingPath} fill="none" stroke={palette.shell} strokeWidth={4} strokeLinecap="round" />
<path d="M 40 108 V 124 H 126 V 108" fill="none" stroke={palette.metal} strokeWidth={3} />
<path d="M 49 124 v 12 M 118 124 v 12" stroke={palette.accent} strokeWidth={2} />
<g data-plunger transform={`translate(${shift} 0)`}>
<rect x={106} y={70} width={45} height={18} rx={5} {...machined} />
<path d="M 148 65 V 93" stroke={palette.dark} strokeWidth={5} strokeLinecap="round" />
</g>
<path data-spring d={spring} fill="none" stroke={palette.metal} strokeWidth={2.5} strokeLinejoin="round" />
<rect x={201} y={61} width={11} height={36} rx={3} {...cast} />
{Array.from({ length: ports }, (_, index) => {
const x = ports === 2 ? 152 + index * 38 : 143 + index * 29
return <g key={x} data-port={index}><path d={`M ${x} 113 V 140`} stroke={palette.metal} strokeWidth={9} /><circle cx={x} cy={140} r={6} {...cast} /></g>
})}
<path
data-flow
data-open={open}
d={open ? "M 151 132 V 106 Q 151 96 163 96 H 190 V 132" : "M 151 132 V 116 M 190 132 V 116"}
fill="none"
stroke={open ? palette.accent : palette.grid}
strokeWidth={3}
strokeDasharray={open ? "5 3" : undefined}
/>
{showField && value > 0.03 && <g data-field fill="none" stroke={palette.glow} opacity={0.25 + value * 0.55} strokeWidth={1.5}><ellipse cx={83} cy={79} rx={50} ry={29} /><ellipse cx={83} cy={79} rx={59} ry={36} /></g>}
</g>
{label && <text x={120} y={155} textAnchor="middle" fontFamily="ui-monospace, monospace" fontSize={6} fill={palette.foreground}>{label}</text>}
</svg>
)
}
export function solenoidValveGoal(behavior: SolenoidValveBehavior, clock: number) {
if (behavior === "static") return 0
const t = Number.isFinite(clock) ? ((clock % 1) + 1) % 1 : 0
if (behavior === "pulse") return t < 0.42 ? 0 : t < 0.52 ? (t - 0.42) * 10 : t < 0.84 ? 1 : 1 - (t - 0.84) / 0.16
return 0.5 - 0.5 * Math.cos(t * Math.PI * 2)
}
export { SolenoidValve }