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
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
| Method | Markup | Notes |
|---|---|---|
| JSON file | data-config="config.json" | Loaded with fetch(). Needs a web server. |
| JSON by id | data-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. |
| Inline | data-config='{"currency":"EUR"}' | Tiny configs. |
| Global variable | data-config-var="Site.calc" | Dotted path on window. |
| JavaScript | InstantEstimate.mount(el, config) | Functions allowed. |
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
| Option | Type | Default | Description |
|---|---|---|---|
id | string | "" | Sent as estimator in webhook payloads. |
title, subtitle | string | "" | Optional heading above the fields. |
theme | dark · light · auto | "dark" | Colour preset; auto follows the system. |
accent, accent2 | CSS colour | preset | Gradient colours. |
dir | ltr · rtl | "ltr" | Text direction. |
lang | string | "" | lang attribute of the widget. |
layout | auto · stacked | "auto" | Two columns from 760px widget width, or always one. |
locale | BCP 47 | "en-US" | Numbers, currency, plurals. |
currency | ISO 4217 | "USD" | "" = plain numbers. |
currencyDisplay | symbol · narrowSymbol · code · name | "symbol" | Intl option. |
decimals | number | 0 | Price decimals. |
pricePrefix, priceSuffix | string | "" | Around every price. |
priceNote | string | "" | Small text under the big price and after the range ("per visit", "+ VAT"). |
rounding | number | 1 | Round low/high to the nearest N. |
minimum | number | 0 | Lowest total. |
spread | [low, high] | [0.92, 1.25] | Range around the total. [1,1] = one price. Also {low, high} or 0.1 (±10%). |
showPrices | boolean | true | Price hints on options. |
numbered | boolean | true | 01, 02 … before labels. |
dock | boolean | true | Sticky mini price bar on narrow screens. |
barMax | number · "auto" | "auto" | Total that fills the meter. |
urlState | false · true · "live" | false | Restore from URL and share links; "live" keeps the address bar in sync. |
urlParam | string | "est" | Query parameter name. |
state | object | — | Initial selections. |
timeline | object · false | weeks | Timeline. |
includes | object · false | {base: []} | Includes line. |
contact | object | — | Contact fields. |
fields | array | [] | Fields. |
actions | array | ["copy"] | Buttons; the first is the main button. |
texts | object | English | All texts. |
Fields
Keys shared by all fields: id, type, label, help, when, summary.
| Key | Description |
|---|---|
id | Unique key used in the state, URL and templates. |
type | choice · multi · range · toggle |
help | Small text under the label. |
when | Show only if other fields match: {"type": ["store","app"]}, {"rush": true}. Hidden fields don't count. |
summary | Text 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.
| Key | Description |
|---|---|
value | Saved value; short and URL-safe. |
label, description | Visible texts. |
price | Fixed amount added (base price or add-on); may be negative. |
percent | Change of the total in %: 30 = ×1.3, -15 = ×0.85. |
multiplier | Factor for the total (instead of percent). |
time, timeMultiplier | Added time; factor for the timeline. |
units | Override range billing: {"pages": {"free": 5, "price": 300, "time": 0.2}}. |
includes | Text for the Includes line; false = never. |
summary | Text in the summary instead of the label. |
hint | Custom hint instead of the automatic price ("" hides it). |
showPrice | false 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.
| Type | Keys | What it does |
|---|---|---|
mailto | to, cc, subject, body | Opens the visitor's email app with a prefilled message. |
webhook | url, method, headers, extra, timeout, credentials, reset, redirect, errorText | POSTs JSON with fetch(); busy, success and error states. Error if status is not 2xx or the reply has "ok": false. |
form | form, map, scrollTo, focus | Fills an existing form by selector, fires input/change, scrolls to it. |
copy | text | Copies 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
| Token | Value |
|---|---|
{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" }
}
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
| Event | Arguments | When |
|---|---|---|
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; }
| Property | Purpose |
|---|---|
--ie-font, --ie-font-display | Fonts |
--ie-accent, --ie-accent-2, --ie-gradient | Accent and gradient |
--ie-bg, --ie-surface, --ie-surface-2, --ie-surface-3 | Backgrounds |
--ie-text, --ie-muted | Text |
--ie-line, --ie-line-2 | Borders |
--ie-chip-on-bg, --ie-chip-on-text, --ie-multi-on-bg | Selected chips |
--ie-btn-bg, --ie-btn-text | Main button |
--ie-success, --ie-error | Status colours |
--ie-radius, --ie-radius-sm, --ie-gap, --ie-pad | Shape and spacing |
--ie-shadow, --ie-glow-opacity, --ie-ease | Depth and motion |
--ie-sticky-top | Sticky 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
| Key | Default |
|---|
Accessibility
- Native radio buttons, checkboxes, range inputs and switches with labels, legends and fieldsets.
- Keyboard: Tab between fields, arrow keys within groups and sliders, Space toggles.
- Visible focus rings; sliders expose
aria-valuetext("6 pages"). - The new estimate is announced politely once the visitor pauses.
- Errors are linked with
aria-describedby; the first invalid field is focused. - Honours
prefers-reduced-motionand forced-colours mode.
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.