Skip to content

Navigation

Connect

Cards with typed inputs and outputs that people can connect, inspect, and disconnect.

When to use it: Use it for visual workflows, material editors, and other relationships that people need to edit directly.

On this page

Example

Loading example…
Connect.tsxtsx
import {
  Connect,
  ConnectCard,
  ConnectDisconnect,
  ConnectInput,
  ConnectInputSelect,
  ConnectInputTrigger,
  ConnectLines,
  ConnectOutput,
} from "@comp0/react";

export function Example() {
  return (
    <div className="mx-auto w-full max-w-xs">
      <p className="mb-6 text-sm/6 text-zinc-600 dark:text-zinc-400">
        Tap a circle, then the square. Or drag between them.
      </p>
      <Connect
        aria-label="Flower connections"
        defaultValue={[{ from: "sun", to: "flower" }]}
        className="relative grid grid-cols-2 gap-12"
      >
        <ConnectLines className="text-teal-600 dark:text-teal-400" strokeWidth={3} />
        <ConnectCard
          value="weather"
          label="Weather"
          className="relative grid min-w-0 content-start justify-items-center gap-6 border-0 p-0 outline-teal-600 focus-visible:outline-2 dark:outline-teal-400"
        >
          <legend className="sr-only">Weather</legend>
          {[
            { value: "sun", label: "Sun", emoji: "☀️" },
            { value: "rain", label: "Rain", emoji: "🌧️" },
          ].map((weather) => (
            <ConnectOutput
              key={weather.value}
              value={weather.value}
              label={weather.label}
              kind="weather"
              className="grid size-14 place-items-center rounded-full border-2 border-amber-300 bg-amber-50 text-3xl outline-teal-600 focus-visible:outline-2 focus-visible:outline-offset-4 data-selected:border-teal-600 data-selected:ring-4 data-selected:ring-teal-600/20 dark:border-amber-700 dark:bg-amber-950 dark:outline-teal-400"
            >
              <span aria-hidden="true">{weather.emoji}</span>
            </ConnectOutput>
          ))}
        </ConnectCard>
        <ConnectCard
          value="garden"
          label="Garden"
          className="relative min-w-0 border-0 pt-10 outline-teal-600 focus-visible:outline-2 dark:outline-teal-400"
        >
          <legend className="sr-only">Garden</legend>
          <ConnectInput
            value="flower"
            label="Flower"
            kind="weather"
            className="grid justify-items-center gap-4"
          >
            <ConnectInputTrigger className="grid size-14 place-items-center rounded-2xl border-2 border-teal-300 bg-teal-50 text-3xl outline-teal-600 focus-visible:outline-2 focus-visible:outline-offset-4 data-available:border-teal-600 data-available:ring-4 data-available:ring-teal-600/20 dark:border-teal-700 dark:bg-teal-950 dark:outline-teal-400">
              <span aria-hidden="true">🌻</span>
            </ConnectInputTrigger>
            <details className="w-full min-w-0 text-sm text-zinc-600 dark:text-zinc-400">
              <summary className="min-h-11 cursor-pointer rounded py-3 text-center outline-teal-600 focus-visible:outline-2 dark:outline-teal-400">
                Source
              </summary>
              <ConnectInputSelect className="min-h-11 w-full min-w-0 rounded-lg border border-zinc-200 bg-white px-1 text-base outline-teal-600 focus-visible:outline-2 dark:border-zinc-700 dark:bg-zinc-900 dark:outline-teal-400" />
              <ConnectDisconnect className="mt-2 min-h-11 w-full rounded px-1 text-sm outline-teal-600 focus-visible:outline-2 disabled:opacity-40 dark:outline-teal-400">
                Disconnect
              </ConnectDisconnect>
            </details>
          </ConnectInput>
        </ConnectCard>
      </Connect>
    </div>
  );
}

Shader connections

A compact shader graph. Tap or drag to connect.

Loading example…
connect.shader.tsxtsx
import { useState } from "react";
import {
  ArrowPathIcon,
  ArrowsPointingOutIcon,
  ArrowDownRightIcon,
  Squares2X2Icon,
  EllipsisHorizontalIcon,
  XMarkIcon,
} from "@heroicons/react/24/outline";
import {
  Connect,
  ConnectCard,
  ConnectDisconnect,
  ConnectInput,
  ConnectInputSelect,
  ConnectInputTrigger,
  ConnectLines,
  ConnectOutput,
  Popover,
  PopoverOverlay,
  PopoverTrigger,
  Inventory,
  InventoryItem,
  InventoryMoveHandle,
  InventoryResizeHandle,
  type ConnectConnection,
  type InventoryLayout,
} from "@comp0/react";

const cards = [
  {
    value: "coordinates",
    label: "Texture Coordinate",
    symbol: "",
    outputs: [
      { symbol: "", value: "generated", label: "Generated", kind: "vector" },
      { symbol: "", value: "normal", label: "Normal", kind: "vector" },
      { symbol: "", value: "uv", label: "UV", kind: "vector" },
    ],
    inputs: [],
  },
  {
    value: "noise",
    label: "Noise Texture",
    symbol: "",
    outputs: [
      { symbol: "", value: "color", label: "Color", kind: "color" },
      { symbol: "ƒ", value: "fac", label: "Fac", kind: "value" },
    ],
    inputs: [{ symbol: "", value: "vector", label: "Vector", kind: "vector" }],
  },
  {
    value: "shader",
    label: "Principled BSDF",
    symbol: "",
    outputs: [],
    inputs: [
      { symbol: "", value: "base-color", label: "Base Color", kind: "color" },
      { symbol: "", value: "roughness", label: "Roughness", kind: "value" },
    ],
  },
];

const initialLayout: InventoryLayout = [
  { value: "coordinates", column: 1, row: 2, columnSpan: 3, rowSpan: 9 },
  { value: "noise", column: 5, row: 1, columnSpan: 4, rowSpan: 9 },
  { value: "shader", column: 10, row: 4, columnSpan: 4, rowSpan: 11 },
];

const initialConnections: readonly ConnectConnection[] = [
  { from: "generated", to: "vector" },
  { from: "fac", to: "roughness" },
  { from: "color", to: "base-color" },
];

export function Example() {
  const [view, setView] = useState<"cards" | "canvas">("cards");
  const [connections, setConnections] = useState(initialConnections);
  const [layout, setLayout] = useState(initialLayout);
  const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({});
  const [layoutMessage, setLayoutMessage] = useState("");

  const cardElements = cards.map((card) => {
    const content = (
      <ConnectCard
        key={card.value}
        value={card.value}
        label={card.label}
        tabIndex={view === "canvas" ? -1 : 0}
        className="relative h-full min-w-0 border-0 p-0 outline-teal-600 focus-visible:outline-2 dark:outline-teal-400"
      >
        <legend className="sr-only">{card.label}</legend>
        <div className="mb-3 flex h-11 items-center justify-center" title={card.label}>
          {view === "canvas" ? (
            <InventoryMoveHandle
              title={`Move ${card.label}`}
              className="grid size-11 cursor-grab touch-none place-items-center rounded-lg text-zinc-500 outline-teal-600 focus-visible:outline-2 dark:outline-teal-400"
            >
              <span aria-hidden="true" className="text-2xl">
                {card.symbol}
              </span>
            </InventoryMoveHandle>
          ) : (
            <span aria-hidden="true" className="text-2xl text-zinc-500">
              {card.symbol}
            </span>
          )}
        </div>
        <div className="flex justify-between gap-1">
          {card.inputs.length > 0 && (
            <div className="grid content-start gap-3">
              {card.inputs.map((port) => (
                <ConnectInput
                  key={port.value}
                  value={port.value}
                  label={port.label}
                  kind={port.kind}
                  className="grid justify-items-center"
                >
                  <ConnectInputTrigger
                    value={port.value}
                    title={port.label}
                    className="relative grid size-11 place-items-center rounded-xl border-2 border-teal-400 bg-teal-50 text-xl outline-teal-600 focus-visible:outline-2 data-available:ring-4 data-available:ring-teal-600/20 dark:border-teal-600 dark:bg-teal-950 dark:outline-teal-400"
                  >
                    <span aria-hidden="true">{port.symbol}</span>
                  </ConnectInputTrigger>
                  <Popover>
                    <PopoverTrigger
                      aria-label={`Edit ${card.label}: ${port.label} source`}
                      title={`Edit ${port.label} source`}
                      className="grid size-11 place-items-center rounded-lg text-zinc-500 outline-teal-600 focus-visible:outline-2 dark:outline-teal-400"
                    >
                      <EllipsisHorizontalIcon aria-hidden="true" className="size-5" />
                    </PopoverTrigger>
                    <PopoverOverlay
                      aria-label={`${card.label}: ${port.label} source`}
                      placement="bottom"
                      offset={4}
                      className="w-64 max-w-[calc(100vw-2rem)] rounded-xl border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
                    >
                      <p className="mb-2 text-sm font-medium">{port.label}</p>
                      <ConnectInputSelect className="min-h-11 w-full min-w-0 rounded-lg border border-zinc-200 bg-white px-2 text-base outline-teal-600 focus-visible:outline-2 dark:border-zinc-700 dark:bg-zinc-900 dark:outline-teal-400" />
                      <ConnectDisconnect
                        title="Disconnect"
                        className="mt-2 grid size-11 place-items-center rounded-lg outline-teal-600 focus-visible:outline-2 disabled:opacity-40 dark:outline-teal-400"
                      >
                        <XMarkIcon aria-hidden="true" className="size-5" />
                      </ConnectDisconnect>
                    </PopoverOverlay>
                  </Popover>
                </ConnectInput>
              ))}
            </div>
          )}
          {card.outputs.length > 0 && (
            <div className="ml-auto grid content-start gap-3">
              {card.outputs.map((port) => (
                <ConnectOutput
                  key={port.value}
                  value={port.value}
                  label={port.label}
                  kind={port.kind}
                  title={port.label}
                  className="relative grid size-11 place-items-center rounded-full border-2 border-amber-400 bg-amber-50 text-xl outline-teal-600 focus-visible:outline-2 data-selected:ring-4 data-selected:ring-teal-600/20 dark:border-amber-600 dark:bg-amber-950 dark:outline-teal-400"
                >
                  <span aria-hidden="true">{port.symbol}</span>
                </ConnectOutput>
              ))}
            </div>
          )}
        </div>
        {view === "canvas" && (
          <InventoryResizeHandle
            title={`Resize ${card.label}`}
            className="mt-3 ml-auto grid size-11 touch-none place-items-center rounded-lg text-zinc-500 outline-teal-600 focus-visible:outline-2 dark:outline-teal-400"
          >
            <ArrowDownRightIcon aria-hidden="true" className="size-5" />
          </InventoryResizeHandle>
        )}
      </ConnectCard>
    );
    if (view === "canvas")
      return (
        <InventoryItem
          key={card.value}
          value={card.value}
          textValue={card.label}
          className="relative min-w-0 outline-teal-600 focus-visible:outline-2 dark:outline-teal-400"
        >
          {content}
        </InventoryItem>
      );
    return content;
  });
  let board = (
    <div className="grid grid-cols-[2.75rem_minmax(5.75rem,1fr)_2.75rem] gap-5">{cardElements}</div>
  );
  if (view === "canvas")
    board = (
      <Inventory
        aria-label="Material cards"
        columns={14}
        rows={18}
        value={layout}
        onChange={(next) => {
          setLayout(next);
          setDrafts({});
        }}
        canChange={(next) =>
          next.every((placement) => {
            const minimum = initialLayout.find((entry) => entry.value === placement.value)!;
            return (
              placement.columnSpan >= minimum.columnSpan && placement.rowSpan >= minimum.rowSpan
            );
          })
        }
        className="h-[32rem] list-none gap-2"
      >
        {cardElements}
      </Inventory>
    );

  return (
    <div>
      <div className="mb-4 flex items-start justify-between gap-4">
        <div>
          <h2 className="text-sm font-semibold">Shader</h2>
          <p className="mt-1 max-w-xl text-sm/5 text-zinc-600 dark:text-zinc-400">Tap ○ → ▢.</p>
        </div>
        <button
          type="button"
          aria-label="Reset"
          title="Reset"
          className="grid size-11 place-items-center shrink-0 rounded-lg border border-zinc-200 px-3 py-2 text-sm outline-teal-600 focus-visible:outline-2 dark:border-zinc-700 dark:outline-teal-400"
          onClick={() => {
            setLayout(initialLayout);
            setDrafts({});
            setConnections(initialConnections);
            setLayoutMessage("Layout and connections reset.");
          }}
        >
          <ArrowPathIcon aria-hidden="true" className="size-5" />
        </button>
      </div>
      <fieldset className="mb-3 flex flex-wrap gap-2" aria-label="Connection view">
        {(["cards", "canvas"] as const).map((choice) => (
          <button
            key={choice}
            type="button"
            aria-label={choice === "cards" ? "Cards" : "Canvas"}
            title={choice === "cards" ? "Cards" : "Canvas"}
            aria-pressed={view === choice}
            onClick={() => setView(choice)}
            className="grid size-11 place-items-center rounded-lg border border-zinc-200 text-sm outline-teal-600 aria-pressed:border-teal-600 aria-pressed:bg-teal-50 focus-visible:outline-2 dark:border-zinc-700 dark:outline-teal-400 dark:aria-pressed:bg-teal-950"
          >
            {choice === "cards" ? (
              <Squares2X2Icon aria-hidden="true" className="size-5" />
            ) : (
              <ArrowsPointingOutIcon aria-hidden="true" className="size-5" />
            )}
          </button>
        ))}
      </fieldset>
      <div className="overflow-x-auto">
        <Connect
          aria-label="Procedural bronze connections"
          value={connections}
          onChange={setConnections}
          className={`relative py-2 ${view === "canvas" ? "min-w-[32rem]" : ""}`}
        >
          <ConnectLines className="text-teal-600 dark:text-teal-400" />
          {board}
        </Connect>
      </div>
      {view === "canvas" && (
        <details className="mt-4 text-sm text-zinc-600 dark:text-zinc-400">
          <summary className="w-fit cursor-pointer rounded py-2 font-medium outline-teal-600 focus-visible:outline-2 dark:outline-teal-400">
            Layout
          </summary>
          <div className="mt-3 grid gap-3">
            {cards.map((card) => {
              const entry = layout.find((placement) => placement.value === card.value)!;
              const minimum = initialLayout.find((placement) => placement.value === card.value)!;
              return (
                <form
                  key={card.value}
                  className="flex flex-wrap items-end gap-2"
                  onSubmit={(event) => {
                    event.preventDefault();
                    const fields = new FormData(event.currentTarget);
                    const next = {
                      value: card.value,
                      column: Number(fields.get("column")),
                      row: Number(fields.get("row")),
                      columnSpan: Number(fields.get("columnSpan")),
                      rowSpan: Number(fields.get("rowSpan")),
                    };
                    const outside =
                      next.column + next.columnSpan > 15 || next.row + next.rowSpan > 19;
                    const overlaps = layout.some(
                      (placement) =>
                        placement.value !== card.value &&
                        next.column < placement.column + placement.columnSpan &&
                        next.column + next.columnSpan > placement.column &&
                        next.row < placement.row + placement.rowSpan &&
                        next.row + next.rowSpan > placement.row,
                    );
                    if (outside || overlaps) {
                      setLayoutMessage(
                        `${card.label} must fit inside the board without overlapping another card.`,
                      );
                      return;
                    }
                    setLayout(
                      layout.map((placement) =>
                        placement.value === card.value ? next : placement,
                      ),
                    );
                    setDrafts({ ...drafts, [card.value]: {} });
                    setLayoutMessage(
                      `${card.label} moved to column ${next.column}, row ${next.row}, spanning ${next.columnSpan} columns and ${next.rowSpan} rows.`,
                    );
                  }}
                >
                  <span className="w-36 self-center font-medium">{card.label}</span>
                  {(
                    [
                      { name: "column", label: "Column", min: 1, max: 14 },
                      { name: "row", label: "Row", min: 1, max: 18 },
                      { name: "columnSpan", label: "Width", min: minimum.columnSpan, max: 14 },
                      { name: "rowSpan", label: "Height", min: minimum.rowSpan, max: 18 },
                    ] as const
                  ).map((field) => (
                    <label key={field.name} className="grid gap-1">
                      {field.label}
                      <input
                        aria-label={`${card.label} ${field.label.toLowerCase()}`}
                        name={field.name}
                        type="number"
                        min={field.min}
                        max={field.max}
                        required
                        value={drafts[card.value]?.[field.name] ?? String(entry[field.name])}
                        onChange={(event) =>
                          setDrafts({
                            ...drafts,
                            [card.value]: {
                              ...drafts[card.value],
                              [field.name]: event.currentTarget.value,
                            },
                          })
                        }
                        className="min-h-11 w-16 rounded border border-zinc-200 bg-white px-2 text-base outline-teal-600 focus-visible:outline-2 dark:border-zinc-700 dark:bg-zinc-900 dark:outline-teal-400"
                      />
                    </label>
                  ))}
                  <button
                    type="submit"
                    className="min-h-11 rounded border border-zinc-200 px-3 outline-teal-600 focus-visible:outline-2 dark:border-zinc-700 dark:outline-teal-400"
                  >
                    Apply {card.label}
                  </button>
                </form>
              );
            })}
          </div>
        </details>
      )}
      <output aria-live="polite" className="mt-3 text-sm/5 text-zinc-600 dark:text-zinc-400">
        {layoutMessage}
      </output>
    </div>
  );
}

Anatomy

Dashed frames are invisible state providers; shaded shapes own real DOM. Numbered pins match the list below.

A wireframe sketch of the assembled component. Each numbered marker matches a part in the list that follows.

  1. Connect

    Native group owning connection state and polite announcements. Owns a DOM element.

  2. ConnectLinesOptional

    Optional, aria-hidden SVG wires that follow layout, size, and scrolling changes. Owns a DOM element.

  3. ConnectCard

    Labelled native fieldset containing a card's ports and other controls. Owns a DOM element.

  4. ConnectOutput

    Button that selects a source or starts a drag. Owns a DOM element.

  5. ConnectInput

    Input context and wire endpoint around its controls. Owns a DOM element.

  6. ConnectInputTrigger

    Button that accepts a selected compatible output. Owns a DOM element.

  7. ConnectInputSelect

    Native source selector with compatible outputs and a Not connected option. Owns a DOM element.

  8. ConnectDisconnect

    Button that removes this input's current source. Owns a DOM element.

Step by step

  1. 1

    Add the main part

    Wrap labelled ConnectCard elements in Connect and assign a unique value to every input and every output.

  2. 2

    Add the supporting parts

    Add ConnectOutput buttons and ConnectInput groups with matching kind strings. Each input contains a ConnectInputTrigger, ConnectInputSelect, and ConnectDisconnect.

  3. 3

    Make the behavior clear

    Add ConnectLines for decorative wires and make native source selectors easy to find. Let your own layout or Inventory position the cards; Connect never opens a dialog or moves focus on mount.

    Exampletsx
    <Connect aria-label="Connections">
      <ConnectCard value="source" label="Palette">
        <ConnectOutput value="color" label="Color" kind="color">
          Color
        </ConnectOutput>
      </ConnectCard>
      <ConnectCard value="target" label="Material">
        <ConnectInput value="surface" label="Surface" kind="color">
          <ConnectInputTrigger>Surface</ConnectInputTrigger>
          <ConnectInputSelect />
          <ConnectDisconnect>Disconnect</ConnectDisconnect>
        </ConnectInput>
      </ConnectCard>
    </Connect>;

Keyboard

Visits cards and their native controls in DOM order.
Space
Selects an output or connects the selected output to a compatible input. · on port buttons
Esc
Cancels connection selection and returns focus to its output.
Focuses the previous or next card. · on ConnectCard itself
HomeEnd
Focuses the first or last card. · on ConnectCard itself

Forms and accessibility

No implicit form serialization. Persist connections through value/onChange; ordinary controls inside cards retain their native behavior.

Accessibility checklist

  • Use visible card and port labels. ConnectCard renders a fieldset, not a dialog, and never takes focus on mount. Labels and kind strings supply contextual accessible names.
  • Always include ConnectInputSelect and ConnectDisconnect for each input. Their native controls expose current sources and support editing without dragging or interpreting the wires.
  • Click or tap an output, then a matching input. Enter and Space activate the same buttons; Escape cancels and returns focus to the selected output. Dragging is an additional path.
  • ConnectLines is decorative and aria-hidden. Port descriptions persistently name connected endpoints, while a polite live region announces edits. Do not rely on wire color to explain types or relationships.
  • Tab follows the DOM and retains native control behavior. Up, Down, Home, and End navigate cards only when the card itself has focus. When composing with Inventory, give ConnectCard tabIndex={-1} and let Inventory own spatial navigation.
  • Inputs accept one output of the same kind from a different card; outputs may feed several inputs. Cycles are allowed. Applications that require an acyclic workflow must veto invalid proposals through controlled value/onChange.
  • Keep cards mounted regardless of viewport visibility. Offer a stacked Cards view for small screens and a scrollable Canvas for spatial editing. Give touch controls at least 44 pixels of space and apply touch-action: none to move and resize grips so browser scrolling does not cancel gestures. Provide visible position and size controls as an alternative to dragging.

API reference

Importtsx
import { Connect, ConnectCard, ConnectOutput, ConnectInput, ConnectInputTrigger, ConnectInputSelect, ConnectDisconnect, ConnectLines } from "@comp0/react";

Connect

DOM element

Native group owning connection state and polite announcements.

PropTypeDescription
valuereadonly ConnectConnection[]Controlled connections, each { from, to }. One source per input; outputs may feed multiple inputs.
defaultValuereadonly ConnectConnection[]Initial uncontrolled connections.
onChange(connections: readonly ConnectConnection[]) => voidReceives the complete proposed connections. Layout and application rules remain with the caller.
aria-labelstringNames this set of connections.

ConnectLines

OptionalDOM element

Optional, aria-hidden SVG wires that follow layout, size, and scrolling changes.

ConnectCard

DOM element

Labelled native fieldset containing a card's ports and other controls.

PropTypeDescription
valuestringUnique card identity; ports on the same card cannot connect.
labelstringCard name included in port and source labels.
tabIndexnumberDefaults to 0 for card navigation. Use -1 when a surrounding composite owns navigation.

ConnectOutput

DOM element

Button that selects a source or starts a drag.

PropTypeDescription
valuestringNonempty identity unique among outputs.
labelstringHuman-readable output name.
kindstringHuman-readable type, matched exactly against an input's kind.
disabledbooleanPrevents choosing this output and removes it from available sources.

ConnectInput

DOM element

Input context and wire endpoint around its controls.

PropTypeDescription
valuestringNonempty identity unique among inputs.
labelstringHuman-readable input name.
kindstringAccepted output type.
disabledbooleanDisables the input's controls and rejects connections.

ConnectInputTrigger

DOM element

Button that accepts a selected compatible output.

ConnectInputSelect

DOM element

Native source selector with compatible outputs and a Not connected option.

ConnectDisconnect

DOM element

Button that removes this input's current source.

Style hooks

Attributes that appear while a state is true. Target them with Tailwind data variants such as data-open:bg-zinc-100, or with any CSS selector.

ConnectOutput

Style hookMeaning
[data-selected]This output is waiting for an input.
[data-connected]This port has a connection.

ConnectInput

Style hookMeaning
[data-connected]This port has a connection.

ConnectInputTrigger

Style hookMeaning
[data-available]This input accepts the selected output.
[data-connected]This port has a connection.

Keep exploring