Web Components Without the Framework: Lessons from Production

BudgetFlow uses htmx for server requests and its hx-live extension for local state. A few elements still need their own behaviour on every page they appear on: a modal, the messages the server shows after an action, and the charts.

We built those elements as web components without a framework. There are twelve now: ui-modal; ui-message, ui-message-toast and ui-message-modal for flashed messages; seven chart elements such as flow-budget and wealth-graph; and live-ping on an admin page. The details below are from the components in this repo.

Why Web Components at All?

A custom element keeps its markup, styles and behaviour in one definition, and the browser runs its code whenever the element enters the page. That matters with htmx. When a swap inserts a <ui-modal> or a <flow-budget>, the element sets itself up in connectedCallback, and no page script has to find and initialise it.

The server renders the elements like any other HTML. Flask fills in their attributes, such as the text of a message or the data of a chart, and the element reads them.

How a Component Is Defined

ui-message-toast shows the basic pattern. A template holds the markup and styles, and a short script registers the element:

<template id="ui-message-toast-template">
	<style>
		@scope {
			.toast {
				padding: var(--space-4);
				background-color: var(--surface-color);
				border: 3px solid transparent;
				&.success { border-color: var(--success-color); }
				&.error   { border-color: var(--error-color);   }
			}
		}
	</style>

	<div class="toast">
		<div class="text"></div>
	</div>
</template>

<script>
customElements.get("ui-message-toast") || customElements.define("ui-message-toast", class extends HTMLElement {
	connectedCallback() {
		const template = document.getElementById("ui-message-toast-template")
		this.append(template.content.cloneNode(true))

		this.querySelector(".text").innerText = this.getAttribute("text")

		const toast = this.querySelector(".toast")
		const category = this.getAttribute("category")
		if (category) {
			toast.classList.add(category)
		}

		// Remove after delay
		setTimeout(() => this.remove_toast(), 6000)
	}
})
</script>

The base template renders the modal and message definitions once in the <head>, so they run before the parser creates the elements in the body. The customElements.get() check skips a definition when the element is already registered. A message itself is plain HTML with two attributes:

<ui-message-toast text="{{ text }}" category="{{ category }}"></ui-message-toast>

Light DOM with @scope

The message and chart elements clone their template into themselves, without a shadow root. A <style> block with @scope and no selector applies its rules only inside the element that contains it, which after cloning is the custom element. The global stylesheet still reaches the content, so a message uses the same color tokens and typography as the rest of the page.

That covers most of what we wanted from encapsulation. The rules of one component cannot leak into the page, and the page does not need to know which classes a component uses inside.

Where Shadow DOM Helps

ui-modal is the one component with a shadow root. Its backdrop and panel cover the viewport above the page, and no page style should change their layout. The content of a modal stays in the light DOM and fills a slot, so a form inside a modal gets the same styles as a form on a page:

<template id="ui-modal-template">
	<style>
		:host {
			position: fixed;
			inset: 0;
			z-index: 2000;
		}

		.panel {
			width: min(26rem, 95vw);
			padding: var(--space-6);
			border-radius: 20px;
			background-color: var(--surface-color);
			overflow-y: auto;
		}
	</style>

	<div class="backdrop">
		<div class="panel" part="container">
			<button class="close-button" type="button" aria-label="Close">&times;</button>
			<slot></slot>
		</div>
	</div>
</template>

Custom properties such as --surface-color cross the shadow boundary, so the panel follows the light and dark theme of the page. The panel is exposed as part="container", so a page can change its width with ::part(container) without access to the rest of the shadow tree.

Composing Components

ui-message-modal shows a flashed message in a modal. Its template contains a ui-modal, and the element removes itself once the modal is gone:

// Inside ui-message-modal's connectedCallback
this.querySelector("ui-modal").addEventListener("ui-modal-removed", () => {
	this.remove()
})

ui-modal handles closing, the backdrop click, the Escape key and the closing animation, and dispatches ui-modal-removed at the end. The message modal only supplies the icon and the text.

Responding to htmx Requests

The modal closes itself after a successful form submission from inside it:

// Inside ui-modal's setup_event_listeners
this.addEventListener("htmx:after:request", (e) => {
	const { response, sourceElement } = e.detail.ctx
	if (response.raw.ok && sourceElement.nodeName === "FORM") {
		this.close()
	}
})

A failed response leaves the modal open, so the user can read the error and correct the form. The listener uses htmx:after:request because htmx triggers it on the form that sent the request, and the event bubbles up to the modal. htmx:before:swap only fires on the target of the swap, which is not the form.

Flashed messages arrive with any htmx response. The server appends each message to the response as an out-of-band swap, for example <div hx-swap-oob="beforeend:#flashed_toasts">, and htmx inserts the element into the container for its kind. The element sets itself up when it connects, the same way as on a full page load.

The charts reload through htmx as well. flow-budget carries hx-get and hx-trigger attributes, so an event from another part of the page makes htmx fetch a new <flow-budget> with fresh data and swap it in:

<flow-budget
	hx-get="{{ budget_view.chart_refresh_url }}"
	hx-trigger="pockets-changed from:body, flows-changed from:body"
	nodes='{{ nodes | tojson | safe }}'
	links='{{ links | tojson | safe }}'
></flow-budget>

Charts as Components

The seven chart elements are JavaScript modules that wrap ECharts. The server passes the data as JSON attributes, and the element draws the chart when it connects. ECharts loads asynchronously, so the element checks that it is still on the page before it draws:

load_echarts().then((echarts) => {
	// The element left the page while ECharts loaded
	if (!this.isConnected) {
		return
	}
	this.render_chart(echarts)
})

Every swap creates a new element, so the old one has to release its chart. disconnectedCallback stops the resize observer, removes the document listener and disposes of the ECharts instance:

disconnectedCallback() {
	this.resize_observer.disconnect()
	document.removeEventListener("htmx:config:request", this.send_demo_state)
	this.chart?.dispose()
	this.chart = null
}

What's Painful

There is no built-in template syntax for dynamic content. The components set text and classes on their cloned templates by hand, and ui-message-modal builds its icon with document.createElement(). A component with many changing rows would take more code.

Attributes are strings, so structured data goes through JSON. The server serialises it with Jinja's tojson filter, and the chart elements read it back with JSON.parse().

::slotted() can style direct children of a slot, but not their descendants. ui-modal therefore styles only its own backdrop, panel and close button, and leaves the content to the page.

Events take care. A listener only works if the event reaches the element it is attached to, as the choice between htmx:after:request and htmx:before:swap above shows. We inspect the event target and its composed path in DevTools before changing a listener.

Why We Kept This Approach

For a dozen small elements, native web components have been enough. We can read and change every line in this repo, and the components work with the HTML Flask renders.

The division is clear in practice: web components package reusable elements, htmx requests server-rendered HTML, and hx-live manages local state where a page needs it. Most of the components use the light DOM with @scope, and only the modal needs a shadow root.

If the components grow more complex, we would reconsider how much DOM code to maintain by hand. For the current set, the plain files are easy to work on.

Start Your Financial Journey Today

Take control of your financial future with visual planning and dynamic budgeting. Sign up now and start managing your budget with confidence!