Our great sponsors
-
turbo
The speed of a single-page web application without having to write any JavaScript (by hotwired)
-
SurveyJS
Open-Source JSON Form Builder to Create Dynamic Forms Right in Your App. With SurveyJS form UI libraries, you can build and style forms in a fully-integrated drag & drop form builder, render them in your JS app, and store form submission data in any backend, inc. PHP, ASP.NET Core, and Node.js.
I'll link to commits in my fork without any (intermediate) demo, as all those changes don't have much impact on the element's behavior, as seen by a reader of the web page (if you're interested in what it changes when looked at through the DevTools, then clone the repository, run npm install, npm run start, then checkout each commit in turn), except in some specific situations. The final state is available here if you want to play with it in your DevTools.
This handles disconnection (as could be done by any destructive change to the DOM, like navigating with Turbo or htmx, I'm not even talking about using the element in a JavaScript-heavy web app) but not reconnection though, and we've exited early from the connectedCallback to avoid initializing the element twice, so this change actually broke our component in these situations where it's moved around, or stashed and then reinserted. To fix that, we need to always call addSparkles in connectedCallback, so move all the rest into an if, that's actually as simple as that… except that when the user prefers reduced motion, sparkles are never removed, so they keep piling in each time the element is connected again. One way to handle that, without introducing our housekeeping of individual timers, is to just remove all sparkles on disconnection. Either that or conditionally add them in connectedCallback if either we're initializing the element (including attaching the shadow DOM) or the user doesn't prefer reduced motion. The difference between both approaches is in whether we want the small animation when the sparkles appear (and appearing at new random locations). I went with the latter.
This handles disconnection (as could be done by any destructive change to the DOM, like navigating with Turbo or htmx, I'm not even talking about using the element in a JavaScript-heavy web app) but not reconnection though, and we've exited early from the connectedCallback to avoid initializing the element twice, so this change actually broke our component in these situations where it's moved around, or stashed and then reinserted. To fix that, we need to always call addSparkles in connectedCallback, so move all the rest into an if, that's actually as simple as that… except that when the user prefers reduced motion, sparkles are never removed, so they keep piling in each time the element is connected again. One way to handle that, without introducing our housekeeping of individual timers, is to just remove all sparkles on disconnection. Either that or conditionally add them in connectedCallback if either we're initializing the element (including attaching the shadow DOM) or the user doesn't prefer reduced motion. The difference between both approaches is in whether we want the small animation when the sparkles appear (and appearing at new random locations). I went with the latter.
But Safari 16.3 and earlier still represent more than a third of users on macOS, and more than a quarter of users on iOS! (according to CanIUse) To widen browser support, I therefore added a workaround, which consists of injecting a element in the shadow DOM. Contrary to the constructible stylesheet, styles cannot be shared by all elements though, as we've seen above, so we only conditionally fallback to that approach, and continue using a constructible stylesheet everywhere it's supported.
- sheet = new CSSStyleSheet(); - sheet.replaceSync(css); + if (supportsConstructibleStylesheets) { + sheet = new CSSStyleSheet(); + sheet.replaceSync(css); + } else { + sheet = document.createElement("style"); + sheet.textContent = css; + } } - this.shadowRoot.adoptedStyleSheets = [sheet];``` + if (supportsConstructibleStylesheets) { + this.shadowRoot.adoptedStyleSheets = [sheet]; + } else { + this.shadowRoot.append(sheet.cloneNode(true)); + }
Enter fullscreen mode Exit fullscreen modeOther possible improvements
I stopped there but there's still room for improvement.
For instance, the
number-of-sparkles
attribute is read once when the element is connected, so changing the attribute afterwards won't have any effect (but will have if you disconnect and then reconnect the element). To handle that situation (if only because you don't control the order of initialization when that element is used within a JavaScript-heavy application with frameworks like React, Vue or Angular), one would have to listen to the attribute change and update the number of sparkles dynamically. This could be done either by removing all sparkles and recreating the correct number of them (withaddSparkles()
), but this would be a bit abrupt, or by reworking entirely how sparkles are managed so they could adapt dynamically (don't recreate a sparkle, let it expire, when changing the number of sparkles down, or create just as many sparkles as necessary when changing it up). I feel like this would bump complexity by an order of magnitude, so it's probably not worth it for such an element.The number of sparkles could also be controlled by a property reflecting the attribute; that would make the element more similar to built-in elements. Once the above is in place, this hopefully shouldn't be too hard.
That number of sparkles is expected to be, well, a number, and is currently parsed with
parseInt
, but the code doesn't handle parsing errors and could set the number of sparkles toNaN
. Maybe we'd prefer using the default value in this case, and similarly for a zero or negative value; basically defining the attribute as a number limited to only positive numbers with fallback.All this added complexity is, to me, what separates so-called HTML web components from others: they're designed to be used from HTML markup and not (or rarely) manipulated afterwards, so shortcuts can be taken to keep them simple.
Still speaking of that number of sparkles, the timers that create new sparkles are entirely disconnected from the animation that also makes them disappear. The animation length is actually configurable through the
--sparkly-text-animation-length
CSS custom property, but the timers delay is not configurable (a random value between 2 and 3 seconds). This means that if we set the animation length to a higher value than 3 seconds, there will actually be more sparkles than the configured number, as new sparkles will be added before the previous one has disappeared. There are several ways to fix this (if we think it's a bug –this is debatable!– and is worth fixing): for instance we could use the Web Animations API to read the computed timing of the animation and compute the timer's delay based on this value. Or we could let the animation repeat and move the element onanimationiteration
, rather than remove it and add another, and to add some randomness it could be temporarily paused and then restarted if we wanted (with a timer of some random delay). The code would be much different, but not necessarily more complex.Regarding the animation events (whether
animationend
like it is now, or possiblyanimationiteration
), given that they bubble, they could be listened to on a single parent (the element itself –filtering out possible animations on light DOM children– or an intermediate element inserted to contain all sparkles). This could hopefully simplify the code handling each sparkle.Last, but not least, the
addSparkles
andaddSparkle
methods could be made private, as there's no reason to expose them in the element's API.Final words
Had I started from scratch, I probably wouldn't have written the element the same way. I tried to keep the changes small, one step at a time, rather than doing a big refactoring, or starting from scratch and comparing the outcome to the original, as my goal was to specifically show what I think could be improved and how it wouldn't necessarily involve big changes. Going farther, and/or possibly using a helper library (I have written earlier about their added value), is left as an exercise for the reader.