Skip to content

Form builder

Describe a form in code. The builder outputs a valid FormDefinition for the API.

ts
import { FormFlowClient, form } from "./sdk/typescript";

const def = form("Customer feedback")
  .welcome({ title: "2 minutes, promise" })
  .nps("nps")
  .longText("why", "What is the main reason for your score?")
  .choice("channel", "How did you find us?", ["Search", "Friend", "Social"], { multiple: true })
  .email("email", "Email (optional)")
  .theme({ colors: { primary: "#4f46e5" }, alignment: "center", radius: 16 })
  .settings({ presentation: "conversational", notifications: { ownerEmails: ["team@example.com"] } })
  .ending("Thank you!")
  .build();

const ff = new FormFlowClient({ apiKey: process.env.FORMFLOW_API_KEY!, baseUrl: "https://YOUR_HOST" });
const f = await ff.forms.create({ name: "Feedback", definition: def });
await ff.forms.publish(f.id);

Helpers

shortText, longText, email, phone, url, number, date, yesNo, choice (single, multiple, dropdown), rating, nps, csat, fileUpload, consent, statement, hidden, or field(type, id, label, props) for any type in the field catalog. startSection groups following fields into pages; logic adds rules; showWhen sets simple visibility.

Validation

The builder rejects invalid or duplicate ids immediately. References, cycles and reachability are checked by the form engine when you create or publish; a failed publish returns the issue list.

Design controls

Beyond colors and fonts, the definition carries optional design controls. Each is optional and unset means the renderer default, so send only what you want to change.

  • Theme: headingWeight (300-900), colors.heading, radii (button, choice, media, card, each falling back to radius), surface (kind none|screen|section|field, shadow, color), choiceStyle (outline|raised|solid|radio|tile), progress (style bar|segmented|hairline|none, thickness, color, track), numberStyle, requiredStyle, statementStyle, errorStyle, buttonWidth, logoSize, logoPlacement (adds top_right), and background.fit|position|imageOpacity. background.overlayOpacity: 0 is honored.
  • Questions: choiceLayout (stack|columns|hug|flow), columns, hideKeys, width (full|half|third|quarter|two-thirds|three-quarters, a 12-column grid that stacks on phones), placeholders, Choice.response; image choices add imageAspect and cardStyle; star ratings add icon (star|heart|thumb|circle|person|face) and showNumbers; products add layout. Media adds frame, fit, radius, ratio, mobileHeight, scrim, textTone.
  • Sections: layout (default|group), collapsible, defaultOpen, tint, stickyHeader, card (fields in one card), columns (1-4), align (left|center|right). Welcome: hideTitle, inlineFirst. Endings: nextEndingId.
  • Settings: navStyle (chevrons|pager|link|arrow|none), showTitle, showStepLabel, showEnterHint, showOkIcon, timeHint, confirmAnswers, sidePanel (with inset and radius for art set in from the edges), header, siteHeader and siteFooter (logoUrl, logoAlt, text, up to 8 links of label and url, align; shown on every screen).
json
{
  "operations": [
    { "op": "set_theme", "theme": {
        "colors": { "primary": "#0f766e", "background": "#fafaf9", "text": "#1c1917" },
        "headingWeight": 500,
        "surface": { "kind": "none" },
        "choiceStyle": "tile",
        "progress": { "style": "hairline", "thickness": 2 },
        "radii": { "button": 999, "choice": 12 },
        "buttonWidth": "full",
        "logoPlacement": "top_right", "logoSize": 28
    } },
    { "op": "update_field", "fieldId": "plan", "patch": {
        "choiceLayout": "columns", "columns": 2, "hideKeys": true,
        "choices": [
          { "id": "solo", "label": "Just me", "response": "Great, you can start free." },
          { "id": "team", "label": "My team" }
        ] } },
    { "op": "update_field", "fieldId": "first", "patch": { "width": "half" } },
    { "op": "update_section", "sectionId": "main", "patch": { "layout": "group" } },
    { "op": "set_settings", "settings": { "navStyle": "pager", "showEnterHint": false, "confirmAnswers": true,
        "sidePanel": { "url": "https://example.com/side.jpg", "side": "left", "ratio": 40, "fit": "cover", "mobile": "banner" } } }
  ]
}

Part styles

theme.parts restyles one named part of the form at a time, so a competitor look is settings rather than code. Parts: page, stage, screen, card, fieldCard, header, title, description, sectionTitle, sectionDescription, questionTitle, questionDescription, number, required, input, placeholder, select, choice, choiceSelected, choiceLabel, choiceIndicator, scaleCell, scaleCellSelected, link, badge, button, buttonSecondary, progress, progressTrack, error, endingTitle, endingDescription, siteHeader, siteFooter, brand, logo, divider, image. Part styles win over presets and tokens.

  • Colour and background: color, background (colour or plain gradient, repeating ones too), backgroundLayers (up to 3 gradients), backgroundImage (url https, an app path or a base64 image; size, position, repeat) and textFill (gradient text with a colour fallback).
  • Borders and outline: borderWidth, borderStyle (solid|dashed|dotted|double|groove|ridge|inset|outset|none) and borderColor, each also per side (borderTopWidth, borderTopStyle, borderTopColor...), outlineWidth, outlineStyle, outlineColor, outlineOffset; radius and per-corner radii in px (0-999) or a percentage string up to 50%.
  • Spacing and size (px unless noted): paddingX, paddingY, paddingTop/paddingRight/paddingBottom/paddingLeft (0-200, a side wins), marginTop/marginBottom/marginLeft/marginRight (-200 to 200), width, maxWidth, minWidth, height, maxHeight (px or a percentage string), minHeight, aspectRatio, objectFit (image part), display (block|inline-block|flex|none), flexDirection, alignItems, justifyContent (container parts), gap.
  • Text: fontFamily (Google families load with fontSource google, customFonts families always), fontSize, fontWeight, fontStyle (incl. oblique), fontVariant, letterSpacing, wordSpacing, lineHeight (ratio or a px string), textTransform, textAlign (incl. justify), textDecoration (underline|line-through|overline) with textDecorationColor, textDecorationThickness, textUnderlineOffset, whiteSpace, textOverflow, textShadow (up to 2).
  • Effects: shadow (none|sm|md|lg|xl, an object with x, y, blur, spread, color, inset, or a list of up to 4), opacity, backdropBlur, blur, brightness, grayscale, transform (rotate, scale, translateX, translateY, skewX), transition (duration, easing), mixBlendMode.
  • States: hover, active (pressed) and disabled take colours, borders, outline, shadow, transform and opacity; focus takes the same plus ringColor and ringWidth; mobile repeats the base properties for phones. The placeholder part styles input placeholder text.
  • A section or a field takes its own style with the same shape, limited to the parts inside it, and it wins over the theme. A section title placed above its card is reached through section.style.sectionTitle.
json
{
  "operations": [
    { "op": "set_theme", "theme": {
        "colors": { "primary": "#673ab7", "background": "#f0ebf8", "text": "#202124" },
        "parts": {
          "stage": { "maxWidth": 640 },
          "header": { "background": "#ffffff", "borderTopWidth": 10, "borderColor": "#673ab7", "radius": 8, "paddingX": 24, "paddingY": 22 },
          "fieldCard": { "background": "#ffffff", "borderWidth": 1, "borderColor": "#dadce0", "radius": 8, "paddingX": 24, "paddingY": 24, "marginBottom": 12 },
          "input": { "background": "transparent", "borderBottomWidth": 1, "radius": 0, "focus": { "borderColor": "#673ab7" } },
          "button": { "background": "#673ab7", "radius": 4, "hover": { "background": "#5e35b1" } }
        } } },
    { "op": "update_section", "sectionId": "main", "patch": { "columns": 2, "card": true, "style": { "sectionTitle": { "fontSize": 20 } } } },
    { "op": "update_field", "fieldId": "email", "patch": { "width": "two-thirds", "style": { "input": { "borderColor": "#d93025" } } } },
    { "op": "set_settings", "settings": {
        "siteHeader": { "text": "Acme Co", "links": [{ "label": "Help", "url": "https://example.com/help" }] },
        "siteFooter": { "text": "Privacy first", "align": "center" } } }
  ]
}

Field options

  • fill_blank blanks[] (up to 30): each is id, answer, width (2-60 characters), boxWidth (16-800 px, the blank's own width), marginLeft (-100 to 400 px of room before the blank), placeholder (max 100), caption (max 60, a line under the blank, styled by the blankCaption part) and options (1-50 strings; the blank becomes a select). The label holds the blanks as ___; the answer is an object keyed by blank id.
  • fillOrder on a columns choice: across (default, row by row), down (each column top to bottom, as printed surveys do) or packed (down, but each column stacks on its own and an option spanning every column starts a new band).
  • short_text: entry: "comb" draws one box per character with combCount (2-24, default 6); the answer stays one string. fit: "answer" sizes the box to the longest allowed answer. long_text.ruled: { color, lineHeight } (hex colour, 16-60 px) draws lined paper that scrolls with the text.
  • simple: { label, helpText } (max 2000 each), a plain-language twin shown when the respondent turns on simple wording in the comfort panel.
  • confidence: { label, min, max } adds a 0-100 slider under the answer; the value is stored as the answer key fieldId.confidence.
  • sticky (boolean): on page layouts the field stays in view while the rest scrolls; ignored in one-question layouts.
  • Canvas layout: settings.presentation: "canvas" shows questions as cards on a pannable, zoomable 2D board; a field's canvas: { x, y } (px, 0-10000) places its card, otherwise cards are placed automatically. Jump logic is drawn as arrows. Phones use the standard page layout.
json
{ "op": "update_field", "fieldId": "code", "patch": { "entry": "comb", "combCount": 8, "simple": { "label": "Your 8 digit code" }, "confidence": { "label": "How sure?", "min": "Guess", "max": "Certain" } } }

Settings reference

  • settings.anchorLine (0.1-0.9): the active question lands with its top at this fraction of the viewport height when it changes. Default no scrolling.
  • settings.comfort: offer (1-6 unique of text-size, readable-font, contrast, reduce-motion, read-aloud, simple-wording) and position (bottom-left|bottom-right|top-left|top-right); a button opens switches the respondent keeps per browser.
  • settings.answerTrail (top|side): chips of the answered questions, each jumping back to its question.
  • settings.feedback: { haptics, sound } booleans; a short vibration and a soft click on choosing and submitting, off under reduced motion.
  • settings.history (boolean): an Undo button and a list to restore from, up to 50 earlier answer states.
  • settings.constraints (up to 40): { item, edge, to, toEdge, offset } pins an item (a pin or region item id) edge to an edge of title, submit, progress, logo, card, stage, header, actions or field:id[.label|.input]. Edges are left|right|top|bottom|centerX|centerY and must be on the same axis; offset is -1000 to 1000 px. Needs CSS anchor positioning.
  • settings.sentenceMode: { text } (max 4000) turns a page layout into one paragraph; each {fieldId} becomes that field inline (an input, or a select for a choice).
  • settings.actions.extra (up to 4): { id, label, kind, url, style, partStyle } with kind link|reset|print; partStyle is that button's own part style over the actionButton part.
  • Region items (settings.regions): imageUrl draws an image before the text of a badge, link, button or icon; imageAfter puts it after; imageWidth (8-400 px, the height is iconSize); justify (start|center|end) aligns the content across the item width.
  • Parts hero (the header banner or illustration) and blankCaption (the caption under a fill-in blank) take the normal part style properties.

Theme additions

  • theme.progressTint: { from, to, target } (hex colours; target background|accent, default accent) moves the colour from from to to as progress runs 0 to 100%.
  • theme.schedule: { darkFrom, darkTo } (HH:MM, may cross midnight) uses the dark palette in the respondent's local time between the two. No location needed.
  • theme.entrance: title (fade|rise|type|none), choices and button (fade|rise|none), stagger (0-300 ms between choices) and delay (0-1000 ms); all optional, off under reduced motion.
  • theme.density (auto|compact|comfortable): spacing by pace; auto watches the respondent's own timing in the browser and sends nothing.
  • themeRules (form level, up to 20): { when, theme }; when is a logic condition and theme a strict partial limited to colors, background, logoUrl, buttonStyle and parts. Every rule that holds is merged over the base theme in order, later wins.
  • layoutRules (form level, up to 20): { when, layout } where layout is any settings.presentation value; the first rule that holds picks the layout, answers are kept.
  • Part style when: focused, filled, empty, invalid, prefilled, locked, each a part style applied while the field is in that state. Part style at: a list of { min, max, style } (up to 4; px 200-2000, one bound is enough) measured against the form column, not the window; style may not hold when or at.
  • Part style fontVariation: wght 100-1000, wdth 25-200, opsz 6-144, slnt -20 to 0 (variable font axes; a font without an axis ignores it). alignSelf (start|center|end|stretch) places any part across its own row.
json
{ "op": "set_theme", "theme": {
    "progressTint": { "from": "#dc2626", "to": "#16a34a" },
    "schedule": { "darkFrom": "20:00", "darkTo": "06:30" },
    "entrance": { "title": "type", "choices": "rise", "stagger": 60 },
    "density": "auto",
    "parts": { "questionTitle": {
      "when": { "focused": { "fontVariation": { "wght": 700 } } },
      "at": [{ "max": 480, "style": { "fontSize": 20 } }] } } } }

Batch edits

ts
await ff.forms.edit(f.id, () => [
  { op: "add_field", field: { id: "company", type: "company", label: "Company" }, index: 1 },
  { op: "set_theme", theme: { radius: 20 } },
]);