A fully functional multi-step subscription form built with zero dependencies — no React, no Vue, no bundler. Just native browser APIs used the way frameworks use them internally.
Originally a Frontend Mentor UI challenge. I used it as an excuse to build a mini-framework from scratch and understand what tools like React and Vue actually abstract away.
Built on the History API (pushState / popstate). Handles:
- Client-side navigation without page reloads
- Back/forward browser button support via the
popstateevent - Active link state synced to the current route
- An
addToHistoryflag that separates "navigate" from "render" — sopopstatecan rerender without double-pushing to the history stack
A Proxy-based global store that fires custom DOM events on every state mutation. This is the same mental model behind Vue 3's reactivity system — intercept set, notify subscribers.
// Setting a value automatically fires a custom event
app.store.selectedPlan = { name: 'arcade', price: '$9/mo', yearly: false };
// → dispatches `selectedPlanChanged` on window
// → any component listening re-renders itselfMutation has one important constraint: array mutations must use = assignment, not .push() / .splice(), or the Proxy trap won't fire. This is documented inline in the code.
Three functions — loadData, updateData, clearData — handle syncing the store to localStorage. State survives page refresh and is cleared on form completion.
A small utility class that fetches and injects component-scoped CSS into Shadow DOM roots via fetch(). Keeps styles encapsulated without a build step.
Each UI step is a native HTMLElement subclass registered via customElements.define. They use Shadow DOM for style encapsulation and <template> elements for HTML stamping.
| Component | Route | Responsibility |
|---|---|---|
<personal-info> |
/ |
Name, email, phone with live validation |
<select-plan> |
/select-plan |
Plan selection + monthly/yearly toggle |
<add-ons> |
/add-ons |
Optional add-on selection, price-aware |
<summary-page> |
/summary |
Order review, total calculation, confirm gate |
<confirm-page> |
/completed |
Confirmation screen, clears store |
<content-header> |
— | Shared slot-based header (title + description) |
<navigation-buttons> |
— | Shared back/next navigation, delegates to router |
window.app
├── app.store → Proxy-based reactive store
└── app.router → History API router
Store mutation
└── fires CustomEvent on window
└── components subscribe via addEventListener
└── re-render themselves
There is no virtual DOM, no diffing, no framework scheduler. Components listen to store events and update their own Shadow DOM directly.
/ (personal-info)
→ /select-plan
→ /add-ons
→ /summary
→ /completed
State is accumulated in the store at each step and persisted to localStorage. On the summary page, the Confirm button is disabled until all three data keys (personalInfo, selectedPlan, addOns) are present and valid.
PersonalInfo validates inputs in real time using regex:
- Name — letters, accented characters, hyphens, apostrophes
- Email — standard RFC 5321 pattern
- Phone — Egyptian mobile format (
01[0|1|2|5]XXXXXXXX)
Error states are applied as CSS classes on the parent element, keeping validation logic separate from styling.
This project is a few years old. Knowing what I know now:
- Route map instead of switch — a
{ '/path': 'component-name' }object would scale better than the switch statement in the router - Generic store events —
store:changed:${property}instead of hardcoded event names per property - Remove
window.app— global namespace coupling is a design smell; a proper module singleton pattern would be cleaner - CSS custom properties — theming through design tokens rather than per-component raw values
These are deliberate tradeoffs I made at the time (or didn't know to make). I've left the code as-is to keep it honest.
No build step required. Just serve the files over HTTP — browsers block fetch() on file:// protocol.
# Using Python
python -m http.server 3000
# Using Node
npx serve .Then open http://localhost:3000.
- Native Web Components (
HTMLElement,customElements, Shadow DOM,<template>) - History API (
pushState,popstate) - ES Modules (native
import/export, no bundler) Proxyfor reactive statefetch()for CSS loadinglocalStoragefor persistence- Zero npm packages
