How to Build a Product Configurator
Product configurators turn browsers into buyers. Instead of static product pages, customers build their own product - selecting size, color, add-ons - and see the price update instantly. Here's how to build one.
What Makes a Good Configurator
- Instant feedback - Price updates as you select
- Clear options - What's included, what costs extra
- Smart constraints - Only show valid combinations
- Visual summary - Confirm what you're getting
Basic Product Options
Start with the core selections: size, color, variant. Define your options and prices upfront. See the Selection Fields tutorial for dropdown and radio patterns.
// Define your product options
const BASE_PRICES = {
small: 29,
medium: 49,
large: 79
};
const COLORS = [
{ id: 'black', name: 'Black' },
{ id: 'white', name: 'White' },
{ id: 'navy', name: 'Navy (+$5)', premium: true }
];
form.addRow(row => {
row.addRadioButton('size', {
label: 'Size',
options: [
{ id: 'small', name: 'Small' },
{ id: 'medium', name: 'Medium' },
{ id: 'large', name: 'Large' }
],
defaultValue: 'medium',
isRequired: true
});
});
form.addRow(row => {
row.addRadioButton('color', {
label: 'Color',
options: COLORS,
defaultValue: 'black',
isRequired: true
});
});Radio buttons work well for 2-5 options. Dropdowns are better for longer lists. Default to the most popular choice.
Dynamic Options
Options that depend on other selections. Different products have different available sizes. See the Conditional Visibility tutorial for more patterns.
// Options that depend on another selection
const SIZES_BY_PRODUCT = {
tshirt: [
{ id: 'xs', name: 'XS' },
{ id: 's', name: 'S' },
{ id: 'm', name: 'M' },
{ id: 'l', name: 'L' },
{ id: 'xl', name: 'XL' }
],
hoodie: [
{ id: 's', name: 'S' },
{ id: 'm', name: 'M' },
{ id: 'l', name: 'L' },
{ id: 'xl', name: 'XL' },
{ id: 'xxl', name: 'XXL' }
]
};
form.addRow(row => {
row.addDropdown('product', {
label: 'Product',
options: [
{ id: 'tshirt', name: 'T-Shirt' },
{ id: 'hoodie', name: 'Hoodie' }
]
});
});
form.addRow(row => {
row.addDropdown('size', {
label: 'Size',
options: () => {
const product = form.dropdown('product')?.value();
return SIZES_BY_PRODUCT[product as keyof typeof SIZES_BY_PRODUCT] ?? [];
},
isVisible: () => form.dropdown('product')?.value() !== null
});
});The size dropdown only appears after selecting a product, and its options change based on which product is selected.
Pro tip
When options change dynamically, previously selected values may become invalid. FormTs automatically clears the value when the selected option is no longer available.
Pricing Logic
Centralize your pricing calculation in one function. This makes it easy to update and test. See the Computed Values tutorial for calculation patterns.
// Centralized pricing calculation
const calculatePrice = () => {
const product = form.dropdown('product')?.value();
const size = form.dropdown('size')?.value();
const color = form.radioButton('color')?.value();
const quantity = form.integer('quantity')?.value() ?? 1;
// Base price by product
let price = product === 'hoodie' ? 59 : 29;
// Size upcharge
if (size === 'xl' || size === 'xxl') {
price += 5;
}
// Premium color
const selectedColor = COLORS.find(c => c.id === color);
if (selectedColor?.premium) {
price += 5;
}
// Quantity discount
if (quantity >= 10) {
price *= 0.9; // 10% off
} else if (quantity >= 5) {
price *= 0.95; // 5% off
}
return price * quantity;
};
form.addRow(row => {
row.addPriceDisplay('total', {
label: 'Total',
computedValue: calculatePrice,
variant: 'large'
});
});The function handles:
- Base price by product type
- Size upcharges
- Premium color options
- Quantity discounts
Add-Ons and Extras
Checkbox lists work great for optional add-ons:
// Checkbox add-ons with individual pricing
const ADD_ONS = [
{ id: 'gift-wrap', name: 'Gift Wrapping', price: 5 },
{ id: 'express', name: 'Express Shipping', price: 15 },
{ id: 'insurance', name: 'Shipping Insurance', price: 3 }
];
form.addRow(row => {
row.addCheckboxList('addons', {
label: 'Add-ons',
options: ADD_ONS.map(a => ({
id: a.id,
name: `${a.name} (+$${a.price})`
}))
});
});
// Include add-ons in total
const calculateTotal = () => {
let total = calculateBasePrice();
const selectedAddons = form.checkboxList('addons')?.value() ?? [];
for (const addonId of selectedAddons) {
const addon = ADD_ONS.find(a => a.id === addonId);
if (addon) total += addon.price;
}
return total;
};Show the price in the option label so customers know the cost before selecting.
See a complete configurator example
Quantity Pricing
Encourage larger orders with tiered discounts and live feedback:
// Tiered quantity pricing with live feedback
form.addRow(row => {
row.addInteger('quantity', {
label: 'Quantity',
min: 1,
max: 100,
defaultValue: 1
});
});
form.addRow(row => {
row.addTextPanel('discount', {
computedValue: () => {
const qty = form.integer('quantity')?.value() ?? 1;
if (qty >= 50) return '๐ 25% bulk discount applied!';
if (qty >= 25) return '๐ 15% bulk discount applied!';
if (qty >= 10) return '๐ 10% bulk discount applied!';
if (qty >= 5) return '๐ก Order 10+ for 10% off';
return '';
},
isVisible: () => (form.integer('quantity')?.value() ?? 1) >= 5
});
});Show customers how close they are to the next discount tier. "Order 3 more for 10% off" is more compelling than just showing the current price.
Inventory and Availability
Show stock status for selected combinations:
// Product variants with availability
const VARIANTS = {
'black-s': { available: true, stock: 50 },
'black-m': { available: true, stock: 30 },
'black-l': { available: true, stock: 10 },
'white-s': { available: true, stock: 25 },
'white-m': { available: false, stock: 0 }, // Out of stock
'white-l': { available: true, stock: 5 }
};
form.addRow(row => {
row.addTextPanel('availability', {
computedValue: () => {
const color = form.radioButton('color')?.value();
const size = form.dropdown('size')?.value();
if (!color || !size) return '';
const key = `${color}-${size}`;
const variant = VARIANTS[key as keyof typeof VARIANTS];
if (!variant?.available) {
return 'โ Out of stock';
}
if (variant.stock <= 5) {
return `โ ๏ธ Only ${variant.stock} left!`;
}
return 'โ In stock';
},
isVisible: () => {
const color = form.radioButton('color')?.value();
const size = form.dropdown('size')?.value();
return !!color && !!size;
}
});
});Real-time availability prevents frustration at checkout. Show "Only 5 left!" to create urgency.
Strikethrough Pricing
When discounts apply, show the original price crossed out. See the Price Display tutorial for all formatting options.
// Show original price when discounted
form.addRow(row => {
row.addPriceDisplay('price', {
label: 'Price',
computedValue: () => {
const base = calculateBasePrice();
const qty = form.integer('quantity')?.value() ?? 1;
// Apply quantity discount
if (qty >= 10) return base * 0.9;
return base;
},
originalPrice: () => {
const qty = form.integer('quantity')?.value() ?? 1;
if (qty >= 10) {
return calculateBasePrice(); // Show original when discounted
}
return undefined; // No strikethrough
},
variant: 'highlight'
});
}); The originalPrice property shows the pre-discount price with a strikethrough when a discount is active.
Order Summary
A live summary section confirms what the customer is getting:
// Live order summary
const summary = form.addSubform('summary', {
title: 'Order Summary',
isCollapsible: false
});
summary.addRow(row => {
row.addTextPanel('items', {
computedValue: () => {
const product = form.dropdown('product')?.value();
const size = form.dropdown('size')?.value();
const color = form.radioButton('color')?.value();
const qty = form.integer('quantity')?.value() ?? 1;
if (!product || !size || !color) return 'Select options above';
const productName = product === 'hoodie' ? 'Hoodie' : 'T-Shirt';
return `${qty}x ${productName} (${size?.toUpperCase()}, ${color})`;
}
});
});
summary.addRow(row => {
row.addPriceDisplay('subtotal', {
label: 'Subtotal',
computedValue: calculateBasePrice
});
});
summary.addRow(row => {
row.addPriceDisplay('discount', {
label: 'Discount',
computedValue: () => {
const base = calculateBasePrice();
const discounted = calculateTotal();
return discounted - base; // Negative number
},
isVisible: () => {
const qty = form.integer('quantity')?.value() ?? 1;
return qty >= 10;
}
});
});
summary.addRow(row => {
row.addPriceDisplay('total', {
label: 'Total',
computedValue: calculateTotal,
variant: 'large'
});
});The summary shows:
- Product description with selected options
- Subtotal before discounts
- Discount amount (when applicable)
- Final total
Complete Example
A full tech product configurator with dynamic upgrades:
export function productConfigurator(form: FormTs) {
const PRODUCTS = {
laptop: { name: 'Laptop', base: 999 },
desktop: { name: 'Desktop', base: 799 },
monitor: { name: 'Monitor', base: 299 }
};
const UPGRADES = {
laptop: [
{ id: 'ram-16', name: '16GB RAM (+$100)', price: 100 },
{ id: 'ram-32', name: '32GB RAM (+$250)', price: 250 },
{ id: 'ssd-512', name: '512GB SSD (+$80)', price: 80 },
{ id: 'ssd-1tb', name: '1TB SSD (+$150)', price: 150 }
],
desktop: [
{ id: 'ram-32', name: '32GB RAM (+$200)', price: 200 },
{ id: 'ram-64', name: '64GB RAM (+$400)', price: 400 },
{ id: 'gpu', name: 'Dedicated GPU (+$300)', price: 300 }
],
monitor: [
{ id: '4k', name: '4K Resolution (+$150)', price: 150 },
{ id: 'curved', name: 'Curved Display (+$100)', price: 100 }
]
};
// Product selection
form.addRow(row => {
row.addRadioButton('product', {
label: 'Select Product',
options: Object.entries(PRODUCTS).map(([id, p]) => ({
id,
name: `${p.name} - $${p.base}`
})),
isRequired: true
});
});
// Dynamic upgrades based on product
form.addRow(row => {
row.addCheckboxList('upgrades', {
label: 'Upgrades',
options: () => {
const product = form.radioButton('product')?.value();
return UPGRADES[product as keyof typeof UPGRADES] ?? [];
},
isVisible: () => form.radioButton('product')?.value() !== null
});
});
// Quantity
form.addRow(row => {
row.addInteger('quantity', {
label: 'Quantity',
defaultValue: 1,
min: 1,
max: 10
});
});
// Calculate total
const total = form.computedValue(() => {
const productId = form.radioButton('product')?.value();
const product = PRODUCTS[productId as keyof typeof PRODUCTS];
if (!product) return 0;
let price = product.base;
// Add upgrades
const selectedUpgrades = form.checkboxList('upgrades')?.value() ?? [];
const availableUpgrades = UPGRADES[productId as keyof typeof UPGRADES] ?? [];
for (const upgradeId of selectedUpgrades) {
const upgrade = availableUpgrades.find(u => u.id === upgradeId);
if (upgrade) price += upgrade.price;
}
// Multiply by quantity
const qty = form.integer('quantity')?.value() ?? 1;
return price * qty;
});
// Display total
form.addRow(row => {
row.addPriceDisplay('total', {
label: 'Total',
computedValue: () => total(),
variant: 'large',
isVisible: () => form.radioButton('product')?.value() !== null
});
});
form.configureSubmitButton({
label: 'Add to Cart'
});
form.configureSubmitBehavior({
sendToServer: false // Calculator only
});
}This configurator:
- Shows different upgrade options per product
- Calculates total including all selected upgrades
- Updates price instantly as options change
- Doesn't submit to server (calculator only)
Best Practices
Start with the Most Important Choice
Lead with the primary decision (product type, plan tier) that determines which other options are available.
Show Prices in Options
"Premium Support (+$50/mo)" is clearer than showing Premium Support and making users calculate the difference.
Default to Popular Choices
Pre-select the most common configuration. This anchors the price and reduces decision fatigue.
Keep the Total Visible
Use a sticky summary or keep the total in view. Customers shouldn't have to scroll to see the price.
Validate Combinations
If certain combinations aren't available (sold out, incompatible), hide or disable them rather than showing an error after selection.
When to Use a Configurator
Configurators work best when:
- Multiple dimensions - Size AND color AND material
- Price varies - Options affect the total
- Combinations matter - Not all options work together
- Complex products - Too many variants for separate pages
If you just have 3 t-shirt sizes at the same price, a simple dropdown is fine. But for custom orders, bundled pricing, or build-your-own products - configurators convert better.
Common Questions
How do I handle out-of-stock variants?
You can either hide unavailable options by filtering them from the options array, or show them disabled with '(Out of Stock)' in the name. Hiding keeps the interface cleaner; showing prevents confusion about why an option is missing.
Can I show product images that change with selections?
FormTs focuses on form logic, not image display. You can use onValueChange callbacks to update images in your surrounding page, or use a TextPanel with HTML content that changes based on selections.
How do I integrate with my e-commerce platform?
Use the form submission data to create cart items or orders. The submission includes all selected options and the calculated total. You can send this to Shopify, WooCommerce, or your custom backend via webhooks or API calls.
Should I show unit price or total price?
Show both when there's a quantity field. Display unit price in the options and total price in the summary. For bulk discounts, show the per-unit savings too: '$45/each (was $50)'.
How do I handle percentage vs fixed discounts?
In your pricing function, check the discount type and calculate accordingly. For percentages: price * (1 - percent/100). For fixed amounts: price - amount. Store the discount type in your options data structure.