Web Component: Hand-drawn Checkbox
This is my first try at a Web Component. This one progressively enhances an <input type="checkbox" />. So even if the user blocks JavaScript, a regular HTML checkbox is still rendered. The custom element can be used like this:
<handdrawn-checkbox>
<input type="checkbox" checked />
</handdrawn-checkbox>
It’s a bit verbose, but it’s the only way to ensure that the progressive enhancement works. The element can also be declared this way:
<handdrawn-checkbox></handdrawn-checkbox>
This still works because this custom element adds a default checkbox in its constructor. The drawback is that this renders nothing if the user blocks JavaScript.
Here’s the gist of how this Web Component works:
-
Using the
slotelement, we create a portal in the template where the original checkbox will be inserted. -
Using the
::slottedpseudo-element, we hide the original checkbox. We need to hide it withopacity: 0so that it remains interactive and accessible. We can’t hide it withvisibility: hiddenordisplay: none. -
Using the
:hostpseudo-class (which targets the root of the shadow DOM) we style the SVG element so that it has the same position and dimensions as the checkbox’s. We also usepointer-events: noneso that mouse clicks and touch events can properly reach the (invisible but still interactive) checkbox. -
Finally, using the
connectedCallbackfunction (which is called when the element is added to the document) we render the shadow DOM and listen forchangeevents coming from the checkbox. That way we can redraw our SVG element when the checkbox is toggled.
And that’s it! Broken down like this it seems simple enough but it took me quite a bit to reach this solution. I quite like this technique of augmenting an existing HTML element with a custom element. It’s portable. It’s accessible. And it feels like magic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
<!DOCTYPE html>
<meta charset="utf-8">
<style>
html, body {
height: 100%;
margin: 0;
-webkit-user-select: none;
user-select: none;
}
.container {
height: 100%;
display: flex;
justify-content: space-evenly;
align-items: center;
color: rgba(237, 75, 26, 0.82);
}
input[type="checkbox"] {
margin: 0;
width: 10em;
height: 10em;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
</style>
<body>
<div class="container">
<handdrawn-checkbox><input type="checkbox" checked /></handdrawn-checkbox>
<handdrawn-checkbox></handdrawn-checkbox>
</div>
<script type="text/javascript">
"use strict";
var template = document.createElement('template');
template.innerHTML = `
<style>
:host {
position: relative;
display: inline-block;
}
:host svg {
position: absolute;
pointer-events: none;
stroke-linecap: round;
stroke-width: 0.5px;
}
:host .border {
stroke: black;
fill: none;
}
::slotted(input) {
opacity: 0;
}
</style>
<svg viewbox='0 0 10 10'>
<path class='border'></path>
<path class='line' stroke='currentColor'></path>
<path class='line' stroke='currentColor'></path>
</svg>
<slot></slot>
`;
class HandDrawnCheckbox extends HTMLElement {
constructor() {
super();
var checkbox = this.querySelector('input[type="checkbox"]');
if (!checkbox) {
var defaultCheckbox = document.createElement('input');
defaultCheckbox.setAttribute('type', 'checkbox');
this.appendChild(defaultCheckbox);
}
}
connectedCallback() {
var checkbox = this.querySelector('input[type="checkbox"]');
var templateClone = template.content.cloneNode(true);
var pathEls = templateClone.querySelectorAll('.line');
var borderEl = templateClone.querySelector('.border');
borderEl.setAttribute('d', getBorderPath());
toggleVisibility(pathEls, checkbox);
var shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(templateClone);
checkbox.addEventListener('change', function() {
toggleVisibility(pathEls, this)
});
}
}
function toggleVisibility(lines, checkbox) {
var visible = checkbox.checked;
var line1 = lines[0];
var line2 = lines[1];
if (visible) {
line1.setAttribute('d', getLine1Path());
line2.setAttribute('d', getLine2Path());
line1.style.strokeDasharray = line1.style.strokeDashoffset = getPathLength(line1);
line2.style.strokeDasharray = line2.style.strokeDashoffset = getPathLength(line2);
setTimeout(() => {
line1.style.transition = line2.style.transition = 'stroke-dashoffset 200ms ease';
line2.style['transition-delay'] = '200ms';
line1.style.strokeDashoffset = line2.style.strokeDashoffset = 0;
}, 10);
} else {
line1.style.strokeDashoffset = getPathLength(line1);
line2.style.strokeDashoffset = getPathLength(line2);
}
}
function getPathLength(path) {
return Math.ceil(path.getTotalLength()) + 1; // Handle rounding issues
}
var crossCorner1Range = [0.5, 1.5];
var crossCorner2Range = [8.5, 9.5];
var borderCorner1Range = [0.2, 0.8];
var borderCorner2Range = [9.2, 9.8];
function getBorderPath() {
return `M${getPoint(borderCorner1Range)} ${getPoint(borderCorner1Range)}L${getPoint(borderCorner2Range)} ${getPoint(borderCorner1Range)}M${getPoint(borderCorner2Range)} ${getPoint(borderCorner1Range)}L${getPoint(borderCorner2Range)} ${getPoint(borderCorner2Range)}M${getPoint(borderCorner2Range)} ${getPoint(borderCorner2Range)}L${getPoint(borderCorner1Range)} ${getPoint(borderCorner2Range)}M${getPoint(borderCorner1Range)} ${getPoint(borderCorner2Range)}L${getPoint(borderCorner1Range)} ${getPoint(borderCorner1Range)}M${getPoint(borderCorner1Range)} ${getPoint(borderCorner1Range)}`;
}
function getLine1Path() {
return `M${getPoint(crossCorner1Range)} ${getPoint(crossCorner1Range)}L${getPoint(crossCorner2Range)} ${getPoint(crossCorner2Range)}`;
}
function getLine2Path() {
return `M${getPoint(crossCorner2Range)} ${getPoint(crossCorner1Range)}L${getPoint(crossCorner1Range)} ${getPoint(crossCorner2Range)}`;
}
function getPoint(range) {
var [start, end] = range;
var diff = end - start;
return +(start + Math.random() * diff).toFixed(2);
}
customElements.define('handdrawn-checkbox', HandDrawnCheckbox);
</script>
</body>