A proposal can look perfectly reasonable until you change one option. The price updates, but the delivery time still belongs to the old plan. Now the page is giving two different answers.
That is the small problem this example tackles: two packages, two revisions, and one consistent summary. It uses ordinary HTML and JavaScript. The company details and estimates are fictional.
The useful part is the shape of the state. You can reuse it for a quote calculator, a package comparison, or a project scope preview.
Keep the choices separate from the result
There are only two choices:
let revision = "first";
let plan = "essential";
The estimate is derived from those choices. It does not need another state variable.
const proposals = {
first: {
essential: {
price: "$4,800", weeks: 3,
items: ["Five responsive pages", "Contact form"]
},
complete: {
price: "$7,200", weeks: 4,
items: ["Five responsive pages", "Contact form", "Project gallery"]
}
},
revised: {
essential: {
price: "$5,400", weeks: 4,
items: ["Five responsive pages", "Booking flow", "Accessibility review"]
},
complete: {
price: "$8,100", weeks: 5,
items: ["Five responsive pages", "Booking flow", "Accessibility review", "Project gallery"]
}
}
};
Each combination has a complete record. That makes it possible to inspect the numbers and scope together before touching the interface.
These are fixed example estimates. A real pricing tool would need its own rules for currency, tax, and calculations.
Give every change the same path through the DOM
Here is the markup. Put it in the body of an HTML page, with the JavaScript below it in a script element.
<h1>Website proposal</h1>
<p>Fictional estimates for a scope comparison.</p>
<div role="group" aria-label="Proposal revision">
<button type="button" data-revision="first" aria-pressed="true">First draft</button>
<button type="button" data-revision="revised" aria-pressed="false">Revised scope</button>
</div>
<div role="group" aria-label="Choose a package">
<button type="button" data-plan="essential" aria-pressed="true">Essential</button>
<button type="button" data-plan="complete" aria-pressed="false">Complete</button>
</div>
<p id="summary" aria-live="polite" aria-atomic="true"></p>
<ul id="deliverables"></ul>
Both sets of buttons change a choice and call the same function:
function render() {
const proposal = proposals[revision][plan];
const revisionLabel = revision === "first" ? "First draft" : "Revised scope";
const planLabel = plan === "essential" ? "Essential" : "Complete";
document.getElementById("summary").textContent =
`${revisionLabel}, ${planLabel}: ${proposal.price}; ${proposal.weeks} weeks.`;
document.getElementById("deliverables").replaceChildren(
...proposal.items.map(item => {
const li = document.createElement("li");
li.textContent = item;
return li;
})
);
document.querySelectorAll("[data-revision]").forEach(button => {
button.setAttribute("aria-pressed", String(button.dataset.revision === revision));
});
document.querySelectorAll("[data-plan]").forEach(button => {
button.setAttribute("aria-pressed", String(button.dataset.plan === plan));
});
}
document.querySelectorAll("[data-revision]").forEach(button => {
button.addEventListener("click", () => {
revision = button.dataset.revision;
render();
});
});
document.querySelectorAll("[data-plan]").forEach(button => {
button.addEventListener("click", () => {
plan = button.dataset.plan;
render();
});
});
render();
The click handlers are deliberately boring. They do not calculate prices, rewrite individual list items, or decide which buttons should look selected. Those changes happen together in render().
Selecting Complete and then Revised scope keeps Complete selected. Switching the revision does not quietly reset the package. That behavior follows directly from keeping the choices separate.
Using textContent also means a deliverable is inserted as text, rather than interpreted as HTML. If this later reads external data, that is a useful boundary to preserve.
Check the combinations, then the transitions
There are four expected results:
| Revision | Package | Price | Delivery |
|---|---|---|---|
| First draft | Essential | $4,800 | 3 weeks |
| First draft | Complete | $7,200 | 4 weeks |
| Revised scope | Essential | $5,400 | 4 weeks |
| Revised scope | Complete | $8,100 | 5 weeks |
Checking each row catches incorrect data. Checking a sequence catches stale UI. Try Complete → Revised scope → Essential → First draft, and compare the summary, deliverables, and pressed button states after each step.
Then use only Tab, Enter, and Space. Native buttons provide the keyboard interaction; aria-pressed exposes the selected state. The polite live region makes the combined price and delivery summary available to assistive technology. That is a starting point, and it still needs testing with the screen readers and browsers your audience uses.
A revision preview needs an honest label
Both revisions in this page are already in the JavaScript object. The switch previews them. It does not save anything, publish a new version, or update a hosted page.
That distinction matters when sharing a prototype. Label the control as a preview, keep the estimates fictional, and test the actual public URL after deployment. A working local file cannot tell you whether the recipient's link works.
The page stays small because it has a small job: let someone compare scope without allowing the price, timeline, and deliverables to drift apart.
AI disclosure: This article and the adapted code example were generated by an AI agent from an existing HTML example. The example was checked in a browser before publication.
Top comments (0)