Patch bay
A rack of jacks joined by patch cables: every route from a source to a destination is a cable you can see and pull. For webhook events to handlers, agent tools to agents, and inputs to outputs.
Webhook routing
Patch a billing provider's events to your handlers. One event can feed several handlers.
3 routes · trial.ending, sub.deleted go nowhere
- invoice.paid to receipt
- invoice.paid to ledger
- charge.failed to dunning
- invoice.paid to receipt
- invoice.paid to ledger
- charge.failed to dunning
Installation
Add with the CLI
Once per project, add the Bevel registry to
components.json.components.json { "registries": { "@bevel": "https://bevel-ui.vercel.app/r/{name}.json" } }Add Patch bay. This brings in the Bevel foundation, installs @base-ui/react and cn, and merges the styles into your global CSS.
Terminal npx shadcn@latest add @bevel/patch-bay
Install manually
Install the dependencies.
Terminal pnpm add @base-ui/react cnAdd the Bevel foundation, if you haven't already. Light, depth, materials, motion and shape tokens, plus the utilities components use to apply them.
styles/bevel.css /* * Bevel foundation. Every component builds on these tokens and utilities; * component stylesheets only add their own palettes and shapes. * * Layers: * 1. Light one source, above and slightly in front of the screen. * 2. Depth named elevations built from the light: recessed, flush, * raised, pressed, object. * 3. Material a surface recipe (leather, plastic, metal) that reads * --bevel-material and --bevel-material-edge. * 4. Motion two curves, a spring, and durations named by purpose. * 5. Shape radii, stitching, and the type scale for amounts. */ :root { /* 1. Light */ --bevel-highlight: oklch(1 0 0 / 0.22); --bevel-highlight-strong: oklch(1 0 0 / 0.35); --bevel-shade: oklch(0 0 0 / 0.32); --bevel-shade-strong: oklch(0 0 0 / 0.5); --bevel-cast: oklch(0.25 0.03 50 / 0.22); /* Stamped into a surface: shaded on the upper inner edge, lit below. */ --bevel-deboss: 0 -1px 0 oklch(0 0 0 / 0.45), 0 1px 0 oklch(1 0 0 / 0.12); /* 3. Material defaults; components and variants override these. */ --bevel-material: oklch(0.47 0.085 52); --bevel-material-edge: oklch(0.3 0.06 44); --bevel-material-recess: oklch(0.2 0.008 260); /* neutral dark; materials set their own */ --bevel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .7 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E"); --bevel-grain-size: 180px; /* 2. Depth */ --bevel-depth-recessed: inset 0 1px 2px var(--bevel-shade-strong), 0 1px 0 var(--bevel-highlight); --bevel-depth-flush: inset 0 1px 0 var(--bevel-highlight), inset 0 -1px 0 var(--bevel-shade); --bevel-depth-raised: inset 0 1px 0 var(--bevel-highlight), 0 1px 0 var(--bevel-material-edge), 0 2px 0 var(--bevel-material-edge), 0 3px 5px var(--bevel-shade); --bevel-depth-pressed: inset 0 1px 2px var(--bevel-shade), 0 1px 0 var(--bevel-highlight); /* A whole object resting on the page. */ --bevel-depth-object: inset 0 1px 0 var(--bevel-highlight), inset 0 -1px 0 var(--bevel-shade), 0 0 0 1px var(--bevel-material-edge), 0 1px 1px var(--bevel-cast), 0 6px 12px -2px var(--bevel-cast), 0 18px 32px -8px var(--bevel-cast); /* The shadow under a loose object, for filter: drop-shadow(). */ --bevel-depth-cast-sm: 0 4px 6px var(--bevel-cast); /* A thin object (a card) lying on another surface. */ --bevel-depth-sheet: inset 0 1px 0 var(--bevel-highlight-strong), inset 0 0 0 1px oklch(0 0 0 / 0.12), 0 -1px 2px oklch(0 0 0 / 0.18); /* 4. Motion */ --bevel-ease-out: cubic-bezier(0.23, 1, 0.32, 1); --bevel-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* Underdamped spring, ~8% overshoot. Pure CSS; pair with --bevel-duration-move or longer. */ --bevel-ease-spring: linear(0, 0.06, 0.202, 0.38, 0.561, 0.723, 0.856, 0.955, 1.023, 1.062, 1.081, 1.083, 1.075, 1.062, 1.047, 1.032, 1.019, 1.009, 1.002, 0.997, 0.994, 0.993, 0.993, 0.994, 1); --bevel-duration-press: 100ms; /* a control giving under a finger */ --bevel-duration-small: 200ms; /* hover lifts, tabs, small state changes */ --bevel-duration-move: 300ms; /* objects moving: cards, drawers */ --bevel-duration-ambient: 520ms; /* whole-scene changes: lighting */ --bevel-stagger: 30ms; --bevel-lift: 3px; /* how far an object rises on hover */ --bevel-perspective: 30rem; /* viewing distance for parts that turn in 3D (covers, flaps) */ /* Unchosen items in a group sit back in shadow. */ --bevel-dim: 0.82; /* 5. Shape */ --bevel-radius-xs: 0.25rem; --bevel-radius-sm: 0.5rem; --bevel-radius-md: 0.625rem; --bevel-radius-lg: 1rem; --bevel-radius-xl: 1.5rem; --bevel-stitch-width: 1.5px; --bevel-stitch-inset: 0.375rem; /* Amounts: the figure, its secondary parts (currency, cents), labels, fine print. */ --bevel-amount: 2.25rem; --bevel-amount-minor: 1.25rem; --bevel-label: 0.75rem; --bevel-fine: 0.6875rem; --bevel-tracking-label: 0.08em; /* stamped labels */ --bevel-tracking-print: 0.06em; /* printed marks on cards */ /* Paper and ink */ --bevel-paper: oklch(0.975 0.008 85); --bevel-paper-foreground: oklch(0.28 0.02 260); --bevel-paper-muted: oklch(0.48 0.015 260); --bevel-paper-rule: oklch(0.78 0.04 240 / 0.45); --bevel-paper-margin: oklch(0.68 0.14 25 / 0.45); --bevel-fibre: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9 .35' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E"); /* Ink soaks unevenly: mostly solid with pale specks. Used as a mask. */ --bevel-ink-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='i'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 0 0 0 2.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23i)'/%3E%3C/svg%3E"); --bevel-ink-red: oklch(0.52 0.19 27); --bevel-ink-blue: oklch(0.44 0.14 262); --bevel-ink-green: oklch(0.5 0.12 155); --bevel-ink-black: oklch(0.3 0.01 260); --bevel-ink-amber: oklch(0.55 0.13 65); /* Brass and ivory: instrument hardware and dial faces */ --bevel-brass: oklch(0.76 0.11 82); --bevel-brass-edge: oklch(0.52 0.08 70); --bevel-ivory: oklch(0.95 0.025 90); --bevel-ivory-foreground: oklch(0.25 0.015 60); --bevel-needle: oklch(0.55 0.2 27); /* Desk stationery: legal pads, pens, pencils, exhibit stickers */ --bevel-pad: oklch(0.95 0.075 98); /* yellow legal pad */ --bevel-pad-foreground: oklch(0.27 0.03 255); --bevel-pad-rule: oklch(0.72 0.06 235 / 0.45); --bevel-pad-margin: oklch(0.62 0.16 25 / 0.55); --bevel-pen-red: oklch(0.52 0.2 27); /* correcting pen */ --bevel-pen-blue: oklch(0.42 0.15 262); /* writing pen */ --bevel-pencil: oklch(0.55 0.01 260); /* not written in ink yet */ --bevel-sticker: oklch(0.9 0.12 95); /* exhibit sticker */ --bevel-sticker-foreground: oklch(0.26 0.03 70); /* Handwriting. Bring a marker font (e.g. Permanent Marker) for the full effect; these are fallbacks. */ --bevel-font-marker: "Permanent Marker", "Marker Felt", "Chalkboard SE", ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif; } .dark { --bevel-highlight: oklch(1 0 0 / 0.14); /* Paper under dim light, not inverted: it's still paper. */ --bevel-paper: oklch(0.88 0.008 85); --bevel-ivory: oklch(0.86 0.022 90); --bevel-pad: oklch(0.86 0.07 98); --bevel-cast: oklch(0 0 0 / 0.55); } /* Depth */ @utility bevel-recessed { background-color: var(--bevel-material-recess); box-shadow: var(--bevel-depth-recessed); } @utility bevel-flush { box-shadow: var(--bevel-depth-flush); } @utility bevel-raised { background-color: color-mix(in oklch, var(--bevel-material), white 7%); background-image: linear-gradient(to bottom, var(--bevel-highlight), transparent 60%); box-shadow: var(--bevel-depth-raised); } @utility bevel-pressed { background-color: color-mix(in oklch, var(--bevel-material), black 4%); background-image: none; box-shadow: var(--bevel-depth-pressed); } @utility bevel-object { box-shadow: var(--bevel-depth-object); } /* Materials */ @utility bevel-leather { background-color: var(--bevel-material); background-image: var(--bevel-grain), linear-gradient(to bottom, var(--bevel-highlight), transparent 28%, transparent 72%, var(--bevel-shade)); background-size: var(--bevel-grain-size) var(--bevel-grain-size), 100% 100%; background-blend-mode: soft-light, normal; } @utility bevel-plastic { background-color: var(--bevel-material); background-image: linear-gradient(115deg, var(--bevel-highlight-strong), transparent 40%), linear-gradient(to bottom, transparent 25%, oklch(0 0 0 / 0.45)); box-shadow: var(--bevel-depth-sheet); } @utility bevel-metal { background-color: var(--bevel-material); background-image: linear-gradient(to bottom, var(--bevel-highlight-strong), transparent 45%, var(--bevel-shade) 100%), repeating-linear-gradient(90deg, oklch(1 0 0 / 0.06) 0 1px, transparent 1px 3px); box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.25), inset 0 1px 0 var(--bevel-highlight-strong); } /* Details */ @utility bevel-deboss { text-shadow: var(--bevel-deboss); } @utility bevel-stitch { border-color: var(--bevel-stitch, oklch(0.88 0.05 82 / 0.8)); border-style: dashed; border-width: var(--bevel-stitch-width); filter: drop-shadow(0 1px 0 var(--bevel-shade)); } /* A lit fill sitting in a recess (meters, progress). Colour from --bevel-fill. */ @utility bevel-fill { background-color: var(--bevel-fill); background-image: linear-gradient(to bottom, oklch(1 0 0 / 0.55), transparent 55%, oklch(0 0 0 / 0.18)); box-shadow: 0 0 6px -1px var(--bevel-fill); } /* A small indicator light. Colour from --bevel-led. */ @utility bevel-led { background-color: var(--bevel-led); background-image: radial-gradient(circle at 35% 30%, oklch(1 0 0 / 0.85), transparent 45%); box-shadow: 0 0 6px var(--bevel-led), inset 0 -1px 1px oklch(0 0 0 / 0.35), 0 1px 0 var(--bevel-highlight); } /* Paper: a sheet resting on the surface, with fibre in it. */ @utility bevel-paper { background-color: var(--bevel-paper); background-image: var(--bevel-fibre); background-size: 240px 240px; color: var(--bevel-paper-foreground); box-shadow: 0 1px 1px var(--bevel-cast), 0 6px 14px -4px var(--bevel-cast); } /* Ink on paper: multiplies into the surface and soaks unevenly. Colour from --bevel-ink. */ @utility bevel-ink { color: var(--bevel-ink, var(--bevel-ink-red)); border-color: currentColor; mix-blend-mode: multiply; mask-image: var(--bevel-ink-texture); mask-size: 160px 160px; } /* Glass over an instrument window: one reflection from the light above. Put on an overlay element. */ @utility bevel-glass { pointer-events: none; background-image: linear-gradient(165deg, var(--bevel-highlight-strong) 0%, transparent 38%, transparent 70%, var(--bevel-shade) 100%); } /* Material families: one per screen. Set on the screen's container. */ [data-bevel-family="leather"] { --bevel-cast: oklch(0.25 0.03 50 / 0.22); } [data-bevel-family="paper"] { --bevel-cast: oklch(0.3 0.01 250 / 0.16); } .dark :is([data-bevel-family="leather"], [data-bevel-family="paper"]) { --bevel-cast: oklch(0 0 0 / 0.55); } /* * Ruled paper: rules every --bevel-rule-height and a margin line at * --bevel-rule-margin. Set --bevel-sheet for the paper colour. The ruling * scrolls with the content. */ @utility bevel-ruled { background-color: var(--bevel-sheet, var(--bevel-paper)); background-image: linear-gradient( to right, transparent calc(var(--bevel-rule-margin) - 1px), var(--bevel-sheet-margin, var(--bevel-paper-margin)) calc(var(--bevel-rule-margin) - 1px) calc(var(--bevel-rule-margin) + 1px), transparent calc(var(--bevel-rule-margin) + 1px) ), repeating-linear-gradient( to bottom, transparent 0 calc(var(--bevel-rule-height) - 1px), var(--bevel-sheet-rule, var(--bevel-paper-rule)) calc(var(--bevel-rule-height) - 1px) var(--bevel-rule-height) ), var(--bevel-fibre); background-size: 100% 100%, 100% 100%, 240px 240px; background-attachment: local; color: var(--bevel-paper-foreground); box-shadow: 0 1px 1px var(--bevel-cast), 0 6px 14px -4px var(--bevel-cast); }Copy
patch-bay.tsxintocomponents/ui.components/patch-bay.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { cn } from "cn" import { useBevelFeedback } from "@/lib/feedback" /** One patch: a source jack's value, then a destination jack's value. */ type PatchBayRoute = [from: string, to: string] type PatchBaySide = "source" | "destination" type Jack = { side: PatchBaySide; value: string; label: string } type Point = { x: number; y: number } type PatchBayContextValue = { routes: PatchBayRoute[] jacks: Jack[] rows: Record<PatchBaySide, string> armed: { side: PatchBaySide; value: string } | null disabled: boolean sockets: React.RefObject<Map<string, HTMLElement>> press: (side: PatchBaySide, value: string) => void pull: (side: PatchBaySide, value: string) => void remove: (route: PatchBayRoute) => void register: (jack: Jack, socket: HTMLElement) => () => void registerRow: (side: PatchBaySide, label: string) => () => void setListed: (listed: boolean) => void labelOf: (side: PatchBaySide, value: string) => string } const PatchBayContext = React.createContext<PatchBayContextValue | null>(null) function usePatchBay() { const context = React.useContext(PatchBayContext) if (!context) throw new Error("PatchBay parts must be inside <PatchBay>") return context } const keyOf = (side: PatchBaySide, value: string) => `${side}:${value}` const same = (a: PatchBayRoute, b: PatchBayRoute) => a[0] === b[0] && a[1] === b[1] function list(names: string[]) { if (names.length < 2) return names.join("") return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` } type PatchBayProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & { /** Current patches, as [source, destination] pairs. */ value?: PatchBayRoute[] defaultValue?: PatchBayRoute[] onValueChange?: (value: PatchBayRoute[]) => void disabled?: boolean } /** * A patch bay: route sources to destinations with cables. Press a jack, * then a jack in the other row, to patch them; patch the same pair again to * pull the cable. A source can feed many destinations and a destination * can take many sources. Put the rows in a PatchBayPanel. Give it an aria-label. */ function PatchBay({ className, value: valueProp, defaultValue = [], onValueChange, disabled = false, children, ...props }: PatchBayProps) { const [uncontrolled, setUncontrolled] = React.useState(defaultValue) const routes = valueProp ?? uncontrolled const [jacks, setJacks] = React.useState<Jack[]>([]) const [rows, setRows] = React.useState<Record<PatchBaySide, string>>({ source: "sources", destination: "destinations" }) const [armed, setArmed] = React.useState<PatchBayContextValue["armed"]>(null) const [announcement, setAnnouncement] = React.useState("") const [listed, setListed] = React.useState(false) const sockets = React.useRef(new Map<string, HTMLElement>()) const feedback = useBevelFeedback() const labelOf = React.useCallback( (side: PatchBaySide, value: string) => jacks.find((j) => j.side === side && j.value === value)?.label ?? value, [jacks] ) const commit = React.useCallback( (next: PatchBayRoute[]) => { if (valueProp === undefined) setUncontrolled(next) onValueChange?.(next) }, [valueProp, onValueChange] ) const remove = React.useCallback( (route: PatchBayRoute) => { commit(routes.filter((r) => !same(r, route))) feedback.click() setAnnouncement(`Unpatched ${labelOf("source", route[0])} from ${labelOf("destination", route[1])}.`) }, [commit, routes, feedback, labelOf] ) const press = React.useCallback( (side: PatchBaySide, value: string) => { if (disabled) return if (!armed || armed.side === side) { if (armed?.value === value) { setArmed(null) setAnnouncement("Cancelled.") return } setArmed({ side, value }) feedback.click() const other: PatchBaySide = side === "source" ? "destination" : "source" setAnnouncement(`Selected ${labelOf(side, value)}. Now choose one of the ${rows[other]}.`) return } const route: PatchBayRoute = side === "destination" ? [armed.value, value] : [value, armed.value] setArmed(null) if (routes.some((r) => same(r, route))) { remove(route) return } commit([...routes, route]) feedback.thunk() setAnnouncement(`Patched ${labelOf("source", route[0])} to ${labelOf("destination", route[1])}.`) }, [disabled, armed, routes, commit, remove, feedback, labelOf, rows] ) const pull = React.useCallback( (side: PatchBaySide, value: string) => { if (disabled) return const index = side === "source" ? 0 : 1 const pulled = routes.filter((r) => r[index] === value) if (!pulled.length) return commit(routes.filter((r) => r[index] !== value)) feedback.click() setAnnouncement(`Unpatched ${labelOf(side, value)}: ${pulled.length} cable${pulled.length === 1 ? "" : "s"}.`) }, [disabled, routes, commit, feedback, labelOf] ) const register = React.useCallback((jack: Jack, socket: HTMLElement) => { const key = keyOf(jack.side, jack.value) sockets.current.set(key, socket) setJacks((all) => all.some((j) => keyOf(j.side, j.value) === key) ? all.map((j) => (keyOf(j.side, j.value) === key ? jack : j)) : [...all, jack] ) return () => { sockets.current.delete(key) setJacks((all) => all.filter((j) => keyOf(j.side, j.value) !== key)) } }, []) const registerRow = React.useCallback((side: PatchBaySide, label: string) => { setRows((r) => ({ ...r, [side]: label })) return () => setRows((r) => ({ ...r, [side]: side === "source" ? "sources" : "destinations" })) }, []) const context = React.useMemo( () => ({ routes, jacks, rows, armed, disabled, sockets, press, pull, remove, register, registerRow, setListed, labelOf }), [routes, jacks, rows, armed, disabled, press, pull, remove, register, registerRow, labelOf] ) return ( <PatchBayContext value={context}> <div role="group" data-slot="patch-bay" data-armed={armed ? "" : undefined} data-disabled={disabled ? "" : undefined} onKeyDown={(event) => { if (event.key === "Escape" && armed) { setArmed(null) setAnnouncement("Cancelled.") } }} className={cn( "flex w-full flex-col gap-4 data-disabled:opacity-60", className )} {...props} > {children} <p role="status" className="sr-only"> {announcement} </p> {!listed && ( <ul aria-label="Routes" className="sr-only"> {routes.length ? ( routes.map((route) => ( <li key={route.join("\u0000")}> {labelOf("source", route[0])} to {labelOf("destination", route[1])} </li> )) ) : ( <li>No routes</li> )} </ul> )} </div> </PatchBayContext> ) } /** The rack panel: the rows of jacks, and the cables drawn between them. */ function PatchBayPanel({ className, children, ...props }: React.ComponentProps<"div">) { const { routes, jacks, sockets } = usePatchBay() const panel = React.useRef<HTMLDivElement>(null) const [points, setPoints] = React.useState<Record<string, Point>>({}) // Where each socket sits, so the cables can be drawn between them. const measure = React.useCallback(() => { const box = panel.current?.getBoundingClientRect() if (!box) return const next: Record<string, Point> = {} for (const [key, el] of sockets.current) { const r = el.getBoundingClientRect() next[key] = { x: r.left - box.left + r.width / 2, y: r.top - box.top + r.height / 2 } } setPoints(next) }, [sockets]) React.useLayoutEffect(measure, [measure, jacks]) React.useEffect(() => { const el = panel.current if (!el) return const observer = new ResizeObserver(measure) observer.observe(el) void document.fonts?.ready.then(measure) return () => observer.disconnect() }, [measure]) const sources = jacks.filter((j) => j.side === "source") return ( <div ref={panel} data-slot="patch-bay-panel" className={cn( "bevel-metal bevel-object relative flex w-full flex-col gap-(--patch-bay-row-gap) rounded-(--bevel-radius-md) p-4 text-(--patch-bay-print)", className )} {...props} > {children} <svg aria-hidden data-slot="patch-bay-cables" className="pointer-events-none absolute inset-0 size-full overflow-visible"> {routes.map((route) => { const a = points[keyOf("source", route[0])] const b = points[keyOf("destination", route[1])] if (!a || !b) return null const colour = Math.max(0, sources.findIndex((s) => s.value === route[0])) % 6 // The cable drops out of the source, hangs, and comes down into the destination. const dy = Math.abs(b.y - a.y) const span = Math.abs(b.x - a.x) const d = `M ${a.x} ${a.y} C ${a.x} ${a.y + dy * 0.9 + span * 0.08}, ${b.x} ${b.y - dy * 0.2}, ${b.x} ${b.y}` return ( <g key={route.join("\u0000")} data-slot="patch-bay-cable" className="transition-opacity duration-(--bevel-duration-small) ease-(--bevel-ease-out) starting:opacity-0 motion-reduce:transition-none" style={{ ["--patch-bay-cable" as string]: `var(--patch-bay-cable-${colour + 1})` }} > <path d={d} className="patch-bay-cable-shadow" /> <path d={d} className="patch-bay-cable-body" /> <circle cx={a.x} cy={a.y} className="patch-bay-plug" /> <circle cx={b.x} cy={b.y} className="patch-bay-plug" /> </g> ) })} </svg> </div> ) } const RowContext = React.createContext<PatchBaySide>("source") type PatchBayRowProps = React.ComponentProps<"div"> & { side: PatchBaySide /** Names the row, e.g. “Events” or “Handlers”. */ label: string } /** A row of jacks. Sources sit on top with labels above; destinations below with labels underneath. */ function PatchBayRow({ className, side, label, children, ...props }: PatchBayRowProps) { const { registerRow } = usePatchBay() React.useEffect(() => registerRow(side, label), [side, label, registerRow]) return ( <RowContext value={side}> <div role="group" aria-label={label} data-slot="patch-bay-row" data-side={side} className={cn("flex flex-col gap-2 data-[side=destination]:flex-col-reverse", className)} {...props} > <span aria-hidden className="bevel-deboss text-(length:--bevel-fine) font-semibold tracking-(--bevel-tracking-label) uppercase" > {label} </span> <div className="flex justify-around gap-1.5"> {children} </div> </div> </RowContext> ) } type PatchBayJackProps = Omit<ButtonPrimitive.Props, "value" | "children"> & { value: string /** The jack's name, printed on its designation strip. */ children: string } /** * One jack: a button named by its label and described by what it's patched * to. Press it to pick it up; Delete or Backspace pulls all its cables. */ function PatchBayJack({ className, value, children, onClick, onKeyDown, ...props }: PatchBayJackProps) { const { routes, armed, disabled, press, pull, register, labelOf } = usePatchBay() const side = React.useContext(RowContext) const socket = React.useRef<HTMLSpanElement>(null) const descriptionId = React.useId() React.useEffect(() => { if (!socket.current) return return register({ side, value, label: children }, socket.current) }, [side, value, children, register]) const index = side === "source" ? 0 : 1 const mine = routes.filter((r) => r[index] === value) const others = mine.map((r) => (side === "source" ? labelOf("destination", r[1]) : labelOf("source", r[0]))) const isArmed = armed?.side === side && armed.value === value const description = others.length ? `${side === "source" ? "Patched to" : "Patched from"} ${list(others)}.` : "Not patched." return ( <span data-slot="patch-bay-position" className="flex max-w-(--patch-bay-jack-width) min-w-0 flex-1 flex-col items-center gap-1.5 in-data-[side=destination]:flex-col-reverse"> <span data-slot="patch-bay-label" aria-hidden className="bevel-paper flex h-(--patch-bay-label-height) w-full items-center justify-center overflow-hidden rounded-(--bevel-radius-xs) px-0.5 text-center text-(length:--bevel-fine) leading-tight font-semibold [overflow-wrap:anywhere]" > {/* Let dotted and snake_case names break at their separators. */} <span className="line-clamp-2">{children.replace(/([._/])/g, "$1\u200b")}</span> </span> <ButtonPrimitive data-slot="patch-bay-jack" data-side={side} data-patched={mine.length ? "" : undefined} aria-pressed={isArmed} aria-describedby={descriptionId} disabled={disabled} onClick={(event) => { onClick?.(event) press(side, value) }} onKeyDown={(event) => { onKeyDown?.(event) if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault() pull(side, value) } }} className={cn( "group/jack grid size-(--patch-bay-jack-size) cursor-pointer place-items-center rounded-full outline-none", "focus-visible:ring-3 focus-visible:ring-(--patch-bay-focus)/70 data-disabled:cursor-not-allowed", className )} {...props} > <span className="sr-only">{children}</span> <span ref={socket} aria-hidden className={cn( "patch-bay-socket grid size-full place-items-center rounded-full", "transition-[scale,filter] duration-(--bevel-duration-press) ease-(--bevel-ease-out) group-active/jack:scale-95 motion-reduce:transition-none", "group-aria-pressed/jack:patch-bay-socket-armed" )} > <span className="patch-bay-hole size-(--patch-bay-hole-size) rounded-full" /> </span> </ButtonPrimitive> <span id={descriptionId} hidden> {description} </span> </span> ) } /** A visible list of patches, each with a button to pull it. Replaces the built-in screen-reader list. */ function PatchBayRoutes({ className, ...props }: React.ComponentProps<"ul">) { const { routes, remove, setListed, labelOf, disabled } = usePatchBay() React.useEffect(() => { setListed(true) return () => setListed(false) }, [setListed]) return ( <ul aria-label="Routes" data-slot="patch-bay-routes" className={cn("flex flex-col gap-1 text-sm", className)} {...props}> {routes.length === 0 && <li className="text-muted-foreground">No routes yet.</li>} {routes.map((route) => { const from = labelOf("source", route[0]) const to = labelOf("destination", route[1]) return ( <li key={route.join("\u0000")} className="flex items-center justify-between gap-3"> <span className="min-w-0 truncate"> {from} <span aria-hidden>→</span> <span className="sr-only">to</span> {to} </span> <ButtonPrimitive data-slot="patch-bay-remove" aria-label={`Unpatch ${from} from ${to}`} disabled={disabled} onClick={() => remove(route)} className="shrink-0 rounded-(--bevel-radius-xs) px-2 py-0.5 text-xs font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-disabled:opacity-50" > Unpatch </ButtonPrimitive> </li> ) })} </ul> ) } export { PatchBay, PatchBayPanel, PatchBayRow, PatchBayJack, PatchBayRoutes } export type { PatchBayProps, PatchBayRowProps, PatchBayJackProps, PatchBayRoute, PatchBaySide }Copy
patch-bay.cssintoyour styles.styles/patch-bay.css /* * Patch bay: two rows of jacks on a rack panel, joined by patch cables * that hang a little between them. Builds on bevel.css (metal, paper, * motion). */ :root { --patch-bay-panel: oklch(0.27 0.008 260); /* black anodised rack panel */ --patch-bay-panel-edge: oklch(0.17 0.008 260); --patch-bay-print: oklch(0.9 0.01 90); --patch-bay-ring: oklch(0.78 0.01 260); /* nickel jack bushing */ --patch-bay-hole: oklch(0.1 0.005 260); --patch-bay-armed: oklch(0.82 0.15 85); /* picked up: a warm ring */ --patch-bay-focus: oklch(0.82 0.15 85); --patch-bay-cable-1: oklch(0.6 0.19 27); /* red */ --patch-bay-cable-2: oklch(0.58 0.14 255); /* blue */ --patch-bay-cable-3: oklch(0.8 0.15 90); /* yellow */ --patch-bay-cable-4: oklch(0.6 0.13 150); /* green */ --patch-bay-cable-5: oklch(0.92 0.01 90); /* white */ --patch-bay-cable-6: oklch(0.6 0.15 320); /* violet */ --patch-bay-cable-width: 4px; --patch-bay-plug-radius: 7px; --patch-bay-jack-size: 2rem; --patch-bay-hole-size: 0.75rem; --patch-bay-jack-width: 4.5rem; --patch-bay-label-height: 1.75rem; /* two lines of fine print */ --patch-bay-row-gap: 4.5rem; /* room for the cables to hang */ } [data-slot="patch-bay-panel"] { --bevel-material: var(--patch-bay-panel); --bevel-material-edge: var(--patch-bay-panel-edge); } /* A nickel bushing round a dark hole, lit from above. */ @utility patch-bay-socket { background-color: var(--patch-bay-ring); background-image: linear-gradient(to bottom, var(--bevel-highlight-strong), transparent 50%, var(--bevel-shade)); box-shadow: 0 0 0 2px var(--patch-bay-panel-edge), 0 2px 3px var(--bevel-shade); } @utility patch-bay-socket-armed { box-shadow: 0 0 0 2px var(--patch-bay-panel-edge), 0 0 0 4px var(--patch-bay-armed), 0 0 10px 2px var(--patch-bay-armed); } @utility patch-bay-hole { background-color: var(--patch-bay-hole); box-shadow: var(--bevel-depth-recessed); } /* Cables: a body in the cable's colour over a soft shadow on the panel. */ @utility patch-bay-cable-body { fill: none; stroke: var(--patch-bay-cable); stroke-width: var(--patch-bay-cable-width); stroke-linecap: round; } @utility patch-bay-cable-shadow { fill: none; stroke: var(--bevel-shade-strong); stroke-width: var(--patch-bay-cable-width); stroke-linecap: round; translate: 0 3px; filter: blur(1.5px); } @utility patch-bay-plug { r: var(--patch-bay-plug-radius); fill: var(--patch-bay-cable); stroke: var(--bevel-shade-strong); stroke-width: 1.5px; } @media (forced-colors: active) { [data-slot="patch-bay-jack"] { border: 1px solid ButtonText; } [data-slot="patch-bay-jack"][aria-pressed="true"] { outline: 3px solid Highlight; } .patch-bay-cable-body, .patch-bay-plug { stroke: CanvasText; } }Import the stylesheets after Tailwind in your global CSS, foundation first.
globals.css @import "./bevel.css"; @import "./patch-bay.css";
Anatomy
Each part is its own component. Leave out the ones you don't need.
<PatchBay aria-label="Webhook routing" value={routes} onValueChange={setRoutes}>
<PatchBayPanel>
<PatchBayRow side="source" label="Events">
<PatchBayJack value="invoice.paid">invoice.paid</PatchBayJack>
</PatchBayRow>
<PatchBayRow side="destination" label="Handlers">
<PatchBayJack value="receipt">Send receipt</PatchBayJack>
</PatchBayRow>
</PatchBayPanel>
<PatchBayRoutes />
</PatchBay>Parts
| Part | Renders | What it does |
|---|---|---|
| PatchBaydiv (group) | div (group) | value and defaultValue: [source, destination] pairs. onValueChange, disabled. Announces each patch, and lists the routes for screen readers. Give it an aria-label. |
| PatchBayPaneldiv | div | The rack panel. Draws the cables, which hang a little between the rows, in a colour per source. |
| PatchBayRowdiv (group) | div (group) | side source or destination, and a label naming the row. Sources sit on top, destinations below. |
| PatchBayJackButton | Button | value; its text is its name. Press one jack, then one in the other row, to patch them; patch the same pair again to pull it. Escape cancels; Delete pulls all its cables. Described as “Patched to Send receipt and Update ledger.” |
| PatchBayRoutesul | ul | Optional: a visible list of routes with an Unpatch button each. Replaces the built-in screen-reader list. |