So Coming off some dayjobbing, I've found myself using custom JS elements more often.
The main advantage that I've been leveraging is their excellent encapsulation, we can pack in a bunch of features, have them easy to use without needing any frameworks at all!
Lets go through a simple example, making a clock element with two modes, analog and digital. lets make the analog clock the default but first up lets define the base code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Clock Element</title>
</head>
<body>
<h1>Analog</h1>
<clock-el></clock-el>
<br/>
<h1>Digital</h1>
<clock-el digital></clock-el>
</body>
</html>
Now we also have access to vars in the element so lets set up some root vars and basic styles:
:root {
--clock-size: 200px;
--clock-bg: #fff;
--clock-border: #333;
--hand-hour: #333;
--hand-minute: #555;
--hand-second: red;
--segment-on: #000;
--segment-off: #ccc;
--digital-bg: #eee;
--digital-border: #444;
--digital-padding: 0.5em;
--digital-radius: 0.5em;
}
custom-clock {
display: inline-block;
}
custom-clock[digital] .analog {
display: none;
}
custom-clock:not([digital]) .digital {
display: none;
}
.digital {
font-family: var(--font-family);
font-size: 2em;
color: var(--digital-color);
text-align: center;
}
And now for the juice:
class ClockEl extends HTMLElement {
static get observedAttributes() {
return ['digital']
}
constructor() {
super()
this.attachShadow({ mode: 'open' })
this.shadowRoot.innerHTML = `
<style>
.analog, .digital {
display: none;
}
:host(:not([digital])) .analog {
display: block;
}
:host([digital]) .digital {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 1em;
}
.digital-wrapper {
display: flex;
gap: 0.25em;
background: var(--digital-bg);
border: 2px solid var(--digital-border);
border-radius: var(--digital-radius);
padding: var(--digital-padding);
}
.clock {
width: var(--clock-size);
height: var(--clock-size);
background: var(--clock-bg);
border: 5px solid var(--clock-border);
border-radius: 50%;
}
svg.hand {
width: 100%;
height: 100%;
}
.hand {
stroke-linecap: round;
transform-origin: 50% 50%;
transition: transform 0.05s linear;
}
.hour { stroke: var(--hand-hour); stroke-width: 4; }
.minute { stroke: var(--hand-minute); stroke-width: 3; }
.second { stroke: var(--hand-second); stroke-width: 2; }
.digit {
width: 20px;
height: 40px;
}
.segment {
fill: var(--segment-off);
}
.on {
fill: var(--segment-on);
}
</style>
<div class="analog">
<div class="clock">
<svg class="hand" viewBox="0 0 100 100">
<line id="hour" x1="50" y1="50" x2="50" y2="30" class="hand hour"/>
<line id="minute" x1="50" y1="50" x2="50" y2="20" class="hand minute"/>
<line id="second" x1="50" y1="50" x2="50" y2="15" class="hand second"/>
<circle cx="50" cy="50" r="1" fill="black"/>
</svg>
</div>
</div>
<div class="digital">
<div class="digital-wrapper" id="digital"></div>
</div>
`
}
connectedCallback() {
this._update()
this._interval = setInterval(() => this._update(), 1000)
}
disconnectedCallback() {
clearInterval(this._interval)
}
_update() {
const now = new Date()
const h = now.getHours()
const m = now.getMinutes()
const s = now.getSeconds()
const pad = n => n.toString().padStart(2, '0')
const str = `${pad(h)}${pad(m)}${pad(s)}`
if (this.hasAttribute('digital')) {
const container = this.shadowRoot.getElementById('digital')
container.innerHTML = ''
for (let i = 0; i < str.length; i++) {
const digit = this._createDigit(str[i])
container.appendChild(digit)
if (i === 1 || i === 3) container.appendChild(this._createColon())
}
} else {
const hourEl = this.shadowRoot.getElementById('hour')
const minuteEl = this.shadowRoot.getElementById('minute')
const secondEl = this.shadowRoot.getElementById('second')
const hourDeg = ((h % 12) + m / 60) * 30
const minuteDeg = (m + s / 60) * 6
const secondDeg = s * 6
hourEl.style.transform = `rotate(${hourDeg}deg)`
minuteEl.style.transform = `rotate(${minuteDeg}deg)`
secondEl.style.transform = `rotate(${secondDeg}deg)`
}
}
_createDigit(num) {
const segments = {
0: [1,1,1,1,1,1,0],
1: [0,1,1,0,0,0,0],
2: [1,1,0,1,1,0,1],
3: [1,1,1,1,0,0,1],
4: [0,1,1,0,0,1,1],
5: [1,0,1,1,0,1,1],
6: [1,0,1,1,1,1,1],
7: [1,1,1,0,0,0,0],
8: [1,1,1,1,1,1,1],
9: [1,1,1,1,0,1,1]
}[parseInt(num)] || [0,0,0,0,0,0,0]
const digit = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
digit.setAttribute('viewBox', '0 0 20 40')
digit.classList.add('digit')
const segDefs = [
'M3 1 h14 v4 h-14 z', // A
'M17 3 v16 h-4 v-16 z', // B
'M17 21 v16 h-4 v-16 z', // C
'M3 35 h14 v4 h-14 z', // D
'M3 21 v16 h4 v-16 z', // E
'M3 3 v16 h4 v-16 z', // F
'M3 18 h14 v4 h-14 z' // G
]
for (let i = 0; i < 7; i++) {
const seg = document.createElementNS('http://www.w3.org/2000/svg', 'path')
seg.setAttribute('d', segDefs[i])
seg.classList.add('segment')
if (segments[i]) seg.classList.add('on')
digit.appendChild(seg)
}
return digit
}
_createColon() {
const colon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
colon.setAttribute('viewBox', '0 0 10 40')
colon.style.width = '10px'
colon.innerHTML = `
<circle cx="5" cy="12" r="2" fill="var(--segment-on)" />
<circle cx="5" cy="28" r="2" fill="var(--segment-on)" />
`
return colon
}
}
customElements.define('clock-el', ClockEl)
Lets go through that section by section:
We're defining our custom element class and telling it to extend the `HTMLElement` which gives us all the usual powers that an html element has, this is important cause we need it to have things like width and height and other typical properties
Next we have a static get observedAttributes() which will peek at the element container and return an array or object with the specified attribute values, here we're using it to figure out if the clock needs to be digital or not
The Constructor is where we can start to work on the element, first up we call super() which initialises the element and then we attach a shadow dom to the element so that it works partially as a sandboxed environment, then we can finally crack it open like any other element using its internal .innerHTML property.
Here will stick in the elements styling and html it needs and then we can wrap up the constructor, this is essentially telling the browser to drop this sandboxed code in the tagged location in the html and then we can move on the really fun stuff
connectedCallback() is the internal function that gets run when the element gets added to the main DOM, for our element I am immediately calling the update method and then setting the interval to keep running the update and then likewise the function disconnectedCallback() is run when the element is removed, we need this so that say we had the clock in a tab, when switching tabs it will remove the clock but the interval will still be hanging around trying to run the update
_update() gets called once a second and generates the data we need to jangle the doohickies, I'll gloss over this as you can run whatever code in your updates to serve your element
and then finally to make it all work, we need to name the custom class, so customElements.define('clock-el', ClockEl) is what I went with. in the define function the first parameter is a lowercase string that allows dashes as long as they are not the first or last character, this is what you'll be using in your html code to spawn the lil beasts in, and the second parameter is what you called your custom class
And there it is, the bones of what you need to start rolling your own custom elements without needing any fancy frameworks or build tools. I've embedded a codepen below with my working code, but if you use this in a public project please feel free to give me a shout out on Bluesky (https://bsky.app/profile/dracky3k.bsky.social)
Happy Hunting
Custom Web Elements
fun to make and use with just a sprinkle of JS to get it all working