I built a multi-step wage calculation form for a client. It has native validation, a step wizard, i18n in 4 languages, Tailwind CSS styling, and an Express backend. The entire dependency list is 5 packages. No React. No form library. No state management library.
I built this as a proof of concept for a client — to demonstrate that you don't need a heavy framework to build a real, interactive form application. It handles user input, validates it, and sends it to a server. And it proves something I keep coming back to in my work: you can build serious applications using the platform's native capabilities — if you know what the platform offers.
I've written about the Rule of Least Power and YAGNI before. This project is where those principles meet real code. Let me walk you through how it works, focusing on the two patterns that make it interesting: event-driven communication across Shadow DOM boundaries, and native HTML form validation in a multi-step wizard.
The Setup: One HTML File, One Custom Element
The entire application starts from a single HTML file with a single custom element. No framework bootstrap, no root div, no hydration step. The browser parses the HTML, finds the custom element, and the component takes over.
This is what "using the platform" looks like. Custom Elements are a browser-native component model. They have lifecycle callbacks, attribute observation, and Shadow DOM encapsulation — the same things React components give you, but without a virtual DOM, a reconciler, or a 40KB runtime.
<!-- The entire HTML file. That's it. -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Minimum Wage App</title>
</head>
<body>
<minimum-wage-app></minimum-wage-app>
<script type="module" src="./app.ts"></script>
</body>
</html>
<!-- No framework bootstrap. No root div.
One custom element. One script tag. -->Event Transmission: CustomEvent + bubbles + composed
This is the core architectural pattern. React has good solutions for decoupled communication too — context providers, render props, the pattern I wrote about where the child is the provider and the parent consumes. But all of those are React-specific abstractions. With Web Components, you get decoupling for free using the DOM's native event system.
The child dispatches a CustomEvent with bubbles: true and composed: true. bubbles makes it rise through the DOM tree. composed makes it cross Shadow DOM boundaries. The parent catches it with a standard event listener. The child doesn't know or care who's listening. And this works in any framework — or no framework at all.
This is the same pattern the browser uses for click, input, and submit events. You're not inventing a communication mechanism — you're using the one that's been in every browser for decades. It's looser coupling than props, more discoverable than a global state store, and zero bytes of additional code.
The key line is Object.fromEntries(formData.entries()). The browser's FormData API collects all form values automatically — no useState per field, no controlled components, no two-way binding. One line turns a native form into a plain object.
// Form component: dispatches a CustomEvent when valid
handleSubmit(e: Event) {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
if (form.checkValidity()) {
this.dispatchEvent(
new CustomEvent('form-submit', {
detail: { formData: Object.fromEntries(formData.entries()) },
bubbles: true, // rises through the DOM
composed: true, // crosses Shadow DOM boundaries
})
);
} else {
form.reportValidity(); // native browser validation UI
}
}
// Parent page: catches it with Lit's @event syntax
// (shorthand for addEventListener — works the same way)
html`
<div @form-submit="${this.handleFormSubmit}">
<wage-form></wage-form>
</div>
`Native Validation: Let the Browser Do the Work
Every form library you've ever used is fundamentally doing what the browser already does: checking if a field is required, if it matches a pattern, if a date is in range. HTML's constraint validation API handles all of this natively.
required, min, max, pattern, type="date", type="email" — these aren't suggestions. They're a full validation framework built into the browser. The browser shows accessible error messages, prevents submission, announces errors to screen readers. All for free.
The dirty-state pattern — adding a CSS class on first input — solves the classic UX problem of showing errors before the user has typed anything. One event handler, one CSS rule, zero state management.
<!-- Native HTML validation — zero JavaScript needed -->
<input type="date" name="start_date"
min="2024-06-10" max="2024-08-05" required />
<input type="text" name="location"
pattern="^\d{4,5}$|^[A-Za-z]{2,}$"
required placeholder="Zip or city" />
<!-- The browser validates, shows native error tooltips,
blocks submission. You write zero validation logic. -->
<!-- CSS handles dirty-state error display: -->
<style>
.dirty:invalid + span { display: block; }
span { display: none; }
</style>
<!-- Add 'dirty' class on first input to avoid
flash-of-red on page load.
Note: @input is Lit's shorthand for addEventListener('input', ...).
In vanilla JS you'd write: el.addEventListener('input', handler) -->
<input @input="${(e) => e.target.classList.add('dirty')}" />
<span class="text-red-500">Please enter a valid location</span>Multi-Step Validation Across Shadow DOM
Here's where it gets interesting. In a multi-step wizard, you need to validate the current step before advancing. But the inputs live in slotted content — they're in the parent's DOM, projected into the child's Shadow DOM via slots.
The solution uses slot.assignedElements() to reach into the slotted content and query the actual input elements. Then it calls the browser's native checkValidity() on each one. No form library needed. The browser already knows if each input is valid.
There's a clever trick for error display: when validation fails, the component temporarily shows all steps (so the browser can find the invalid field), calls reportValidity() (which shows the native error tooltip), then hides the steps again. The browser's own UI points to the exact invalid field — better UX than any custom error message.
The validation result bubbles up as another CustomEvent (step-validated), which the parent form catches to enable/disable the submit button. Again — DOM events, not callbacks or state.
// Multi-step wizard: validate before advancing
private validateStep() {
// Query the slot for the current step
const stepSlot = this.shadowRoot
?.querySelector(`slot[name="step${this.currentStep}"]`);
const assignedElements = stepSlot.assignedElements({ flatten: true });
let isValid = true;
assignedElements.forEach((element) => {
element.querySelectorAll('input').forEach((input) => {
if (!input.checkValidity()) isValid = false;
});
});
// Bubble validation result up as a CustomEvent
this.dispatchEvent(new CustomEvent('step-validated', {
detail: { step: this.currentStep, isValid },
bubbles: true, composed: true,
}));
return isValid;
}
private nextStep() {
if (this.validateStep()) {
this.currentStep++;
} else {
// Temporarily show all steps so the browser can
// point to the invalid field with its native tooltip
this.showAllSteps = true;
setTimeout(() => {
this.formElement.reportValidity();
this.showAllSteps = false;
}, 1);
}
}Slots Over Props: Composition Without Coupling
In React, you'd build a multi-step form by passing step content as children or render props. The parent and child are coupled through the component API.
With Web Components, slots provide native content projection. The parent defines what goes in each step. The child controls which slot is visible. Neither needs to know the other's implementation. You can swap the step wizard component entirely without touching the form fields.
This is the Web Components equivalent of composition over inheritance — but it's built into the browser. No children prop, no render callback, no React.cloneElement. Just named slots.
// Parent defines content. Child controls visibility.
// No props drilling. No render callbacks. Just slots.
// wage-form.ts — parent composes steps as slotted content
html`
<wage-form-step .totalStep=${3} .formElement=${this.formElement}>
<div slot="step1">
<input type="date" name="start_date" required />
<input type="text" name="location" pattern="..." required />
</div>
<div slot="step2">
<input type="date" name="birthday_date" required />
</div>
<div slot="step3">
<input type="radio" name="function" value="Bricklayer" required />
<input type="radio" name="function" value="Plumber" required />
</div>
</wage-form-step>
`
// wage-form-step.ts — child renders slots based on step
html`
${this.getStepSlots().map((step) => html`
<slot name="step${step}"
?hidden="${this.currentStep !== step}">
</slot>
`)}
`5 Dependencies. That's the Whole App.
The entire project runs on Lit (3KB runtime), i18next for translations, Tailwind for styling, Express for the backend, and Vite for building. That's it.
Lit is intentionally the thinnest possible layer over Web Components — it gives you reactive properties, efficient re-rendering, and template literals. It doesn't reinvent the DOM. It doesn't add a virtual DOM. It doesn't ship a reconciler. Your components are real Custom Elements that work in any framework or no framework.
Compare this to a typical React form project. Before you write a line of business logic, you've installed react, react-dom, a form library (react-hook-form or formik), a validation schema library (yup or zod), a router, a styling solution, possibly a state management library. That's 10-15 dependencies, each with its own update cycle, breaking changes, and security surface.
{
"dependencies": {
"express": "^4.21.0",
"i18next": "^23.15.2",
"i18next-browser-languagedetector": "^8.0.0",
"lit": "^3.2.0",
"tailwindcss": "^3.4.13"
}
}
// 5 dependencies. That's the entire runtime.
// No React. No Redux. No form library.
// No state management. No virtual DOM.
//
// Compare to a typical React form project:
// react, react-dom, react-hook-form OR formik,
// yup OR zod, react-router, @emotion OR styled-components,
// some state lib, some i18n lib...
// → 10-15 dependencies before you write a line of business logic.When This Approach Fits (And When It Doesn't)
This works well for: small to medium apps with form-heavy flows, multi-step wizards, internal tools, micro-frontends that need to work across frameworks, and any project where you want to minimize runtime overhead.
This doesn't replace React for: large SPAs with complex client-side routing, apps with heavy real-time state (collaborative editing, chat), or teams that are already productive in React and don't have a reason to change.
The point isn't "never use React." It's that for a surprisingly large category of applications — forms, dashboards, content-heavy pages, embedded widgets — you don't need a 40KB framework runtime. The browser gives you components (Custom Elements), styling encapsulation (Shadow DOM), content projection (slots), events (CustomEvent), validation (Constraint Validation API), and data collection (FormData). That's a full application framework, and it ships with every browser.
Try It Yourself
The full source code is on GitHub: github.com/LucaMele/modern-small-form. Clone it, run npm install && npm run dev, and see how a real multi-step form works with 5 dependencies and zero framework overhead.
If you've been building forms with React + react-hook-form + zod, spend 30 minutes reading through this codebase. You might be surprised how much of what you're npm installing, the browser already provides.

