All docs

Docs

Building a page with the AppFlow shell and components

How any flow page should use this library: the shell, the main CTA, inputs and errors. This is the library's side of the contract. App logic — validation, state, navigation, API calls — belongs to the consuming app (st-appflow keeps its own docs/step-authoring.md for that).

Each rule says how it's enforced today. "Not yet" means it's written down but nothing checks it; those are candidates for the design-system rules ticket. Linked from AGENTS.md.

1. Compose pages from library components

  • Build every visible part of a page from spring-design-library-react components, with AppFlowPageTitle as the page title.
  • Don't hand-build inputs, buttons, selectors or modals in a page.
  • If a page needs something the library doesn't have, that's a library change (a new component or a new prop), made here — not something built inside the page.
  • Radio choices are always SpringRadioGroup (built from the SpringRadio atom). Don't map SpringRadios into your own group or draw radios in a page; SpringRadio on its own is for library components such as PlanCard.

Enforced by: not yet.

2. The shell is fixed; only the page body changes

AppFlowShell (via AppFlowShellWithParams) renders the same frame on every page: header, optional progress bar, the page body, the main CTA (when the page has one), and the legal footer (desktop). A page renders only its body.

  • Don't render a header, progress bar or footer inside a page.
  • Progress, caption, logoHref and legalLinks are shell props, not page content. progress={null} hides the bar.

Enforced by: AppFlowShell.test.tsx (the shell always renders these regions).

3. A page's main CTA is registered with the shell

Not every page has one. Pages where the customer moves on (most steps) register one main CTA; end pages with nothing to continue to — e.g. application complete, sorry — register none.

useAppFlowCta({
  label: 'Continue',
  onClick: onContinue,
  loading: submitting,
  disabled: !canContinue,
  error: submitError, // shown above the button on phones
})
  • When a page has a main CTA, register it with useAppFlowCta; the shell draws it (inline on desktop, floating bar on phones). Don't render your own Continue button.
  • A page without one registers nothing (or passes null); the shell then draws no CTA.

Known gap: the library's own reference screens mix two patterns — about 27 screen files use the in-body AppFlowInlineCta, 13 use useAppFlowCta. Which one reference screens should use is an open decision for the UX engineer.

Enforced by: AppFlowShell.test.tsx checks that a registered CTA renders and fires, and that no CTA renders when a page registers none.

4. Inputs: give them their value; don't write to the input directly

Inputs such as TextField keep their own copy of the value and render from it. Use either:

  • controlled: value + onChange (the page owns the value), or
  • uncontrolled: defaultValue (the field owns it).

Don't set the input element's value directly (through a ref, or a form library's register, which does this): the field's next render overwrites it with its own value, so stored answers and programmatic fills disappear. With react-hook-form, bind fields through Controller (value + onChange).

Enforced by: not yet.

5. Errors: field errors on the field, form errors on the CTA

  • Field error (this input is wrong): the input's own status="error" and errorMessage. Supported by TextField, TileSelector, SpringRadioGroup, DropdownField, PhoneNumberField, OtpField, SplitPillInput, LoanAmountSlider and FileUploader.
  • Form error (the submit failed, or a rule across fields): the CTA's error, which the shell shows above the button on phones. On desktop the page shows it in its body (st-appflow uses its own StepFormError for that).
  • Don't hand-build error text or icons; use the components' error props or the library Message.

Enforced by: not yet.

6. Plan cards: pick by what the customer does with them

The customer…UsePhones
chooses a Foundation plan with a radioPlanCard (mode="select", the default)PlanCarousel, cards with isMobile
chooses it with the card's own button ("Get Gold")PlanCard mode="action" + actionPlanCarousel, cards with isMobile
only compares plans, nothing to choose herePlanCard mode="readOnly"Carousel + CarouselTrack + CarouselIndicator, cards with isMobile
sees the plan they already chosePlanSummaryCard—
sees a personal-loan offer or picks an SPL planSpringLoanCard (layout)—

PlanCarousel is for choosing (its pager is the radio group); Carousel is for showing. Rows: answer makes a row expand; highlight marks a plan's selling point (accent when included). Figma's plan-card / foundation and plan-card / feature-row use the same property names (mode, isMobile, highlight, expandable).

When the request doesn't say (agents: ask, don't guess):

  1. Can the customer choose a plan here — with a radio, with a button, or not at all? → mode.
  2. Phone layout? → the carousel in the table.
  3. Should any row open with details (answer) or stand out (highlight)?

If the request describes the elements instead ("two cards, a Get Gold button, the Bloom row stands out"), map them to props and confirm the result in one line rather than asking again. If the design already makes it obvious, use it and say which mode you picked.

Enforced by: types — mode="action" without action doesn't compile. Which card a page uses: not yet.

7. Layout (rule candidate, not built yet)

Every page body should share one column width and spacing. Today each page sets its own classes, and the library has no shared page-body component or layout tokens, so pages drift (different widths and mobile gaps). Planned: a shared page-body component with fixed width options and layout tokens.

Enforced by: not yet.