Designing Web Components to display Bluesky Likes

9 min read
This is an unpublished draft. It may be incomplete, contain errors or be completely wrong. Please check back later for the finished version, or subscribe to my feed to be notified.
Screenshot of the Bluesky Likes components

Just want the components? Here you go: Demo Repo NPM

A love letter to the Bluesky API

I’m old enough to remember the golden Web 2.0 era, when many of today’s big social media platforms grew up. A simpler time, when the Web was much more extroverted. It was common for websites to embed data from others (the peak of mashups), and prominently feature widgets from various platforms to showcase a post’s likes or shares.

Especially Twitter was so ubiquitous that the number of Twitter shares was my primary metric for how much people were interested in a blog post I wrote. Then, websites started progressively becoming walled gardens, guarding their data with more fervor than Gollum guarding the Precious. Features disappeared or got locked behind API keys, ridiculous rate limits, expensive paywalls, and other restrictions. Don’t get me wrong, I get it. A lot of it was reactionary, a response to abuse — the usual reason we can’t have nice things. And even when it was to stimulate profit — it is understandable that they want to monetize their platforms. People gotta eat.

I was recently reading this interesting article by Salma Alam-Naylor. The article makes some great points, but it was something else that caught my eye: the widget of Bluesky likes at the bottom.

Screenshot of Salma's Bluesky likes widget
Salma's Bluesky likes widget that inspired these

I mentioned it to my trusty apprentice Dmitry who discovered the API was actually much simpler than what we’ve come to expect. Later, it turned out Salma has even written an entire post on how to implement the same thing on your own site.

The openness of the API was so refreshing. Not only can you read public data without being authenticated, you don’t even need an API key! Major nostalgia vibes.

It seemed the perfect candidate for a web component that you can just drop in to a page, give it a post URL, and it will display the likes for that post. I just had to make it, and of course use it right here.

Web Components that use API data have been historically awkward. Let’s set aside private API keys or APIs that require authentication even for reading public data for a minute. Even for public API keys, where on Earth do you put them?! There is no established pattern for passing global options to components. Attributes need to be specified on every instance, which is very tedious. So every component invents their own pattern: some bite the bullet and use attributes, others use static class fields, data-* attributes on any element or on specific elements, separate ES module exports, etc. None of these are ideal, so components often do multiple. Not to mention the onboarding hassle of creating API keys if you want to try multiple APIs.

The Bluesky API was a breath of fresh air: just straightforward HTTP GET requests with straightforward JSON data responses.

Sing with me!
🎶 all you need is fetch 🎺🎺🎺
🎶 all you need is fetch 🎺🎺🎺
🎶 all you need is fetch, fetch 🎶
🎶 fetch is all you need 🎶

Building a component that used it was a breeze.

Two Components for displaying Bluesky likes

In the end I ended up building two separate components, published under the same bluesky-likes npm package:

They can be used separately, or together. E.g. to get a display similar to Salma’s widget, the markup would look like this:

<script src="https://unpkg.com/bluesky-likes" type="module"></script>

<h2>
	<bluesky-likes src="https://bsky.app/profile/lea.verou.me/post/3lhygzakuic2n"></bluesky-likes>
	likes on Bluesky
</h2>

<p>
	<a href="https://bsky.app/profile/lea.verou.me/post/3lhygzakuic2n">Like this post on Bluesky to see your face on this page</a>
</p>

<bluesky-likers src="https://bsky.app/profile/lea.verou.me/post/3lhygzakuic2n"></bluesky-likers>

And the result would be similar to this:

Requests are aggressively cached across component instances, so no, the fact that it’s two separate components doesn’t mean you’ll be making duplicate requests. Additionally, these ended up pretty lightweight: the whole package is ~2 KB minified & gzipped and dependency-free.

API Design for Web Components

Design Principles

Per my usual API design philosophy, I wanted these components to make common cases easy, complex cases possible, and not have usability cliffs, i.e. the progression from the former to the latter should be smooth.

API design curve

What does that mean for a web component?

Common use cases should be easy

You should have a good result by simply including the component and specifying the minimum input to communicate your intent, in this case, a Bluesky post URL.

Complex use cases should be possible

You should be able to fully gut the component’s HTML and CSS to fully suit your needs if your needs are very specific, though it’s okay if that requires work (e.g. subclassing, overriding JS properties or methods etc.).

In this case, all component styles and templates are exposed as static properties on the component class that you can modify or replace, either directly on it, or in your own subclass.

No usability cliffs

Making common things easy and complex things possible is not enough for a good API. Most use cases fall somewhere in between the two extremes. If a small increase in use case complexity throws you off the deep end in API complexity, you’re gonna have a bad time.

The API should have enough customization hooks that common customizations do not require going through the same flow as full customization and recreating everything.

For web components, this might mean:

The 99-99 rule of Web Components

The Ninety-Ninety Rule tells us that the last 10% of the work takes 90% of the time. I would argue that for web components, it’s more like a 99-99 Rule.

Take these Bluesky Likes components as an example. They are the poster child for the kind of straightforward, simple component that does one thing well. But web components are a bit like children: if most people realized upfront how much work they are, way fewer would get made.

Building a web component is always more work than it looks

Even when the core functionality is very quick to implement, there are so many other things that need to be done:

And this is without any additional functionality creeping up. Some examples below.

My first prototype of <bluesky-likes> always had an internal link in its shadow DOM that opened the full list of likers in a new tab. There were several usability, accessibility, and i18n issues:

Often components will solve these types of problems the brute force way, by replicating all <a> attributes on the component itself, which is both heavyweight and a maintenance nightmare over time.

Instead, we went with a slightly unconventional solution: the component detects whether it’s inside a link, and removes its internal <a> element in that case. This solves all four issues at once; the answer to all of them is to just wrap it with the link of your choice. This allowed us to just pick a good default title attribute, and not have to worry about it.

It’s not perfect: now that :host-context() is removed, there is no way for a component to style itself differently when it’s inside a link, to e.g. control the focus outline. And the detection is not perfect, because doing it perfectly would incur a performance penalty for little gain. But the tradeoffs so far seem worth it.

Keyboard accessibility in <bluesky-likers>

My first prototype of <bluesky-likers> wrapped all avatars with regular links (they just had rel="nofollow" and target=_blank"). Quite reasonable, right? And then it dawned on me: this meant that if a keyboard user had the misfortune of stumbling across this component in their path, they would have needed to hit Tab 101 (!) times in the worst case to escape it. Yikes on bikes! 😱

So what to do? tabindex="-1" would remove the links from the tabbing order, fixing the immediate problem. But then how would keyboard users actually access them? Would I need to implement a whole arrow key based navigation system for them? Fortunately, before going down that path I stepped back and asked myself: “Do they need to?”. These links are entirely auxiliary; in Salma’s original widget these were not links at all. Not only is it not common to need to click through the profiles of users who liked a post, it was also already possible via the Bluesky “Liked By” page which was usually already linked in the rest of the UI (e.g. via <bluesky-likes>)!

In the end, what I added was a default slot to specify content that is visually hidden unless focused. This way, in the unlikely scenario that someone is using this component by itself with no other links around it, they can simply nest content within it to provide a link to the full list of likers or even other context. And because the visually hidden styling is applied to the slot this also allows providing fallback content to everyone.

The pain of creating locale-aware web components

Both components display formatted numbers: <bluesky-likes> displays the total number of likes, and <bluesky-likers> displays the number of likes not shown (if any).

My first prototypes simply called .toLocaleString("en", {notation: "compact"}) on the number. But this meant that in a non-English site, large numbers would still be formatted like “1K” etc. Thankfully, Intl.NumberFormat (what toLocaleString() uses internally) handles the complexity of what to do for different locales, but you still need to pass the right locale to it.

What language would that be? If you answered this.lang, you’d be wrong. That gives you the value of an element’s lang attribute, but the actual computed language of the element is inherited from the nearest ancestor with a lang attribute.

Something like this is a good compromise:

const lang = this.lang
			|| this.parentNode.closest("[lang]")?.lang
			|| this.ownerDocument.documentElement.lang
			|| "en";

This is what these components use. It’s not perfect, but it covers a good majority of cases with minimal performance impact. Notably, the cases it misses is when the component is inside a shadow tree but is getting its language from an element outside that shadow tree, that is not the root element.

If you wanted to do it properly, it would be a lot more involved. Possibly something like this, which you might want to abstract into a helper function.

let lang = this.lang;
if (!lang) {
	let langElement = this;
	while (!(langElement = langElement.closest("[lang]"))) {
		let root = langElement.getRootNode();
		let host = root.host ?? root.documentElement;
		langElement = host;
	}

	lang = langElement?.lang || "en";
}

But, actually, if you really needed to do it properly, even now you wouldn’t be done! What about dynamically reacting to changes? Any element’s lang attribute could change at any point.

Er, take my advice don’t go there. Pour yourself a glass of wine (replace with your vice of choice if wine is not your thing), watch an episode of your favorite TV show and try to forget about this.

Some of you will foolishly not take my advice. I already hear some voices at the back crying “But what about mutation observers?”. Oh my sweet summer child.

What are you going to observe? The element with the lang attribute you just found? WRONG. What if a lang attribute is added to an between that and your component? That will affect the component’s language, but it won’t be picked up. And since you can’t observe an element’s line of ancestors, your only recourse is to watch the entire subtree.

I told you to not think about it. You didn’t listen. It’s still not too late to go for that wine.

Still here? Damn, you’re stubborn. Here’s how to do it if you really need to. Once you’ve detected the language (e.g. suppose we detected el), generate a CSS rule like this and add it to your shadow DOM:

:host(:lang(el)) {
	--lang: el;
}

Then, register the --lang property, add a transition on it, and watch for changes via transition events (or just use Style Observer if you’re already using it).

See? I told you not to think about it.

Do I think that’s a good idea? In most cases, absolutely not. Even if effort, complexity, and scope was no issue, the performance impact of doing it properly is not worth it for the tiny fraction of additional use cases you’re covering. I can only see it being a good idea in very specific cases, or if you have a reason to strive for this kind of perfection (e.g. guidelines from higher up).

A lot of web components development is about making exactly these kinds of tradeoffs between how close you want to get to the way a native element would behave, because going all the way and expecting the same dynamic behavior as if it’s a native HTML element is rarely the best balance of tradeoffs.

That said, this should be easier. It should not need to require balancing tradeoffs. It’s just reading a component’s language for crying out loud!

In September at TPAC we made progress in getting WHATWG to standardize a way to actually read locale information and react to future changes, but (to my knowledge) not much has happened since. I hope this dramatic reenactment generates some empathy among WHATWG folks on what web components developers are up against.

🚢 it, squirrel!

It’s all fun and games and then you ship.

If you’re not careful, building a web component can be a potentially unbounded task. Some tasks are definitely necessary, e.g. accessibility, i18n, performance, etc, but there comes a point where you’re mainly petting.

So here they are: Demo Repo NPM

They’re far from perfect. Yes, they could be improved in a number of ways. But they’re good enough to use here, and that will do for now. If you want to improve them, pull requests are welcome (check with me for big features though). And if you use them on a for-profit site, I do expect you to fund their development. That’s an ethical and social expectation, not a legal one (but it will help prioritization, and that’s in your best interest too).

If you’ve used them, I’d love to see what you do with them!