kyle halleman home

Making that local-time custom element

June 19, 2025

I did it, I made the local-time custom element (aka web component) I said I was going to. Here’s how.

Making a basic custom element

This will not be a post on all the ins and outs of creating a web component. Instead, this is the minimum amount of information you need to make a basic web component. Outside of the normal documentation on MDN and web.dev articles about web components, I took a lot of inspiration from the work Zach Leatherman has been doing with the web components he’s been making over the years as well as the web components that Heydon Pickering and Andy Bell released as part of Every Layout.

Registering

Custom elements are ES6 classes that extend from the HTMLElement class. You then need to register them using window.customElements.define(). There can only be one definition for a given tag name on a page. I like how Zach uses a static register method on the class to handle registering the component. I could see this being extra useful if you wanted to create your own base class that all your custom elements extend from.

static register(tagName) {
	if ("customElements" in window) {
		customElements.define(tagName || "local-time", LocalTime);
	}
}

Rendering

The connectedCallback lifecycle hook is called each time the element is added to the DOM. This is where we do all our setup and rendering logic. There is also an attributeChangedCallback, which runs whenever an attribute defined in the observedAttributes array changes. This is also often a time when you want to re-render your component. The components in Every Layout implement a render method that gets called in both of these lifecycle hooks (Lit also uses a render method).

The render of local-time is very simple: it sets the textContent.

render() {
	this.textContent = LocalTime.format(this.datetime, this.options);
}

Attributes

I mentioned responding to attribute changes with attributeChangedCallback, and alluded to some of those attributes in my render method. How do you define, set, and get attributes on a custom element? With setters and getters, of course.

For this first iteration of the local-time component, I’m targeting just the attributes I need for my site. To make this more generic, I’d add more to cover more use cases.

I liked an approach I saw in Zach’s line-numbers component, where he defines a static attrs object on the class. He uses this to map a more JavaScript-friendly variable name to a more HTML-friendly name (e.g., manualRender -> manual-render). I’ve found that most of the time, HTML attributes don’t include hyphens (except aria-_ and data-_) but rather are lowercased (tabindex, autocomplete, maxlength, etc.). I made my attributes field an array, then used it in observedAttributes.

static attributes = ["datetime", "day", "month", "timezone", "year"];

static get observedAttributes() {
	return LocalTime.attributes;
}

I’m not really using attributes outside of observedAttributes, but I feel like it makes the source more readable to have a field saying “these are the attributes you can apply.”

Likewise, I added a default field to show what few defaults the component has.

static defaults = {
	day: "numeric",
	month: "long",
	timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
	year: "numeric",
};

You might have noticed these attributes all correspond to options for Intl.DateTimeFormat. For now, I just support attributes I care about. If and when I release this on npm, then I’ll support any possible option to DateTimeFormat as an attribute.

Setting and getting the attributes is basic and boring, but here’s a taste:

get day() {
	return this.getAttribute("day") || LocalTime.defaults.day;
}

set day(val) {
	this.setAttribute("day", val);
}

The logic

You might have noticed in the render method that the real work was being done by a LocalTime.format class method. If you read the last post, you’ll be familiar with its implementation.

static format(dateTime, options) {
	const dateTimeFormat = new Intl.DateTimeFormat(
		window.navigator.language,
		options
	);

	return dateTimeFormat.format(new Date(dateTime));
}

To get the options as an object to pass to Intl.DateTimeFormat, I wrote a special getter.

get options() {
	return {
		day: this.day,
		month: this.month,
		timeZone: this.timezone,
		year: this.year,
	};
}

Accessibility

This is a really simple custom element. It replaces the built-in time element with a useful set of features to format the visible date per a user’s time zone and locale. The time element has semantic meaning. Why else would we be using it? Custom elements don’t have semantic meaning by default. We need to give them that meaning using ARIA. Thankfully, there is an ARIA role that directly corresponds to the time element. It’s time! That works out well, because in the past I’ve made custom elements that should take on the roles of description lists, terms, and details and there are no corresponding ARIA roles. Frustrating. 1

Next steps

You can view the full source by opening your web inspector and looking at the script tag at the bottom of the document of this page. Next, I plan to release this on npm as a standalone package.

  1. As I was writing this, I tripled checked. According to MDN, there are structural roles for description list elements: associationlist, associationlistitemkey, and associationlistitemvalue. But, W3C’s Authoring Practices Guide does not list them, nor can I find them in the WAI-ARIA spec. ↩︎