Instant Estimate

A drop-in project price calculator. Visitors choose what they need and instantly see a price range, a timeline and what's included, then send it to you by email, webhook or your own contact form.

Overview

No dependenciesOne CSS and one JS file. No build step, no external requests.
4 field typesChoice chips or cards, add-ons, range slider, toggle switch.
Real pricing modelBase, per unit with free units, fixed add-ons, percentage multipliers.
5 submit actionsmailto, JSON webhook, prefill your form, copy summary, share link.
Any language, RTLAll texts in the config; Intl number, currency and plural rules.
ThemesDark, light, auto; accent colours; 25+ CSS custom properties.

Package contents: dist/ (the two files you upload), examples/ (live demo, form prefill, webhook, JSON configs), server/receive.php (optional), this documentation, README.md, CHANGELOG.md, LICENSE.txt, THIRD-PARTY.txt.

Installation

Upload dist/instant-estimate.css and dist/instant-estimate.js, write a config and add three lines:

<!-- in <head> -->
<link rel="stylesheet" href="/assets/instant-estimate.css">

<!-- where the calculator should appear -->
<div data-instant-estimate data-config="/assets/config.json"></div>

<!-- before </body> -->
<script src="/assets/instant-estimate.js" defer></script>

A minimal config.json:

{
  "currency": "USD",
  "rounding": 50,
  "fields": [
    { "id": "service", "type": "choice", "label": "Service", "options": [
      { "value": "logo",  "label": "Logo",           "price": 400,  "time": 1 },
      { "value": "brand", "label": "Brand identity", "price": 1800, "time": 3 }
    ] },
    { "id": "rounds", "type": "range", "label": "Revision rounds", "min": 1, "max": 6, "default": 2,
      "price": 120, "free": 2, "units": { "one": "round", "other": "rounds" } },
    { "id": "rush", "type": "toggle", "label": "Rush delivery", "percent": 30, "timeMultiplier": 0.6 }
  ],
  "actions": [ { "type": "mailto", "to": "hello@example.com" }, "copy" ]
}

WordPress, Webflow, Shopify, Squarespace: upload the two files (media library or theme assets), then paste the three lines into a "Custom HTML" / "Embed" block. Use the JSON-in-the-page method below if you cannot upload a JSON file.

Loading the config

MethodMarkupNotes
JSON filedata-config="config.json"Loaded with fetch(). Needs a web server.
JSON by iddata-config="#cfg" + <script type="application/json" id="cfg">Works everywhere, also from disk.
JSON inside<div data-instant-estimate><script type="application/json">…No id needed.
Inlinedata-config='{"currency":"EUR"}'Tiny configs.
Global variabledata-config-var="Site.calc"Dotted path on window.
JavaScriptInstantEstimate.mount(el, config)Functions allowed.
Opening files from disk: browsers block fetch() on file:// pages, so data-config="config.json" only works on a web server (any host, or a local one). The included examples pass their configs inline so they also work from disk.

Attribute overrides: data-theme, data-accent, data-accent2, data-locale, data-currency, data-dir, data-layout.

Live demo

This calculator runs from ../dist on this page. Its whole config is in the snippet below it.

Global options

OptionTypeDefaultDescription
idstring""Sent as estimator in webhook payloads.
title, subtitlestring""Optional heading above the fields.
themedark · light · auto"dark"Colour preset; auto follows the system.
accent, accent2CSS colourpresetGradient colours.
dirltr · rtl"ltr"Text direction.
langstring""lang attribute of the widget.
layoutauto · stacked"auto"Two columns from 760px widget width, or always one.
localeBCP 47"en-US"Numbers, currency, plurals.
currencyISO 4217"USD""" = plain numbers.
currencyDisplaysymbol · narrowSymbol · code · name"symbol"Intl option.
decimalsnumber0Price decimals.
pricePrefix, priceSuffixstring""Around every price.
priceNotestring""Small text under the big price and after the range ("per visit", "+ VAT").
roundingnumber1Round low/high to the nearest N.
minimumnumber0Lowest total.
spread[low, high][0.92, 1.25]Range around the total. [1,1] = one price. Also {low, high} or 0.1 (±10%).
showPricesbooleantruePrice hints on options.
numberedbooleantrue01, 02 … before labels.
dockbooleantrueSticky mini price bar on narrow screens.
barMaxnumber · "auto""auto"Total that fills the meter.
urlStatefalse · true · "live"falseRestore from URL and share links; "live" keeps the address bar in sync.
urlParamstring"est"Query parameter name.
stateobject—Initial selections.
timelineobject · falseweeksTimeline.
includesobject · false{base: []}Includes line.
contactobject—Contact fields.
fieldsarray[]Fields.
actionsarray["copy"]Buttons; the first is the main button.
textsobjectEnglishAll texts.

Fields

Keys shared by all fields: id, type, label, help, when, summary.

KeyDescription
idUnique key used in the state, URL and templates.
typechoice · multi · range · toggle
helpSmall text under the label.
whenShow only if other fields match: {"type": ["store","app"]}, {"rush": true}. Hidden fields don't count.
summaryText in the summary (toggles); false leaves a range out.

choice — pick one

{ "id": "type", "type": "choice", "label": "Project type", "default": "website", "display": "chips",
  "options": [
    { "value": "landing", "label": "Landing page", "price": 1500, "time": 2, "units": { "pages": { "free": 1 } } },
    { "value": "website", "label": "Website",      "price": 4000, "time": 5, "units": { "pages": { "free": 5 } } }
  ] }

display: "cards" renders larger cards showing each option's description.

multi — add-ons

{ "id": "features", "type": "multi", "label": "Features", "max": 3, "default": ["cms"],
  "options": [ { "value": "cms", "label": "Content editor", "price": 600, "time": 0.5, "includes": "content editor" } ] }

range — quantity slider

{ "id": "pages", "type": "range", "label": "Pages", "min": 1, "max": 30, "step": 1, "default": 6,
  "price": 250, "time": 0.15, "free": 0, "units": { "one": "page", "other": "pages" }, "format": "{n} {unit}", "hint": "" }

price and time are per unit above free. hint: "" hides the "+$250 / page" hint. Options can override free, price and time through their units key.

toggle — on/off

{ "id": "rush", "type": "toggle", "label": "Rush delivery", "description": "About 25% faster.",
  "multiplier": 1.3, "timeMultiplier": 0.75, "summary": "rush timeline", "default": false }

Option keys

For choice and multi options, and for toggles themselves.

KeyDescription
valueSaved value; short and URL-safe.
label, descriptionVisible texts.
priceFixed amount added (base price or add-on); may be negative.
percentChange of the total in %: 30 = ×1.3, -15 = ×0.85.
multiplierFactor for the total (instead of percent).
time, timeMultiplierAdded time; factor for the timeline.
unitsOverride range billing: {"pages": {"free": 5, "price": 300, "time": 0.2}}.
includesText for the Includes line; false = never.
summaryText in the summary instead of the label.
hintCustom hint instead of the automatic price ("" hides it).
showPricefalse hides the automatic hint.

Pricing model

subtotal = Σ price of selected options and toggles
         + Σ max(0, range value − free) × price per unit
total    = max(minimum, subtotal × Π multipliers)
low      = round(total × spread[0], rounding)
high     = round(total × spread[1], rounding)

Multipliers apply to the whole subtotal. Example from the agency config: Online store $7,500 + 4 extra pages × $250 + Payments $1,200 + Content editor $600 = $10,300; × 1.3 rush = $13,390 → $12,300 – $16,700.

The core is pure and also runs in Node:

const IE = require('./dist/instant-estimate.js');
const r = IE.calculate(config, { type: 'store', pages: 12, rush: true });
r.low; r.high; r.formatted.range; r.timeline; r.summary;

Timeline & includes

"timeline": { "units": { "one": "day", "other": "days" }, "base": 0, "min": 1, "spread": 1.25, "pad": 1, "decimals": 0 },
"includes": { "base": ["Design", "build"], "empty": "Design, build, SEO, 30-day support" }
time     = (base + Σ time + Σ billable units × time per unit) × Π timeMultipliers
timeLow  = max(min, round(time))
timeHigh = max(timeLow, round(time × spread) + pad)

units uses plural categories of the locale (zero one two few many other) or a single string. Set "timeline": false or "includes": false to hide a row.

Contact fields

"contact": { "title": "Where should we send it?", "help": "", "fields": [
  { "id": "name",    "label": "Name", "required": true },
  { "id": "email",   "label": "Email", "type": "email", "required": true },
  { "id": "message", "label": "Message", "type": "textarea", "rows": 4 }
] }

Keys: id, label, type (text · email · tel · textarea), required, placeholder, autocomplete, maxlength, rows, wide. Validated before webhook and mailto (set validate on an action to change that). A hidden honeypot field is always included and sent as _hp.

Submit actions

The first action is the main button. Strings are short for {type}. Every action accepts label, successText and validate.

TypeKeysWhat it does
mailtoto, cc, subject, bodyOpens the visitor's email app with a prefilled message.
webhookurl, method, headers, extra, timeout, credentials, reset, redirect, errorTextPOSTs JSON with fetch(); busy, success and error states. Error if status is not 2xx or the reply has "ok": false.
formform, map, scrollTo, focusFills an existing form by selector, fires input/change, scrolls to it.
copytextCopies the summary (or your template).
share—Copies a link that restores the selections (enable urlState).
"actions": [
  { "type": "webhook", "url": "/estimate/receive.php", "successText": "Thanks! We reply today." },
  { "type": "form", "label": "Use in contact form", "form": "#contact-form",
    "map": { "#service": "{value:type}", "[name=estimate]": "{range}", "#message": "{summary}" } },
  "copy", "share"
]

Form mapping rules: text inputs and textareas get the value; a select picks the option with that value, else that text; radio groups check the matching value; several checkboxes take a comma list; one checkbox is checked when the value is not empty, 0 or false. With the JS API a mapping can be a function (result, state, payload) => value; see examples/form-prefill.html.

Template tokens

TokenValue
{summary}Full text summary
{range}, {low}, {high}, {total}Formatted prices
{lowValue}, {highValue}, {totalValue}Raw numbers
{timeline}, {timelineLow}, {timelineHigh}Timeline text and numbers
{includes}Includes line
{contact}, {contact:ID}All contact fields as lines, or one field
{field:ID}, {value:ID}Selected text or raw value of a field
{link}, {title}, {date}Share URL, config title, today (YYYY-MM-DD)

Webhook payload

{
  "estimator": "agency", "version": "1.1.0",
  "summary": "Instant estimate: Website, 6 pages. Estimated $3,900 – $5,300, 5–7 weeks.",
  "state": { "type": "website", "pages": 6, "features": [], "rush": false },
  "selections": [ { "field": "type", "label": "Project type", "value": "website", "text": "Website" } ],
  "estimate": { "currency": "USD", "low": 3900, "high": 5300, "total": 4250, "range": "$3,900 – $5,300",
                "timeline": { "low": 5, "high": 7, "text": "5–7 weeks" }, "includes": ["Design", "build"] },
  "contact": { "name": "Jane", "email": "jane@example.com" },
  "page": "https://example.com/pricing", "shareUrl": "", "_hp": "", "extra": { "source": "pricing-page" }
}
Never trust prices sent from a browser blindly. If the estimate turns into an order, recalculate it on the server with IE.calculate(config, payload.state) (Node) or your own logic.

PHP endpoint (optional)

server/receive.php emails each estimate to you. Open it, fill in the constants at the top, upload it, and point a webhook at it.

const IE_RECIPIENT      = '';   // where estimates are sent
const IE_FROM           = '';   // sender on your domain
const IE_SUBJECT        = 'New estimate: {range}';
const IE_ALLOWED_ORIGIN = '';   // only for a calculator on another domain
const IE_RATE_LIMIT     = 5;    // per IP …
const IE_RATE_WINDOW    = 600;  // … per 10 minutes

It accepts POST only, limits the body size, validates the JSON and email, silently accepts honeypot hits, rate-limits by IP with one small file in the system temp folder, strips line breaks from header values and answers with {"ok": true} or {"ok": false, "message": "…"}. PHP 7.4+ with mail(); no database.

JavaScript API

const calc = InstantEstimate.mount('#quote', config);

calc.getState();                  // { type: 'website', pages: 6, features: [], rush: false }
calc.setState({ pages: 12 });     // merge + validate + re-render
calc.getResult();                 // low, high, total, timeline, includes, selections, formatted, summary, ratio
calc.getSummary();
calc.getShareUrl();
calc.getConfig();                 // normalised config
calc.submit(0);                   // run an action by index or by type: calc.submit('webhook')
calc.notify('Saved', 'success');  // info | success | error
calc.reset();
calc.destroy();

InstantEstimate.get('#quote');    // instance of an auto-mounted element
InstantEstimate.init();           // mount [data-instant-estimate] elements added later
InstantEstimate.calculate(config, state);       // pure
InstantEstimate.encodeState(config, state);     // "type.app~pages.9~features.lang,3d~rush.0"
InstantEstimate.decodeState(config, string);
InstantEstimate.formatMoney(config, 1234);

Events

EventArgumentsWhen
change(result, state)Every change, and once on mount.
submit{ action, payload, result, preventDefault() }Before an action runs. Edit payload or cancel.
success{ action, payload, data?, url? }After an action worked (data = webhook reply).
error{ action, payload, error }Webhook or copy failed.
const off = calc.on('change', (r) => console.log(r.formatted.range));
off();   // unsubscribe
calc.on('submit', (e) => { e.payload.extra = { campaign: 'spring' }; });
document.addEventListener('instantestimate:change', (e) => console.log(e.detail.result.total));

Theming

Choose theme (dark, light, auto) and accent/accent2 in the config. Fine-tune with CSS custom properties on .ie:

.ie {
  --ie-accent: #ff5a7a;
  --ie-accent-2: #ffb547;
  --ie-radius: 12px;
  --ie-font: inherit;       /* use your site's font */
  --ie-sticky-top: 90px;    /* below a fixed header */
}
.ie[data-ie-theme="light"] { --ie-surface: #fffdf8; }
PropertyPurpose
--ie-font, --ie-font-displayFonts
--ie-accent, --ie-accent-2, --ie-gradientAccent and gradient
--ie-bg, --ie-surface, --ie-surface-2, --ie-surface-3Backgrounds
--ie-text, --ie-mutedText
--ie-line, --ie-line-2Borders
--ie-chip-on-bg, --ie-chip-on-text, --ie-multi-on-bgSelected chips
--ie-btn-bg, --ie-btn-textMain button
--ie-success, --ie-errorStatus colours
--ie-radius, --ie-radius-sm, --ie-gap, --ie-padShape and spacing
--ie-shadow, --ie-glow-opacity, --ie-easeDepth and motion
--ie-sticky-topSticky result panel offset

All classes start with ie-. The layout responds to the widget's own width (container query), so it fits sidebars, columns and full-width sections alike.

Languages & RTL

Set locale and currency; prices, digits and plural units follow Intl. Translate texts in texts, labels in fields. For right-to-left set "dir": "rtl": layout, sliders, switches, arrows and the meter flip.

{ "dir": "ltr", "lang": "en", "locale": "en-GB", "currency": "GBP",
  "texts": { "estimate": "Estimated cost", "range": "{low} to {high}", "listSeparator": ", " } }

The full English printing example: examples/configs/rtl-persian-print.json.

All texts

KeyDefault

Accessibility

Browser support

Current Chrome, Edge, Firefox, Safari (macOS and iOS) and Samsung Internet. The two-column layout uses container queries (Chrome/Edge 105+, Safari 16+, Firefox 110+); older browsers show one column and work fully. Internet Explorer is not supported.

Troubleshooting

"This price calculator could not load its settings."

The JSON file was not found or is invalid (check it with a JSON validator). On file:// pages use inline config instead of data-config="….json".

Copy does not work

The modern clipboard needs https://. On http:// a fallback is used; if that is blocked too, a message asks the visitor to copy manually.

The webhook shows an error

Check the response in the browser's developer tools (Network). receive.php answers "not set up yet" until IE_RECIPIENT is filled in. Another domain needs CORS headers (IE_ALLOWED_ORIGIN).

Several calculators on one page

That's fine. Give each its own urlParam if you use urlState.

The result panel hides under my fixed header

Set --ie-sticky-top to your header height.

Credits & licence

Made by Vortaxis Studio. No third-party code or fonts are included (see THIRD-PARTY.txt).

Single-domain and Full-source licences both cover one production domain. Read LICENSE.txt and LICENSING/README.txt for activation and rights.

Support & updates: vortaxis.com/store — questions answered for 6 months after purchase; updates for your version are free.

Licence and production activation

Single-domain and Full-source purchases each activate one production hostname. Full source adds editable code, white-label and SaaS rights; it does not include unlimited installations or redistribution. Localhost and .test development hosts are allowed.

Set the delivered key and production hostname in LICENSING/licence.json. Use https://vortaxis.com/licence/ to view activation or request a domain transfer. Vortaxis Studio approves replacement domains. Production activation requires a network connection; public demos remain unrestricted.

See LICENSE.txt for the complete terms. Existing purchases retain their original terms.