How-To Guide

How to Build a Product Configurator

January 2026 ยท 13 min read

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.

Ready to Build Your Configurator?

Create product configurators that convert. Start building today.