Headless UI (Beta)
React components for rendering pipe0 forms and catalog pickers.
@pipe0/react ships two headless libraries: form rendering and catalog.
Both follow the same pattern — a root component + compound parts + a
render={(props, state) => ReactNode} escape hatch.
Install
pnpm i @pipe0/reactImport the stylesheet once at the app root:
import "@pipe0/react/styles";Form rendering
Renders the configuration form for a single pipe or search.
Zero-config
Pass pipeId, publicKey, and onSubmit. The form renders all sections and a
submit button by default.
import { PipeForm } from "@pipe0/react";
<PipeForm
pipeId="json:extract@1"
publicKey="pk_abc..."
onSubmit={(payload) => console.log(payload)}
/>;<SearchForm searchId={…} publicKey={…} onSubmit={…} /> is identical, paired
with useSearchForm.
Compound parts
Drive layout yourself with usePipeForm. With no children, <PipeForm> falls
back to <PipeFormContent /> + <PipeFormSubmitButton />.
| Part | Purpose |
|---|---|
PipeFormHeader / PipeFormTitle | Top of the form. |
PipeFormContent | Iterates sections. |
PipeFormSection (section prop) | One section wrapper. |
PipeFormGroup (group prop) | One group inside a section. |
PipeFormField (field prop) | Renders the right adapter for the field kind. |
PipeFormSubmitButton | Submit button bound to the form state. |
PipeFormFooter | Wraps the submit button area. |
PipeFormErrors | Top-level submit errors. |
import {
PipeForm,
PipeFormContent,
PipeFormSection,
PipeFormGroup,
PipeFormSubmitButton,
usePipeForm,
} from "@pipe0/react";
function MyForm({ publicKey, handle }) {
const pipeForm = usePipeForm({ pipeId: "json:extract@1", publicKey });
return (
<PipeForm context={pipeForm} onSubmit={handle}>
<PipeFormContent>
{pipeForm.sections.map((s) => (
<PipeFormSection key={s.key} section={s}>
{s.groups.map((g) => (
<PipeFormGroup key={g.key} group={g} />
))}
</PipeFormSection>
))}
</PipeFormContent>
<PipeFormSubmitButton />
</PipeForm>
);
}Render prop
Every compound part accepts render={(props, state) => ReactNode}. Spread
{...props} to keep refs, handlers, className, and data-* wired; use
state to rebuild the markup.
<PipeFormContent
render={(props, { sections }) => (
<div {...props} className="grid gap-6">
{sections.map((section) => (
<PipeFormSection key={section.key} section={section} />
))}
</div>
)}
/>State by part:
| Part | State |
|---|---|
PipeFormContent | { sections, fieldPaths, hasFieldLoaderError, isFieldLoaderLoading, form } |
PipeFormSection | { groups, hasErrors } |
PipeFormGroup | { fields, expanded } |
PipeFormField | adapter resolved from field.kind |
Styling and field adapters
Wrap a subtree with FormProvider to inject classNames (slot map) and
adapters (per-kind component map). Nested providers shallow-merge.
import { FormProvider, PipeForm } from "@pipe0/react";
import type { FieldAdapterMap, FormClassNames } from "@pipe0/react";
const classNames: FormClassNames = {
label: "text-sm font-medium",
field: "space-y-1",
error: "text-red-600",
};
const adapters: FieldAdapterMap = {
text_input: ({ field }) => (
<input
value={field.value ?? ""}
onChange={(e) => field.setValue(e.target.value)}
disabled={field.disabled}
/>
),
};
<FormProvider classNames={classNames} adapters={adapters}>
<PipeForm pipeId="json:extract@1" publicKey={pk} onSubmit={handle} />
</FormProvider>;Adapter kinds include: text_input, textarea_input, select_input,
multi_select_input, boolean_input, int_input, number_input,
range_input, date_range_input, connector_input, providers_input,
prompt_input, template_input, json_schema_input, pipes_run_if_input.
Only override the kinds you care about — the rest fall through to defaults.
Common class slots: form, section, sectionLabel, sectionDescription,
group, groupHeader, groupContent, field, label, description,
error, plus per-kind slots (textInput, selectInput, …).
Visibility
Control which sections, groups, and fields render. null hides.
const pipeForm = usePipeForm({
pipeId,
publicKey,
sectionMap: { connector: null },
groupMap: { advanced: { label: "Advanced", defaultExpand: false } },
pathMap: { "input.raw": null },
});pipeForm.sections already reflects these maps.
Resolvers
Fetch user-specific options (connections, audiences, locations, …).
import type { FormResolvers } from "@pipe0/base";
const resolvers: FormResolvers = {
getConnections: async () => {
const res = await fetch("/api/pipe0/connections");
return res.json(); // [{ public_id, provider }]
},
getFieldContext: async ({ fieldPath, query, payload }) => {
const res = await fetch("/api/pipe0/context", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fieldPath, query, payload }),
});
return res.json();
},
};<PipeForm
pipeId="email:send:resend@1"
publicKey="pk_abc..."
resolvers={resolvers}
onSubmit={handle}
/>Resolvers must proxy through your backend.
publicKey identifies your project but does not authorize resolver
requests. The underlying endpoints require your server-side API key and need
to know which of your users is asking. Your resolver hits your backend, which
authenticates the user and forwards the call to pipe0.
export async function POST(req: Request) {
const user = await requireUser(req); // your auth
const body = await req.json();
const res = await fetch("https://api.pipe0.com/v1/pipes/field-context", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PIPE0_API_KEY!}`,
"content-type": "application/json",
},
body: JSON.stringify({
field_path: body.fieldPath,
query: body.query,
payload: body.payload,
}),
});
return new Response(await res.text(), { status: res.status });
}Catalog
The picker users navigate to choose a pipe or search. Three variants share the
same shape: PipeCatalog, SearchCatalog, SearchesCatalog.
Zero-config
import { PipeCatalog } from "@pipe0/react";
<PipeCatalog onSelectPipe={(pipeId) => console.log(pipeId)} />;Optional props: filter (whitelist/blacklist of pipes or providers — memoize
it), initialColumnFilters, classNames, components.
Compound parts
Drive the catalog with usePipeCatalogTable. Without children, the root renders
search filter → category filter → column filters → active filters → list →
empty by default.
| Part | Purpose |
|---|---|
PipeCatalogSearchFilter | Free-text search input. |
PipeCatalogCategoryFilter | Category tabs. |
PipeCatalogColumnFilters | All column filters in one row. |
PipeCatalogInputFieldFilter | Typed wrapper (pipe-only). |
PipeCatalogOutputFieldFilter | Typed wrapper. |
PipeCatalogProviderFilter | Typed wrapper. |
PipeCatalogTagFilter | Typed wrapper. |
PipeCatalogActiveFilters | Chips for currently-applied filters. |
PipeCatalogList | The list/grid of cards. |
PipeCatalogCard | Per-row card slot. |
PipeCatalogEmpty | Empty-state slot. |
import {
PipeCatalog,
PipeCatalogSearchFilter,
PipeCatalogCategoryFilter,
PipeCatalogProviderFilter,
PipeCatalogActiveFilters,
PipeCatalogList,
PipeCatalogEmpty,
usePipeCatalogTable,
} from "@pipe0/react";
function PipePicker({ onSelect }) {
const ctx = usePipeCatalogTable();
return (
<PipeCatalog context={ctx} onSelectPipe={onSelect}>
<PipeCatalogSearchFilter />
<PipeCatalogCategoryFilter />
<PipeCatalogProviderFilter />
<PipeCatalogActiveFilters />
<PipeCatalogList />
<PipeCatalogEmpty />
</PipeCatalog>
);
}Render prop
Same (props, state) contract as forms — useful when you want to swap a
filter UI without rebuilding the table logic.
<PipeCatalogProviderFilter
render={(props, { value, setValue, options }) => (
<MyProviderPicker
{...props}
selected={value}
onChange={setValue}
options={options}
/>
)}
/>State by part:
| Part | State |
|---|---|
*CatalogSearchFilter | { value, setValue, isActive } |
*CatalogCategoryFilter | { value, setValue, options, counts, totalCount, isActive } |
*CatalogColumnFilter + typed wrappers | { value, setValue, options, isActive } |
*CatalogList | { cards, isEmpty } |
*CatalogActiveFilters | { activeFilters, isEmpty } (each: { id, value, label, remove }) |
*CatalogCard | { selected, expanded, setExpanded } |
Styling and component swaps
CatalogProvider injects classNames and components (Card,
EmptyState, Badge) for a subtree. Nested providers shallow-merge.
import { CatalogProvider, PipeCatalog } from "@pipe0/react";
<CatalogProvider
classNames={{ filterSelect: "border-zinc-300", card: "rounded-xl" }}
components={{ EmptyState: () => <p>No matches.</p> }}
>
<PipeCatalog onSelectPipe={handle} />
</CatalogProvider>;Common class slots: root, searchInput, filters, filterSelect,
categoryFilter, categoryButton, categoryButtonActive, activeFilters,
activeFilterPill, card, cardHeader, cardLogo, cardLabel,
cardDescription, cardBadges, badge, emptyState.
Custom card
Compose cards from primitives and self-binding widgets. Read row data with
usePipeCatalogCard().
import {
CatalogCard,
CatalogCardHeader,
CatalogCardTitle,
CatalogCardDescription,
CatalogCardBadges,
CatalogCopyId,
CatalogCreditBadge,
CatalogProviderAvatars,
PipeCatalogCard,
usePipeCatalogCard,
} from "@pipe0/react";
function MyPipeCard() {
const card = usePipeCatalogCard();
return (
<CatalogCard>
<CatalogCardHeader>
<CatalogProviderAvatars />
<CatalogCardTitle>{card.label}</CatalogCardTitle>
<CatalogCopyId />
</CatalogCardHeader>
<CatalogCardDescription>{card.description}</CatalogCardDescription>
<CatalogCardBadges>
<CatalogCreditBadge />
</CatalogCardBadges>
</CatalogCard>
);
}
<PipeCatalogCard render={() => <MyPipeCard />} />;Card data is pre-derived: label, description, providers,
startingCostPerProvider, startingCreditAmount, defaultOutputFields,
docPath, plus entry / latestEntry escape hatches. Don't re-derive these
from raw catalog entries.
Derived widgets that read from card context: CatalogCopyId,
CatalogCreditBadge, CatalogDocsBadge, CatalogFieldBadge,
CatalogProviderAvatars.
Search variants
SearchCatalog and SearchesCatalog mirror PipeCatalog exactly — paired
with useSearchCatalogTable / useSearchesCatalogTable, using onSelectSearch
/ onSelectSearches, and reading rows via useSearchCatalogCard() /
useSearchesCatalogCard(). Neither exposes InputFieldFilter (searches have
no input fields).