A complete webshop checkout, Shopify-style β
One typed metadata tree, one template component, and the most consequential form on the web. This example rebuilds the structure of a modern one-page checkout, not its branding, and ends with a split-payment finale that no other page here demonstrates.
Years ago, a usability team at a major retailer studied why so many shoppers filled a cart and never bought. The culprit was a single button next to the sign-in form that read "Register". First-time buyers assumed they had to create an account to purchase, so they abandoned. The team replaced it with a "Continue" button and a quiet line of text: you can check out as a guest. Sales rose 45% in the first month, an additional 300 million dollars in the first year. Jared Spool told this story for years and never named the retailer, so neither do we.
The lesson is not about one button. It is that checkout is the highest-stakes form on the web, and small structural choices move real money. Baymard Institute finds the average checkout asks for 11.3 fields across 5.1 steps, while an optimized checkout needs only about 8 fields, and that 17% of shoppers have abandoned a purchase purely because checkout was too long or complicated.
Few companies talk about this more publicly than Shopify. According to a study Shopify commissioned from one of the Big Three consulting firms, its checkout converts about 15% better on average, and up to 36% better, than comparable checkouts, with Shop Pay lifting conversion by up to 50% against guest checkout. Those are vendor-published figures from an unnamed study, so treat them as Shopify's own claim rather than independent fact. Either way, this is the checkout we rebuild below, as the benchmark worth matching.
What you are looking at
Everything in the live form below is rendered by the library from one metadata tree through one neutrally styled template. We rebuild the checkout's structure, not Shopify's visual design or logo. Payment inputs are inert demo fields, called out again next to them below.
The checkout, live β
The first decision is a choice: check out with an express wallet, or enter details manually. Picking a branch is all it takes for the engine to mount that branch and relax validation on the other. From there the form walks through contact details, a delivery address with per-country postal validation, an optional business and VAT section, shipping, payment, and an optional gift message, with a sticky order summary reacting live on the right.
This checkout intentionally stays one page, matching the benchmark it rebuilds. For a page that validates one step at a time with validateSection() instead, see the Wizard and Client Onboarding Wizard examples.
Ordering is UI-level, not XSD
Open "Split your payment across multiple methods" above to see the finale: a repeatable, ordered list of payment sources where reordering redistributes the remaining amount live. The display order and the per-row order value it writes are library behavior layered on the shape; they have no equivalent in <xs:choice> occurrence semantics.
One metadata tree, one template β
Everything above is declarative. The engine walks the tree, classifies each node by its shape, and renders it through the neutral template below. The rest of this page walks through the metadata behind it, the same way the other advanced examples do.
Metadata walkthrough β
Since this page sets aside: false, there is no "On this page" outline. Use this short list instead:
- Express vs. manual checkout
- Delivery address and per-country postal validation
- Conditional visibility and validation
- Shipping method
- Payment method
- Split payment, the finale
- Gift message
- What It Demonstrates
- How It Works
- Related Source
Express vs. manual checkout β
The top-level decision is a choice with preserveOnSwitch: true, so switching to express and back to manual restores whatever was already typed. The express branch has no fields of its own, so it declares a presenceValue: the value written into the form the moment the branch is picked, which is what lets a branch with nothing to fill in pass validation and stay selected when saved values are loaded back in:
name: 'checkoutMethod',
type: 'heading',
fieldOptions: { label: 'How would you like to check out?' },
description: 'Choose one. Express skips the form; manual reveals it.',
explicitChoiceSelection: true,
choicePicker: { show: true },
preserveOnSwitch: true,
choice: [
{
name: 'express',
type: 'heading',
fieldOptions: { label: 'β‘ Express checkout' },
description: 'Pay with a saved wallet. In a real store this would redirect to your wallet to confirm; this demo stops here.',
presenceValue: true,
},Delivery address and per-country postal validation β
The country field carries an xsd_enumeration restriction. The postal code field reads its sibling country through computedProps, deriving the path with thisField.parent.path rather than a hard-coded one, which is exactly what lets the same address fragment mount again at the billing path further down:
{
name: 'postalCode',
fieldOptions: { label: 'Postal code' },
computedProps: [(thisField) => {
const isActive = readPath(values, `${thisField.parent.path}.sameAsShipping`) !== true;
thisField.hide = !isActive;
thisField.minOccurs = isActive ? 1 : 0;
const country = readPath(values, `${thisField.parent.path}.country`) as string | undefined;
thisField.placeholder = postalRuleFor(country).placeholder;
// Swapping to the other country's fixed function reference on country change rebuilds
// the field's rule set, which revalidates the already-entered value against the new
// pattern immediately.
thisField.validation = isActive ? POSTAL_VALIDATIONS[postalRuleKeyFor(country)] : noOpValidation;
}],
},Conditional visibility and validation β
The business toggle reveals a company name and a VAT number, both hidden and not required until the toggle is on:
{ name: 'isBusiness', type: 'checkbox', minOccurs: 0, colSpan: 'full', fieldOptions: { label: 'I am ordering as a business' } },
{
name: 'company',
fieldOptions: { label: 'Company name' },
computedProps: [(thisField) => {
const isBusiness = readPath(values, `${thisField.parent.path}.isBusiness`) === true;
thisField.hide = !isBusiness;
// The minOccurs flip rebuilds the field's rule set, and vee-validate
// revalidates a field whenever its rules change, so a stale required error
// clears on hide without any manual revalidation.
thisField.minOccurs = isBusiness ? 1 : 0;
}],
},
{
name: 'vatNumber',
fieldOptions: { label: 'VAT number' },
computedProps: [(thisField) => {
const isBusiness = readPath(values, `${thisField.parent.path}.isBusiness`) === true;
thisField.hide = !isBusiness;
thisField.minOccurs = isBusiness ? 1 : 0;
// Swapping between these two stable function references (rather than a fresh
// closure each run) changes the field's own rule set, which rebuilds and
// revalidates automatically, picking up the pattern check without a manual
// revalidation call.
thisField.validation = isBusiness ? vatValidation : noOpValidation;
}],
},The billing "same as shipping" toggle reuses the exact same address fields, by spreading the function that builds them a second time:
children: [
{ name: 'sameAsShipping', type: 'checkbox', minOccurs: 0, colSpan: 'full', fieldOptions: { label: 'Billing address is the same as shipping' } },
...createAddressFields(),
],Shipping method β
A choice whose pickup branch nests a pickup-point field; home delivery has no fields of its own:
{
name: 'shippingMethod',
type: 'heading',
fieldOptions: { label: 'Shipping method' },
explicitChoiceSelection: true,
choicePicker: { show: true },
choice: [
{
name: 'homeDelivery',
type: 'heading',
fieldOptions: { label: 'π Home delivery' },
description: `1 to 3 business days, ${HOME_DELIVERY_FEE.toFixed(2)}`,
presenceValue: true,
},
{
name: 'pickup',
type: 'select',
fieldOptions: { label: 'Pickup point' },
description: 'Collect nearby, free',
options: PICKUP_POINT_OPTIONS,
restriction: { enumeration: PICKUP_POINT_OPTIONS.map(option => option.key) },
},
],
},Payment method β
Four branches, four different flavours: plain text inputs, a bank <select>, an empty redirect-only branch, and an installment-plan enumeration:
choice: [
{
name: 'card',
fieldOptions: { label: 'π³ Card' },
inertPaymentDisclaimer: true,
children: [
{ name: 'disclaimer', type: 'disclaimer' },
{ name: 'cardNumber', fieldOptions: { label: 'Card number' }, placeholder: '1234 5678 9012 3456', colSpan: 'full' },
{ name: 'expiry', fieldOptions: { label: 'Expiry' }, placeholder: 'MM / YY' },
{ name: 'cvc', fieldOptions: { label: 'CVC' }, placeholder: '123' },
],
},
{
name: 'ideal',
fieldOptions: { label: 'iDEAL' },
inertPaymentDisclaimer: true,
children: [
{ name: 'disclaimer', type: 'disclaimer' },
{ name: 'bank', type: 'select', fieldOptions: { label: 'Your bank' }, colSpan: 'full', options: BANK_OPTIONS, restriction: { enumeration: BANK_OPTIONS.map(option => option.key) } },
],
},
{
name: 'payPal',
type: 'heading',
fieldOptions: { label: 'PayPal' },
presenceValue: true,
},
{
name: 'klarna',
fieldOptions: { label: 'Klarna' },
children: [
{
name: 'installmentPlan',
type: 'select',
fieldOptions: { label: 'Installment plan' },
colSpan: 'full',
options: klarnaOptionsFor(ORDER_TOTAL),
restriction: { enumeration: KLARNA_MONTHS.map(String) },
computedProps: [(thisField) => {
thisField.options = klarnaOptionsFor(total.value);
}],
},
],
},
],Split payment, the finale β
Here is the shape no other page here covers: a repeatable ordered choice (maxOccurs > 1 combined with choice). Split payment is opt-in, so it never inflates the default field count. Add payment sources (gift card, voucher, card, PayPal); they apply top to bottom. Every row is a live occurrence of the choice, not a static line item: each one seeds a starting value by occurrence index (a second gift card gets a different code and amount than the first) and stays fully editable, with its own per-occurrence validation (splitPayment.giftCard[0].amount and splitPayment.giftCard[1].amount are separate fields with separate errors). Change an amount and the ledger redistributes the remainder live. Each row shows the amount it covers and what remains after it, and reordering rewrites the two affected occurrences' order values directly, building on the repeatable ordered choice and persisted order the Choices page demonstrates:
{
name: 'splitPayment',
type: 'splitPayment',
fieldOptions: { label: 'Split payment' },
description: 'Add sources in the order they should apply. Codes and amounts are editable; changing an amount redistributes the remainder live.',
// Never required by occurrence count: coverage (below) is the real gate, since one
// row (e.g. a single card) can fully cover the total on its own.
minOccurs: 0,
maxOccurs: SPLIT_PAYMENT_MAX_OCCURS,
explicitChoiceSelection: true,
displayOrder: 'added',
preserveOrder: true,
computedProps: [(thisField) => {
thisField.hide = !splitPaymentActive.value;
}],
// Every branch repeats the container's own type: the occurrence slot family
// (`splitPayment-choice-array-item`) is driven by each branch's own `type`, not the
// container's, so this is what routes every row to the ledger row component below.
// Row fields are seeded with a per-occurrence starting value but stay fully editable:
// each occurrence registers its own field (`splitPayment.giftCard[0].amount`) with its
// own validation, and editing a gift card's amount reflows the whole ledger live.
choice: [
{
name: 'giftCard',
type: 'splitPayment',
fieldOptions: { label: 'π Gift card' },
description: 'Fixed amount',
children: [
{ name: 'code', fieldOptions: { label: 'Code' }, validation: splitCodeValidation, computedProps: [(thisField, fieldValue) => setupSplitPaymentField(thisField, fieldValue, SPLIT_PAYMENT_SEEDS.giftCardCode)] },
{ name: 'amount', fieldOptions: { label: 'Amount' }, placeholder: '25.00', validation: splitAmountValidation, computedProps: [(thisField, fieldValue) => setupSplitPaymentField(thisField, fieldValue, SPLIT_PAYMENT_SEEDS.giftCardAmount)] },
],
},
{
name: 'voucher',
type: 'splitPayment',
fieldOptions: { label: 'π·οΈ Voucher' },
description: 'Fixed amount',
children: [
{ name: 'code', fieldOptions: { label: 'Code' }, validation: splitCodeValidation, computedProps: [(thisField, fieldValue) => setupSplitPaymentField(thisField, fieldValue, SPLIT_PAYMENT_SEEDS.voucherCode)] },
{ name: 'amount', fieldOptions: { label: 'Amount' }, placeholder: '25.00', validation: splitAmountValidation, computedProps: [(thisField, fieldValue) => setupSplitPaymentField(thisField, fieldValue, SPLIT_PAYMENT_SEEDS.voucherAmount)] },
],
},
{
name: 'card',
type: 'splitPayment',
fieldOptions: { label: 'π³ Card' },
description: 'Covers the remainder',
children: [
{ name: 'cardNumber', fieldOptions: { label: 'Card number' }, colSpan: 'full', placeholder: '1234 5678 9012 3456', validation: splitCardNumberValidation, computedProps: [(thisField, fieldValue) => setupSplitPaymentField(thisField, fieldValue, SPLIT_PAYMENT_SEEDS.cardNumber)] },
],
},
{
name: 'payPal',
type: 'splitPayment',
fieldOptions: { label: 'PayPal' },
description: 'Covers the remainder',
children: [
{ name: 'account', type: 'email', fieldOptions: { label: 'Account' }, colSpan: 'full', placeholder: '[email protected]', validation: splitAccountValidation, computedProps: [(thisField, fieldValue) => setupSplitPaymentField(thisField, fieldValue, SPLIT_PAYMENT_SEEDS.payPalAccount)] },
],
},
],
},Gift message β
An optional field with an xsd_maxLength restriction, revealed behind a toggle (one of the Baymard reduction tactics) and paired with a character counter that lives in the template, not the engine:
{
name: 'giftMessage',
type: 'heading',
fieldOptions: { label: 'Extras' },
// Optional as a whole: left untouched whenever no gift message is added.
minOccurs: 0,
children: [
{ name: 'addGiftMessage', type: 'checkbox', minOccurs: 0, colSpan: 'full', fieldOptions: { label: 'Add a gift message' } },
{
name: 'message',
type: 'textareaWithCounter',
minOccurs: 0,
colSpan: 'full',
fieldOptions: { label: 'Gift message' },
placeholder: 'Write a short noteβ¦',
// The engine's own maxLength rule enforces the limit directly off this
// restriction, which also doubles as what the counter below reads
// (`fieldMetadata.restriction?.maxLength`) for its own "x / 200" display.
// Swapping between this stable object and `undefined` rebuilds the field's rule
// set, which revalidates automatically, clearing a stale over-length error the
// moment the toggle switches off.
computedProps: [(thisField) => {
const wantsGiftMessage = readPath(values, `${thisField.parent.path}.addGiftMessage`) === true;
thisField.hide = !wantsGiftMessage;
thisField.restriction = wantsGiftMessage ? GIFT_MESSAGE_RESTRICTION : undefined;
}],
},
],
},What It Demonstrates β
- A top-level
choicefor express vs. manual checkout, shown with minimal inert UI - Email
xsd_pattern, and a countryxsd_enumerationthat drives per-country postal validation throughcomputedProps - Conditional visibility and validation: the business and VAT toggle, and the billing "same as shipping" toggle
- A shipping
choicewith a field nested only on the pickup branch - A payment
choicewith four branch flavours: card inputs, a bank select, an empty branch, an enumeration select - The finale: a repeatable ordered
choice(maxOccurs > 1) for split payment, with editable per-occurrence fields that validate independently, a computed per-row remaining amount, template-driven reordering, and a hard cross-field rule that the rows cover the order total - A gift message with
xsd_maxLengthand a character counter
How It Works β
The split-payment list is a choice with maxOccurs > 1 and explicitChoiceSelection. Each source is a branch; adding one pushes an occurrence whose fields register like any other (splitPayment.giftCard[0].amount), so editing an amount flows through the normal field-write path and every derived figure follows. The per-row remaining amount is a plain computed value inside the ledger row component, reading the form's values through vee-validate's useFormContext rather than through computedProps; the container's computedProps hides the section, and each row field's computedProps only seeds a starting value by occurrence index, leaving the field editable. The row rules themselves are closures that stand down when split payment is switched off, so a hidden, half-edited row can never block submit. The Choices page demonstrates the underlying repeatable ordered choice and its persisted order value; the reorder control itself, which writes the two affected occurrences' order values directly through the normal field-write path, is this page's own addition on top of that mechanism. No new engine primitive is involved.
Moving a remainder-covering source (card or PayPal) above a fixed source can push the running total past the order total. Every displayed amount clamps at 0 in that case, and the amber "still unassigned" readout turns into a rose banner naming the exact overshoot, blocking submit until the rows are reordered or a source is removed. Submission itself goes through vee-validate's handleSubmit, which re-validates the whole form before placing the order; the place-order button's disabled state reads the live aggregate meta.valid. The coverage check is backed by a hidden field whose own validation message is what the banner shows, so it stays correct even if the live readout has not re-rendered yet.
Related Source β
- CheckoutFormExample.vue (the metadata tree)
- CheckoutFormTemplate.vue (the checkout's own neutral template component)
- Choices for the repeatable ordered choice mechanics
- Wizard and Client Onboarding Wizard for the
validateSection()step pattern (this page is intentionally one page, matching the benchmark it rebuilds)