One of my favorite product design principles is Alan Kay’s “Simple things should be simple, complex things should be possible”.
[1]
I had been saying it almost verbatim long before I encountered Kay’s quote.
Kay’s maxim is deceptively simple, but its implications run deep.
It isn’t just a design ideal — it’s a call to continually balance friction, scope, and tradeoffs in service of the people using our products.
This philosophy played a big part in Prism’s success back in 2012,
helping it become the web’s de facto syntax highlighter for years, with over 2 billion npm downloads.
Simple things were easy: All it took to highlight code on a webpage was including two files, a JS file and a CSS file.
No markup changes.
No JS glue code.
Styling used readable CSS class names.
Even adding new languages — the most common “complex” use case — required far less knowledge and effort than alternatives.
At the same time, highly complex things were possible: Prism exposed a deep extensibility model so plugin authors could patch internals and dramatically alter behavior.
These choices were not free.
The friendly styling API increased clash risk, and deep extensibility reduced encapsulation.
These were conscious, hard, tradeoffs.
Since Alan Kay was a computer scientist, his quote is typically framed as a PL or API design principle,
but that sells it short.
It applies to a much, much broader class of interfaces.
This distinction hinges on the distribution of use cases.
Products often cut scope by identifying the ~20% of use cases that drive ~80% of usage — aka the Pareto Principle.
Some products, however, have such diverse use cases that Pareto doesn’t meaningfully apply to the product as a whole.
There are common use cases and niche use cases, but no clean 20-80 split.
The tail of niche use cases is so long, it becomes significant in aggregate.
For lack of a better term, I’ll call these long‑tail UIs.
Nearly all creative tools are long-tail UIs.
That’s why it works so well for programming languages and APIs — both are types of creative interfaces.
But so are graphics editors, word processors, spreadsheets, and countless other interfaces that help humans create artifacts — even some you would never describe as creative.
Example: Google Calendar
You wouldn’t describe Google Calendar as a creative tool, but it is a tool that helps humans create artifacts (calendar events).
It is also a long-tail product:
there is a set of common, conceptually simple cases (one-off events at a specific time and date),
and a long tail of complex use cases (recurring events, guests, multiple calendars, timezones, etc.).
Indeed, Kay’s maxim has clearly been used in its design.
The simple case has been so optimized that you can literally add a one hour calendar event with a single click (using a placeholder title).
A different duration can be set after that first click through dragging
[2].
But almost every edge case is also catered to — with additional user effort.
Google Calendar is squarely a long-tail UI.
The Pareto Principle is still useful for individual features, as they tend to be more narrowly defined.
E.g. there is a set of spreadsheet formulas (actually much smaller than 20%) that drives >80% of formula usage.
While creative tools are the poster child of long-tail UIs,
there are long-tail components in many transactional interfaces such as e-commerce or meal delivery (e.g. result filtering & sorting, product personalization interfaces, etc.).
Filtering UIs are another big category of long-tail UIs, and they involve so many tradeoffs and tough design decisions you could literally write a book about just them.
Airbnb’s filtering UI here is definitely making an effort to make simple things easy with (personalized! 😍) shortcuts and complex things possible via more granular controls.
Picture a plane with two axes: the horizontal axis being the complexity of the desired task (from the user’s perspective),
and the vertical axis the cognitive and/or physical effort users need to expend to accomplish their task using a given interface.
Following Kay’s maxim guarantees these two points:
Simple things being easy guarantees a point on the lower left (low use case complexity → low user effort).
Complex things being possible guarantees a point somewhere on the far right.
The lower down, the better — but higher up is acceptable.
Alan Kay's maxim visualized.
But even if we get these two points — what about all the points in between?
There are infinite different ways to connect them, and they produce vastly different overall user experiences.
How does your interface fare when their use case gets slightly more complex?
Are users yeeted into the deep end of interface complexity (bad), or do they only need to invest a proportional, incremental amount of additional effort to achieve their goal (good)?
Meet the complexity-to-effort curve, the most important usability metric you’ve never heard of.
For delightful user experiences, making simple things easy and complex things possible is not enough — the transition between the two should also be smooth.
You see, simple use cases are the spherical cows in space of product design.
They work great for prototypes to convince stakeholders, or in marketing demos, but the real world is messy.
Most artifacts that users need to create to achieve their real-life goals rarely fit into your “simple” flows completely, no matter how well you’ve done your homework.
They are mostly simple — with a liiiiitle wart here and there.
For a long-tail interface to serve user needs well in practice,
we need to consciously design the curve, not just its endpoints.
A model with surprising predictive power is to treat user effort as a currency that users are spending to buy solutions to their problems.
Nobody likes paying it;
in an ideal world software would read our mind and execute perfectly with zero user effort.
Since we don’t live in such a world, users understand to pay a bit of effort to achieve their goals,
and are generally willing to pay more when they feel their use case warrants it.
Just like regular pricing, actual user experience often depends more on the relationship between cost and budget than on the absolute cost itself.
If you pay more than you expected, you feel ripped off.
You may still pay it because you need the product in the moment, but you’ll be looking for a better deal in the future.
And if you pay less than you had budgeted, you feel like you got a bargain, with all the delight and loyalty that entails.
Suppose you’re ordering pizza. You want a simple cheese pizza with ham and mushrooms.
You use the online ordering system, and you notice that adding ham to your pizza triples its price.
We’re not talking some kind of fancy ham where the pigs were fed on caviar and bathed in champagne, just a regular run-of-the-mill pizza topping.
You may still order it if you’re really craving ham on your pizza and no other options are available, but how does it make you feel?
It’s not that different when the currency is user effort.
The all too familiar “But I just wanted to _________, why is it so hard?”.
When a small increase in use case complexity results in a disproportionately large increase in user effort cost, we have a usability cliff.
Usability cliffs make users feel resentful, just like the customers of our fictitious pizza shop.
A usability cliff is when a small increase in use case complexity requires a large increase in user effort.
Usability cliffs are very common in products that make simple things easy and complex things possible through entirely separate flows with no integration between them:
a super high level one that caters to the most common use case with little or no flexibility,
and a very low-level one that is an escape hatch: it lets users do whatever,
but they have to recreate the solution to the simple use case from scratch before they can tweak it.
Example: The HTML video element
Simple things are certainly easy: all we need to get a video with a nice sleek set of controls that work well on every device is a single attribute: controls.
We just slap it on our <video> element and we’re done with a single line of HTML:
<video src="videos/cat.mp4" controls></video>
➡️
Now suppose use case complexity increases just a little.
Maybe I want to add buttons to jump 10 seconds back or forwards.
Or a language picker for subtitles.
Or just to hide the volume control on a video that has no audio track.
None of these are particularly niche, but the default controls are all-or-nothing: the only way to change them is to reimplement the whole toolbar from scratch, which takes hundreds of lines of code to do well.
Simple things are easy and complex things are possible.
But once use case complexity crosses a certain (low) threshold, user effort abruptly shoots up.
That’s a usability cliff.
Example: Instagram editor
For Instagram’s photo editor, the simple use case is canned filters, whereas the complex ones are those requiring tweaking through individual low-level controls.
However, they are implemented as separate flows: you can tweak the filter’s intensity, but you can’t see or adjust the primitives it’s built from.
You can layer both types of edits on the same image, but they are additive, which doesn’t work well.
Ideally, the two panels would be integrated, so that selecting a filter would adjust the low-level controls accordingly, which would both facilitate incremental tweaking
and serve as a teaching aid for how filters work.
Example: Filtering in Coda
My favorite end-user facing product that gets this right is Coda,
a cross between a document editor, a spreadsheet, and a database.
All over its UI, it supports entering formulas instead of raw values, which makes complex things possible.
To make simple things easy, it also provides the GUI you’d expect even without a formula language.
But here’s the twist: these presets generate formulas behind the scenes that users can tweak!
Whenever users need to go a little beyond what the UI provides, they can switch to the formula editor and adjust what was generated
— far easier than writing it from scratch.
Another nice touch: “And” is not just communicating how multiple filters are combined, but is also a control that lets users edit the logic.
Defining high-level abstractions in terms of low-level primitives is a great way to achieve a smooth complexity-to-effort curve,
as it allows you to expose tweaking at various intermediate levels and scopes.
The downside is that it can sometimes constrain the types of high-level solutions that can be implemented.
Whether the tradeoff is worth it depends on the product and use cases.
If you like eating out, this may be a familiar scenario:
— I would like the rib-eye please, medium-rare.
— Thank you sir/ma’am. How would you like your steak cooked?
Annoying, right?
And yet, this is how many user interfaces work; expecting users to communicate the same intent multiple times in slightly different ways.
If incremental value should require incremental user effort, an obvious corollary is that things that produce no value should not require user effort.
Using the currency model makes this obvious: who likes paying without getting anything in return?
Respect user effort.
Treat it as a scarce resource — just like regular currency — and keep it close to the minimum necessary to declare intent.
Do not require users to do work that confers them no benefit, and could have been handled by the UI.
If it can be derived from other input, it should be derived from other input.
A once ubiquitous example that is thankfully going away, is the credit card form which asks for the type of credit card in a separate dropdown.
Credit card numbers are designed so that the type of credit card can be determined from the first four digits.
There is zero reason to ask for it separately.
Beyond wasting user effort, duplicating input that can be derived introduces an unnecessary error condition that you now need to handle:
what happens when the entered type is not consistent with the entered number?
User actions that meaningfully communicate intent to the interface are signal.
Any other step users need to take to accomplish their goal, is noise.
This includes communicating the same input more than once,
providing input separately that could be derived from other input with complete or high certainty,
transforming input from their mental model to the interface’s mental model,
and any other demand for user effort that does not serve to communicate new information about the user’s goal.
Some noise is unavoidable.
The only way to have 100% signal-to-noise ratio would be if the interface could mind read.
But too much noise increases friction and obfuscates signal.
Example: Programmatic Element removal
The two web platform methods to programmatically remove an element from the page provide a short yet demonstrative example of this for APIs.
To signal intent in this case, the user needs to communicate two things:
(a) what they want to do (remove an element), and (b) which element to remove.
Anything beyond that is noise.
The modern element.remove() DOM method has an extremely high signal-to-noise ratio.
It’s hard to imagine a more concise way to signal intent.
It replaced the older parent.removeChild(child) method, which had much worse ergonomics.
The older method was framed around removing a child, so it required two parameters: the element to remove, and its parent.
But the parent is not a separate source of truth — it would always be the child node’s parent!
As a result, its actual usage involved boilerplate, where
developers had to write a much noisier if (element.parentNode) element.parentNode.removeChild(element)[3].
The difference in signal-to-noise ratio is staggering: 81% vs 20% in this case.
Of course, it was usually encapsulated in utility functions, which provided a similar signal-to-noise ratio as the modern method.
However, user-defined abstractions don’t come for free, there is an effort (and learnability) tax there, too.
Boilerplate is repetitive code that users need to include without thought, because it does not actually communicate intent.
It’s the software version of red tape: hoops you need to jump through to accomplish your goal, that serve no obvious purpose in furthering said goal except for the fact that they are required of you.
In case of parent.removeChild() above, the amount of boilerplate may seem small, but when viewed as a percentage of the total amount of code, the difference is staggering.
The exact ratio (81% vs 20% here) varies based on the specifics of the API (variable names, method wording, etc.),
but when the difference is meaningful, it transcends these types of low-level details.
Think of it like big-O notation for API design.
Improving signal-to-noise ratio is also why the front-end web industry gravitated towards component architectures over copy-pasta snippets:
components increase signal-to-noise ratio by encapsulating boilerplate and exposing a much higher signal UI.
They are the utility functions of user interfaces.
As an exercise for the reader, try to calculate the signal-to-noise ratio of a Bootstrap accordion (or any other complex component in any UI library that expects you to copy-paste snippets).
Instead of syntax, visual interfaces have micro-interactions.
There are various models to quantify the user effort cost of micro-interactions, such as KLM.
When pointing out friction issues in design reviews,
I have sometimes heard “users have not complained about this”.
This reveals a fundamental misunderstanding about the psychology of user feedback.
Users are much more vocal about things not being possible, than about things being hard.
The reason becomes clear if we look at the neuroscience of each.
Friction is transient in working memory; after completing the task, details fade from the user’s prefrontal cortex.
However, the negative emotions persist in the limbic system and build up over time.
Filing a complaint requires prefrontal engagement, which for friction is brief or absent.
Users often can’t even articulate why the software feels unpleasant: the specifics vanish; the feeling remains.
Hard limitations, on the other hand, persist as conscious appraisals.
The trigger doesn’t go away, since there is no workaround, so it’s far more likely to surface in explicit user feedback.
Both types of pain points cause negative emotions,
but friction is primarily processed by the limbic system (emotion),
whereas hard limitations remain in the prefrontal cortex (reasoning).
This also means that when users finally do reach the breaking point and complain about friction, you better listen.
Second, user complaints are filed when there is a mismatch in expectations.
Things are not possible but the user feels they should be, or interactions cost more user effort than the user had budgeted,
e.g. because they know that a competing product offers the same feature for less (work).
Often, users have been conditioned to expect poor user experiences,
either because all options in the category are high friction, or because the user is too novice to know better
[4].
So they begrudgingly pay the price, and don’t think they have the right to complain, because it’s just how things are.
You might ask, “If all competitors are equally high-friction, how does this hurt us?”
An unmet need is a standing invitation to disruption that a competitor can exploit at any time.
Because you’re not only competing within a category; you’re competing with all alternatives — including nonconsumption (see Jobs‑to‑be‑Done).
Even for retention, users can defect to a different category altogether (e.g., building native apps instead of web apps).
Historical examples abound.
When it comes to actual currency, a familiar example is Airbnb: Until it came along, nobody would complain that a hotel of average price is expensive — it was just the price of hotels.
If you couldn’t afford it, you just couldn’t afford to travel, period.
But once Airbnb showed there is a cheaper alternative for hotel prices as a whole, tons of people jumped ship.
It’s no different when the currency is user effort.
Stripe took the payment API market by storm when it demonstrated that payment APIs did not have to be so high friction.
iPhone disrupted the smartphone market when it demonstrated that no, you did not have to be highly technical to use a smartphone.
The list goes on.
Unfortunately, friction is hard to instrument.
With good telemetry you can detect specific issues (e.g., dead clicks), but there is no KPI to measure friction as a whole.
And no, NPS isn’t it — and you’re probably using it wrong anyway.
Instead, the emotional residue from friction quietly drags many metrics down (churn, conversion, task completion), sending teams in circles like blind men touching an elephant.
That’s why dashboards must be paired with product vision and proactive, first‑principles product leadership.
Steve Jobs exemplified this posture: proactively, aggressively eliminating friction presented as “inevitable.”
He challenged unnecessary choices, delays, and jargon, without waiting for KPIs to grant permission.
Do mice really need multiple buttons? Does installing software really need multiple steps? Do smartphones really need a stylus?
Of course, this worked because he had the authority to protect the vision; most orgs need explicit trust to avoid diluting it.
So, if there is no metric for friction, how do you identify it?
Usability testing lets you actually observe firsthand what things are hard instead of having them filtered through users’ memories and expectations.
Design reviews/audits by usability experts is complementary to usability testing, as it often uncovers different issues. Design reviews are also great for maximizing the effectiveness of usability testing by getting the low-hanging fruit issues out of the way before it.
Dogfooding is unparalleled as a discovery tool — nothing else will identify as many issues as using the product yourself, for your own, real needs.
However, it’s important to keep in mind that you’re a huge power user of your own product.
You cannot surface learnability issues (curse of knowledge) and you will surface issues no-one else has.
Dogfooding is a fantastic discovery tool, but you still need user research to actually evaluate and prioritize the issues it surfaces.
Reducing friction rarely comes for free, just because someone had a good idea.
These cases do exist, and they are great, but it usually takes sacrifices.
And without it being an organizational priority, it’s very hard to steer these tradeoffs in that direction.
The most common tradeoff is implementation complexity.
Simplifying user experience is usually a process of driving complexity inwards and encapsulating it in the implementation.
Explicit, low-level interfaces are far easier to implement, which is why there are so many of them.
Especially as deadlines loom, engineers will often push towards externalizing complexity into the user interface, so that they can ship faster.
And if Product leans more data-driven than data-informed, it’s easy to look at customer feedback and conclude that what users need is more features
(it’s not).
Simple to use is often at odds with simple to implement.
The first faucet is a thin abstraction: it exposes the underlying implementation directly, passing the complexity on to users, who now need to do their own translation of temperature and pressure into amounts of hot and cold water.
It prioritizes implementation simplicity at the expense of wasting user effort.
The second design prioritizes user needs and abstracts the underlying implementation to support the user’s mental model.
It provides controls to adjust the water temperature and pressure independently, and internally translates them to the amounts of hot and cold water.
This interface sacrifices some implementation simplicity to minimize user effort.
This is why I’m skeptical of blanket calls for “simplicity.”: they are platitudes.
Everyone agrees that, all else equal, simpler is better.
It’s the tradeoffs between different types of simplicity that are tough.
In some cases, reducing friction even carries tangible financial risks, which makes leadership buy-in crucial.
This kind of tradeoff cannot be made by individual designers — only when eliminating friction is an organizational priority.
The Oslo airport train ticket machine is the epitome of a high signal-to-noise interface.
You simply swipe your credit card to enter and you swipe your card again as you leave the station at your destination.
That’s it. No choices to make. No buttons to press. No ticket.
You just swipe your card and you get on the train.
Today this may not seem radical, but back in 2003, it was groundbreaking.
To be able to provide such a frictionless user experience, they had to make a financial tradeoff:
it does not ask for a PIN code, which means the company would need to absorb the financial losses from fraudulent charges (stolen credit cards, etc.).
When user needs are prioritized at the top, it helps to cement that priority as an organizational design principle to point to when these tradeoffs come along in the day-to-day.
Having a design principle in place will not instantly resolve all conflict, but it helps turn conflict about priorities
into conflict about whether an exception is warranted, or whether the principle is applied correctly, both of which are generally easier to resolve.
Of course, for that to work everyone needs to be on board with the principle.
But here’s the thing with design principles (and most principles in general): they often seem obvious in the abstract, so it’s easy to get alignment in the abstract.
It’s when the abstract becomes concrete that it gets tough.
“User needs come before the needs of web page authors, which come before the needs of user agent implementors, which come before the needs of specification writers, which come before theoretical purity.”
This highlights another key distinction: the hierarchy of user needs is more nuanced than just users over developers.
While users over developers is a good starting point, it is not sufficient to fully describe the hierarchy of user needs for many products.
A more flexible framing is consumers over producers;
developers are just one type of producer.
Consumers are typically more numerous than producers, so this minimizes collective pain.
Producers are typically more advanced, and can handle more complexity than consumers. I’ve heard this principle worded as “Put the pain on those who can bear it”, which emphasizes this aspect.
Producers are typically more invested, and less likely to leave
The web platform has multiple tiers of producers:
Specification writers are at the bottom of the hierarchy, and thus, can handle the most pain (ow! 🥴)
Browser developers (“user agent implementors” in the principle) are consumers when it comes to specifications, but producers when it comes to the web platform
Web developers are consumers when it comes to the web platform, but producers when it comes to their own websites
Even within the same tier there are often producer vs consumer dynamics.
E.g. when it comes to web development libraries, the web developers who write them are producers and the web developers who use them are consumers.
This distinction also comes up in extensible software, where plugin authors are still consumers when it comes to the software itself,
but producers when it comes to their own plugins.
It also comes up in dual sided marketplace products
(e.g. Airbnb, Uber, etc.),
where buyer needs are generally higher priority than seller needs.
In the economy of user effort, the antithesis of overpriced interfaces that make users feel ripped off
are those where every bit of user effort required feels meaningful and produces tangible value to them.
The interface is on the user’s side, gently helping them along with every step, instead of treating their time and energy as disposable.
The user feels like they’re getting a bargain: they get to spend less than they had budgeted for!
And we all know how motivating a good bargain is.
User effort bargains don’t have to be radical innovations;
don’t underestimate the power of small touches.
A zip code input that auto-fills city and state,
a web component that automatically adapts to its context without additional configuration,
a pasted link that automatically defaults to the website title (or the selected text, if any),
a freeform date that is correctly parsed into structured data,
a login UI that remembers whether you have an account and which service you’ve used to log in before,
an authentication flow that takes you back to the page you were on before.
Sometimes many small things can collectively make a big difference.
In some ways, it’s the polar opposite of death by a thousand paper cuts:
Life by a thousand sprinkles of delight! 😀
In the end, “simple things simple, complex things possible” is table stakes.
The key differentiator is the shape of the curve between those points.
Products win when user effort scales smoothly with use case complexity, cliffs are engineered out, and every interaction declares a meaningful piece of user intent.
That doesn’t just happen by itself.
It involves hard tradeoffs, saying no a lot, and prioritizing user needs at the organizational level.
Treating user effort like real money, forces you to design with restraint.
A rule of thumb is place the pain where it’s best absorbed by prioritizing consumers over producers.
Do this consistently, and the interface feels delightful in a way that sticks.
Delight turns into trust.
Trust into loyalty.
Loyalty into product-market fit.
Yes, typing can be faster than dragging, but minimizing homing between input devices improves efficiency more, see KLM↩︎
Yes, today it would have been element.parentNode?.removeChild(element), which is a little less noisy, but this was before the optional chaining operator. ↩︎
When I was running user studies at MIT, I’ve often had users exclaim “I can’t believe it! I tried to do the obvious simple thing and it actually worked!” ↩︎
tl;dr: State of HTML 2025 survey is now open!
Take it now
Mamma mia, here we go again!
About two weeks ago, I announced that I was back leading this year’s State of HTML 2025 survey, after a one year hiatus.
We are grateful for all the suggestions that poured in, they were immensely helpful in shaping the survey.
After two weeks of hard work from a small team spanning three continents, we are finally ready to launch!
I would urge each and every one of you that works with the web platform to fill out this survey.
It’s a unique opportunity to have your voice heard in the browser vendors’ decision-making process.
Survey results are used by browsers to prioritize roadmaps — the reason Google is funding this.
The results from State of … surveys directly feed into prioritization for next year’s Interop project.
Time spent thoughtfully filling them out is an investment that can come back to you tenfold
in the form of seeing features you care about implemented, browser incompatibilities being prioritized, and gaps in the platform being addressed.
In addition to browsers, several standards groups are also using the results for prioritization and decision-making.
Additionally, you get to learn about new and upcoming features you may have missed,
and get a personalized, sharable score at the end to see how you compare to other respondents!
While the survey will be open for about a month,
responses entered within the first two weeks (until end of July) will have a much higher impact on the Web,
as preliminary data will be directly used to inform Interop 2026.
We spent a lot of time thinking about which features we are asking about and why.
As a result, we added 35 new features, and removed 18 existing ones to make room.
This is probably one of the hardest parts of the process, as we had to make some tough decisions.
We are also using the Web Components section to pilot a new format for pain points questions,
consisting of a multiple choice question with common pain points,
followed by the usual free form text list:
While this increases the number of questions,
we are hoping it will reduce survey fatigue by allowing participants to skip the freeform question more frequently (or spend less time on it) if most of their pain points have already been covered by the multiple choice question.
Last but not least, we introduced browser support icons for each feature, per popular request:
Absolutely! Do not worry about filling it out perfectly in one go.
If you create an account, you can edit your responses for the whole period the survey is open, and even fill it out across multiple devices,
e.g. start on your phone, then fill out some on your desktop, etc.
Even if you’re filling it out anonymously, you can still edit responses on your device for some time,
so you can have it open in a browser tab and revisit it periodically.
For the same reason there are JS APIs in the HTML standard:
many JS APIs are intrinsically related to HTML.
We mainly included JS APIs that are in some way related to HTML, such as:
APIs used to manipulate HTML dynamically (DOM, interactivity, etc.)
Web Components APIs, used to create custom HTML elements
PWA features, including APIs used to access underlying system capabilities (OS capabilities, device capabilities, etc.)
The only two exceptions to this are two Intl APIs,
which were mainly included because we wanted to get participants thinking about any localization/internationalization pain points they may have.
However, if you don’t write any JS, we absolutely still want to hear from you!
In fact, I would encourage you even more strongly to fill out the survey,
as people who don’t write JS are very underrepresented in these surveys.
All questions are optional, so you can just skip any JS-related questions.
There is also a question at the end, where you can select that you only write HTML/CSS:
While proposals with no browser support are not good candidates for immediate prioritization by browsers,
their context chips give browser vendors and standards groups invaluable insight into what matters to developers,
which also drives prioritization decisions.
However, we heard you loud and clear: when mature and early stage features are mixed together, you felt bait-and-switched.
So this year, we are including icons to summarize browser support of each feature we ask about:
We are hoping this will also help prevent cases where participants confuse a new feature they have never heard of, with a more established feature they are familiar with.
Absolutely not! Localization has been an integral part of these surveys since the beginning.
Fun fact: None of the people working on these surveys is a native English speaker.
However, since translations are a community effort, they are not necessarily complete, especially in the beginning.
If you are a native speaker of a language that is not yet complete, please consider helping out!
Two years ago, I was funded by Google to design the inaugural State of HTML survey.
While I had led State of … surveys before (also graciously sponsored by Google), that was by far the most intense, as 0→1 projects often are.
In addition to the research, content, and analysis work that goes into every State of … survey,
the unique challenges it presented were a forcing function for finally tackling some longstanding UX issues with these surveys.
As a result, we pioneered new survey interaction UIs, and validated them via usability testing.
This work did not just affect State of HTML, but had ripple effects on all subsequent State of … surveys.
The results made it all worth it.
Turnout was the highest ever for a new Devographics [1] survey: 21 thousand participants, which remains a record high for State of HTML.
The survey findings heavily influenced Interop 2024 (hello Popover API and Declarative Shadow DOM!) and helped prioritize several other initiatives, such as stylable selects.
Despite lower 2024 participation, the survey still significantly influenced Interop 2025;
notably, View transitions was added after being prominent in the survey for two years in a row.
This is the goal of these surveys: to drive meaningful change in the web platform.
Sure, getting a shareable score about what you know and seeing how you compare to the rest of the industry is fun, but the reason browser vendors pour thousands of dollars into funding these surveys is because they provide unique vendor-neutral insights into developer pain points and priorities, which helps them make better decisions about what to work on.
And this ultimately helps you: by getting your voice heard, you can directly influence the tools you work with.
It’s a win-win: developers get better tools, and browser vendors get better roadmaps.
Last year, I was too busy to take the lead again.
Wrapping up my PhD and starting a new job immediately after, there was no time to breathe, let alone lead a survey.
I’m happy to be returning to it this year, but my joy is bittersweet.
When I was first asked to lead this year’s survey a few months ago,
I was still too busy to take it on.
Someone else from the community accepted the role — someone incredibly knowledgeable and talented who would have done a fantastic job.
But they live in the Middle East, and as the war escalated, their safety and their family’s well-being were directly impacted.
Understandably, leading a developer survey became the least of their concerns.
In the meantime, I made a few decisions that opened up some availability, and I was able to step in at the last minute.
It’s a sobering reminder that events which feel far away can hit close to home — shaping not just headlines, but the work and lives of people we know.
A big part of these surveys is “feature questions”: respondents are presented with a series of web platform features,
and asked about their familiarity and sentiment towards them.
At the end, they get a score based on how many they were familiar with that they can share with others,
and browser vendors and standards groups get signal on which upcoming features to prioritize or improve.
You can see which features were included in last year’s survey here or in [2] the table below.
I believe that co-designing these surveys with the community is the best way to avoid blind spots.
While the timeline is tighter than usual this year (the survey is launching later this month!), there is still a little time to ask:
👉🏼 Which upcoming HTML features or Web APIs are currently on your radar? 👈🏼
What does “on your radar” mean? Features you’re excited about and would love to see progress on.
Why focus on upcoming features?
The best candidates for these surveys are features that are mature enough to be fleshed out (at least a mature proposal, ideally a spec and WPT tests),
but not so mature they have already been implemented in every browser.
These are the features for which a survey such as this can drive meaningful impact.
If it’s so early for a feature that it’s not yet fleshed out, it’s hard to make progress via initiatives such as Interop.
Interest is still useful signal to help prioritize work on fleshing it out, but it’s a bit of a longer game.
And for features that are already implemented everywhere, the only thing that can improve things further is passage of time
— a problem for which I unfortunately have no solution (yet).
Obviously we’re looking at all the usual suspects already,
and initiatives such as webstatus.dev
and Web platform features explorer provide a treasure trove of data which makes this task infinitely easier than it used to be.
But this kind of preliminary signal is also useful for filtering and prioritization — to give you a sense, my list of candidate new features to ask about already has 57 items (!).
Given that State of HTML 2024 asked about 49 features, that will need some very heavy pruning.
While the title is “State of HTML”,
anything that wouldn’t fit better in State of CSS or State of JS is fair game.
This includes topics such as accessibility, browser APIs, web components, templating, static site generation, media formats, and more.
This may seem strange at first, but is no different than how the HTML specification itself covers a lot more than just HTML markup.
Any way to reach me works fine.
You can post in the comments here (preferred),
or reply on
BlueSky,
Mastodon,
Threads,
LinkedIn, or
Twitter.
Make sure to check the other replies first, and 👍 those with features you care about.
Looking forward to your ideas and comments!
Devographics is the company behind “State of …” surveys. ↩︎
As an Easter egg, this widget is just a <details> element with custom CSS.
Inspect it to see how it works!
It works best in Chrome and Safari, as they fully support ::details-content.
Chrome also supports calc-size(), which enables a nice animation, while the interaction in Safari is more abrupt.
In terms of a11y, the summary gets spoken out as a regular <summary> element, with “Show more” or “Show less” at the end of its content.
It seems ok-ish to me, but I’d love to hear from those with more expertise in this area. ↩︎
You may be familiar with this wonderful illustration and accompanying
blog post by Henrik Kniberg about good MVPs:
It’s a very visual way to illustrate the age-old concept that
that a good MVP is not the one developed in isolation over months or years,
grounded on assumptions about user needs and goals,
but one that delivers value to users as early as possible,
so that future iterations can take advantage of the lessons learned from real users.
I love Henrik’s metaphor so much, I have been using a similar system to flesh out product requirements and shipping goals, especially early on.
It can be immediately understood by anyone who has seen Henrik’s illustration,
and I find it can be a lot more pragmatic and flexible than the usual simple two tiered system (core requirements and stretch goals).
Additionally, I find this fits nicely into a fixed time, variable scope development process,
such as Shape Up.
🛹 The Skateboard aka the Pessimist’s MVP
What is the absolute minimum we can ship, if need be?
Utilitarian, bare-bones, and somewhat embarrassing, but shippable — barely.
Anything that can be flintstoned gets flintstoned.
🛴 The Scooter aka the Realist’s MVP
The minimum product that delivers value. Usable, but no frills. This is the target.
🚲 The Bicycle aka the Optimist’s MVP
Stretch goals — UX polish, “sprinkles of delight”, nonessential but high I/E features.
Great if we get here, fine if we don’t.
🏍️ The Motorcycle
Post-launch highest priority items.
🚗 The Car
Our ultimate vision, taking current constraints into account.
🏎️ The Hovercar aka the North Star UI
The ideal experience — unconstrained by time, resources, or backwards compatibility.
Unlikely to ship, but a guiding light for all of the above.
Please note that the concept of a North Star UI has no relation to the North Star Metric.
While both serve as a guiding light for product decisions, and both are important,
the North Star UI guides you in designing the product,
whereas the North Star Metric is about evaluating success.
To avoid confusion, I’ll refer to it as “North Star UI”, although it’s not about the UI per se, but the product vision on a deeper level.
The first three stages are much more concrete and pragmatic, as they directly affect what is being worked on.
The more we go down the list, the less fleshed out specs are, as they need to allow room for customer input.
This also allows us to outline future vision, without having to invest in it prematurely.
The most controversial of these is the last one: the hovercar, i.e. the North Star UI.
It is the very antithesis of the MVP.
The MVP describes what we can ship ASAP,
whereas the North Star describes the most idealized goal, one we may never be able to ship.
It is easy to dismiss that as a waste of time, a purely academic exercise.
“We’re all about shipping. Why would we spend time on something that may not even be feasible?” I hear you cry in Agile.
Stay with me for a moment, and please try to keep an open mind.
Paradoxical as it may sound, fleshing out your North Star can actually save you time.
How? Start counting.
At its core, this framework is about breaking down tough product design problems into three more manageable components:
North Star: What is the ideal solution?
Constraints: What prevents us from getting there right now?
Compromises: How close can we reasonably get given these constraints?
One way to frame it is is that 2 & 3 are the product version of tech debt.[1]
It’s important to understand what constraints are fair game to ignore for 1 and which are not.
I often call these ephemeral or situational constraints.
They are constraints that are not fundamental to the product problem at hand,
but relate to the environment in which the product is being built and could be lifted or change over time.
Things like:
Engineering resources
Time
Technical limitations (within reason)
Performance
Backwards compatibility
Regulatory requirements
Unlike ephemeral constraints, certain requirements are part of the problem description and cannot be ignored.
Some examples from the case studies below:
Nearly every domain of human endeavor has a version of divide and conquer:
instead of solving a complex problem all at once, break it down into smaller, manageable components and solve them separately.
Product design is no different.
This process really shines when you’re dealing with the kinds of tough product problems where at least two of these questions are hard,
so breaking it down can do wonders for reducing complexity.
By solving these components separately,
our product design process becomes can more easily adapt to changes.
I have often seen “unimplementable” solutions become implementable down the line,
due to changes in internal or external factors, or simply because someone had a lightbulb moment.
By addressing these components separately, when constraints get lifted all we need to reevaluate is our compromises.
But without this modularization, our only solution is to go back to the drawing board.
Unsurprisingly, companies often choose to simply miss out on the opportunity, because it’s cheaper (or seems cheaper) to do so.
Every shipping goal is derived from the North Star, like peeling layers off an onion.
This is whether you realize it or not.
Whether you realize it or not, every shipping goal is always derived from the North Star, like peeling layers off an onion.
In some contexts the process of breaking down a bigger shipping goal into milestones that can ship independently is even called layering.
The process is so ingrained, so automatic, that most product designers don’t realize they are doing it.
They go from hovercar to car so quickly they barely realize the hovercar was there to begin with.
Thinking about the North Star is taboo — who has time for daydreaming?
We must ship, yesterday!
But the hovercar is fundamental.
Without it, there is no skateboard — you can’t reduce the unknown.
When designing it is not an explicit part of the process,
the result is that the main driver of all product design decisions is something that can never be explicitly discussed and debated like any other design decision.
In what universe is that efficient?
A skateboard might be a good MVP if your ultimate vision is a hovercar,
but it would be a terrible minimum viable cruise ship — you might want to try a wooden raft for that.
A skateboard may be a great MVP for a car, but a terrible MVP for a cruise ship.
Making the North Star taboo doesn’t make it disappear (when did that ever work?).
It just means that everyone is following a different version of it.
And since MVPs are products of the North Star, this will manifest as difficulty reaching consensus at every step of the way.
The product team will disagree on whether to ship a skateboard or a wooden raft,
then on whether to build a scooter or a simple sailboat,
then on whether to work on a speedboat or a yacht,
and so on.
It will seem like there is so much disconnect that every decision is hard,
but there is actually only one root disconnect that manifests as multiple because it is never addressed head on.
When the North Star is not clearly articulated, everyone has their own.
Here is a story that will sound familiar to many readers:
A product team is trying to design a feature to address a specific user pain point.
Alice has designed an elegant solution that addresses not just the problem at hand, but several prevalent longstanding user pain points at once — an eigensolution.
She is aware it would be a little trickier to implement than other potential solutions,
but the increase in implementation effort is very modest, and easily offset by the tremendous improvement in user experience.
She has even outlined a staged deployment strategy that allows it to ship incrementally, adding value and getting customer feedback earlier.
Excited, she presents her idea to the product team, only to hear engineering manager Bob dismiss it with “this is scope creep and way too much work, it’s not worth doing”.
However, what Bob is actually thinking is “this is a bad idea; any amount of work towards it is a waste”.
The design session is now derailed; instead of debating Alice’s idea on its merits, the discussion has shifted towards costing and/or reducing effort.
But this is a dead end because the amount of work was never the real problem.
In the end, Alice wants to be seen as a team player, so she backs off and concedes to Bob’s “simpler” idea, despite her worries that it is overfit to the very specific use case being discussed, and the product is now worse.
Arguing over effort feels safer and less confrontational than debating vision — but is often a proxy war.
Additionally, it is not productive.
If the idea is poor, effort is irrelevant.
And once we know an idea is good and believe it to our core, we have more incentive to figure out implementation,
which often proves to be easier than expected once properly investigated.
Explicitly fleshing out the Hovercar strips away the noise and brings clarity.
When we answer the questions above in order and reach consensus on the North Star before moving on to the compromises,
we know what is an actual design decision and what is a compromise driven by practical constraints.
Articulating these separately, allows us to discuss them separately.
It is very hard to evaluate tradeoffs collaboratively if you are not on the same page about what we are trading off and how much it’s worth.
You need both the cost and the benefit to do a cost-benefit analysis!
Additionally, fleshing the North Star out separately ensures that everyone is on the same page about what is being discussed.
All too often have I seen early design sessions where one person is discussing the skateboard,
another the bicycle, and a third one the hovercar,
no-one realizing that the reason they can’t reach consensus is that they are designing different things.
Conventional wisdom is that we strip down the North Star to an MVP, ship that, then iterate based on user input.
With that process, our actual vision never really gets evaluated and by the time we get to it, it has already changed tremendously.
But did you know you can actually get input from real users without writing a single line of code?
Believe it or not, you don’t need to wait until a UI is prototyped to user test it.
You can even user test a low-fi paper prototype or even a wireframe.
This is widely known in usability circles, yet somehow entirely unheard of outside the field.
The user tells you where they would click or tap on every step, and you mock the UI’s response by physically manipulating the prototype or showing them a wireframe of the next stage.
Obviously, this works better for some types of products than others.
It is notably hard to mock rich interactions or UIs with too many possible responses.
But when it does work, its Impact/Effort ratio is very high;
you get to see whether your core vision is on the right track,
and adjust your MVP accordingly.
It can be especially useful when there are different perspectives within a team about what the North Star might be,
or when the problem is so novel that every potential solution is low-confidence.
No-one’s product intuition is always right, and there is no point in evaluating compromises if it turns out that even the “perfect” solution was not actually all that great.
So far, we have discussed the merits of designing our North Star,
assuming we will never be able to ship it.
However, in many cases,
simply articulating what the North Star is can bring it within reach.
It’s not magic, just human psychology.
Once we have a North Star, we can use it to evaluate proposed solutions:
How do they relate to it?
Are they a milestone along a path that ends at the North Star?
Do they actively prevent us from ever getting there?
Prioritizing solutions that get us closer to the North Star can be a powerful momentum building tool.
Humans find it a lot easier to make one more step along a path they are already on, than to make the first step on an entirely new path.
This is well-established in psychology and often used as a technique for managing depression or executive dysfunction.
However, it applies on anything that involves humans — and that includes product design.
Once we’re partway there, it naturally begs the question: can we get closer? How much closer?
Even if we can’t get all the way there, maybe we can close enough that the remaining distance won’t matter.
And often, the closer you get, the more achievable the finish line gets.
In fact, sometimes simply reframing the North Star as a sequence of milestones rather than a binary goal can be all that is needed to make it feasible.
For an example of this, check out the CSS Nesting case study below.
In my 20 years of product design, I have seen ephemeral constraints melt away so many times I have learned to interpret “unimplementable” as “kinda hard; right now”.
Two examples from my own experience that I find particularly relevant below,
one around Survey UI, and one around a CSS language feature.
Originally, I needed to aggressively prioritize due to minimal engineering resources, which led me to design an extremely low-effort solution which still satisfied requirements.
The engineer hated the low-effort idea so much, he prototyped a much higher-effort solution in a day, backend and all.
Previously, this would have been entirely out of the question.
Once I took the ephemeral constraints out of the question, I was able to design a much better, novel solution, but it got pushback on the basis of effort.
Prototyping it allowed us to user test it, which revealed it performed way better than alternatives.
Once user testing built engineering momentum and the implementation was more deeply looked into, it turned out it did not actually require as much effort as initially thought.
Here is a dirty little secret about software engineering (and possibly any creative pursuit):
neither feasibility nor effort are fixed for a given task.
Engineers are not automatons that will implement everything with the same energy and enthusiasm.
They may implement product vision they disagree with,
but you will be getting very poor ROI out of their time.
Investing the time and energy to get engineers excited can really pay dividends.
When good engineers are excited, they become miracle workers.
In fact, engineering momentum is often, all that is needed to make the infeasible, feasible.
It may seem hard to fit this into the crunch of OKRs and KPIs but it’s worth it; the difference is not small, it is orders of magnitude.
Things that were impossible or insurmountable become feasible, and things that would normally take weeks or months get done in days.
One way to build engineering momentum is to demonstrate the value and utility of what is being built.
All too often, product decisions are made in a vacuum, based on gut feelings and assumptions about user needs.
Backing them up with data, such as usability testing sessions is an excellent way to demonstrate (and test!) their basis.
When possible, having engineers observe user testing sessions firsthand can be much more powerful than secondhand reports.
Example of CSS code, with (right) and without (left) nesting.
Which one is easier to read?
This is one of the rare cases where the North Star was well known in advance,
since the syntax was already well established in developer tooling (CSS preprocessors).
Instead, the big challenge was navigating the practical constraints,
since CSS implemented in browsers has different performance characteristics,
so a syntax that is feasible for tooling may be out of reach for a browser.
In this case, the North Star syntax had been ruled out by browser engineers due to prohibitive parsing performance [3],
so we had to design a different, more explicit syntax that could be parsed more efficiently.
At this point, it is important to note that CSS Nesting is a feature that is very heavily used once available.
Conciseness and readability are paramount,
especially when conciseness is the sole purpose of the feature in the first place!
Initial attempts for a syntax that satisfied these technical requirements introduced a lot of noise,
making the syntax tedious to write and noisy to read.
Even worse, these attempts were actively incompatible with the North Star syntax, as well as other parts of the language (namely, the @scope rule).
This meant that even if the North Star syntax became feasible later,
CSS would need to forever support syntax that would then have no purpose,
and would only exist as a wart from the past, just like HTML doctypes.
Once Google became very keen to ship Nesting (driven by State of CSS 2022, which showed it as the top missing CSS feature),
a small subset of the CSS Working Group, led by Elika Etemad and myself met to explore alternatives,
and produced four competing proposals.
The one that the group voted to adopt [4] was the one I designed explicitly to answer the question:
If the North Star syntax is out of the question right now, what is the largest subset of it that is feasible?
Once we got consensus on this intermediate syntax, I started exploring whether we could get any closer to the 🌟, even proposing an algorithm that would reduce the number of cases that required the slower parsing to essentially an edge case.
A few other WG members joined me, with my co-TAG member Peter Linss being most vocal.
This is a big advantage of North Star compatible designs: it is much easier to convince people to move a little further along on the path they are already on, than to move to a completely different path.
With a bit of luck, you may even find yourself implementing an “infeasible” North Star without even realizing it, one little step at a time.
We initially faced a lot of resistance from browser engineers, until eventually a brilliant Google engineer, Anders Ruud and his team experimented with variations of my proposed algorithm and actually closed in on a way to implement the North Star syntax in Chrome.
The rest, as they say, is history. 🌟
Hopefully by now you’re convinced about the value of investing time in reaching alignment on an explicit North Star that has buy-in from the entire product team.
A common misconception is that the North Star is a static goal that prevents you from adapting to new data, such as customer feedback.
But often, your North Star will change a lot over time, and that’s okay.
Having an initial destination does not take away your ability to course correct.
That’s not giving up, it’s adapting.
And yes, it’s true that many product teams do use a vision-led approach — they just start from the car, not the hovercar.
While that confers some of the benefits above, there is still an implicit reduction happening, because the hovercar is still there in the back of their mind.
Note that for this framework to be beneficial, it is important that everyone is on the same page and understands the steps, benefits, and goals of this approach.
Co-designing a North Star with a team that sees the process as a pointless thought experiment will only add friction and will not confer any of these benefits.
Also, this is a mindset that can only work when applied top-down.
If you are not a decision-maker at your place of work and leadership is not on board,
you will have a very hard time if you try to push this ad hoc, without first getting leadership buy-in.
You can try sending them a link to this blog post!
If this post resonated, please share your own case studies in the comments.
Or, if you decide to give this framework a try, I’d love to hear how it went!
for any Compilers geeks out there that want all the deets: it required potentially unbounded lookahead since there is no fixed number of tokens a parser can read and be able to tell the difference between a selector and a declaration. ↩︎
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.
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 🎶
In the end I ended up building two separate components, published under the same bluesky-likes npm package:
<bluesky-likes> — displays the number of likes for a post, and
<bluesky-likers> — displays the list of users who liked a post.
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:
I started by making a single component that did both, but it quickly became clear that it was better to split them up.
It provided a lot more flexibility with only a tiny bit more effort for the common case,
and it allowed me to simplify the internal structure of each component.
Requests are aggressively cached across component instances,
so 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.5 KB minified & gzipped and dependency-free.
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.
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.
You should not need to write CSS to make it look decent
You should not need to write JavaScript to make it work
You should not need to slot content for things that could have sensible defaults
You should not need to specify things it can figure out on its own from things you’ve already specified
If you’re willing to put more work into it, the sky should be the limit.
You should be able to completely restyle it, customize nearly every part of its UI etc, but the UX of these things doesn’t need to be optimized.
For example:
Extensibility over encapsulation:
If something doesn’t need to be hidden away, expose it as a part.
Don’t be frugal with your parts.
The downsides of exposing too many parts are few and small, but not exposing enough parts can make certain styling impossible.
Don’t be frugal with slots:
use slots with fallback content liberally.
That way people can customize content or even entire parts of the UI.
Expose states for conditional styling. Yes, it’s Baseline now.
You can style the <slot> element itself, to avoid adding (and thus, having to expose) additional wrapper divs.
And yes, you can expose a <slot> via a part as well.
Just be mindful that that part will be available whether the slot has slotted content or not.
For these components, as a proof of concept, in addition to parts and slots
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, for extreme customizability.
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 users off the deep end in terms of API complexity, they’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:
Ideally, standard CSS properties on the host should work.
This is also part of the principle of least astonishment.
However, sometimes this is simply not feasible or it would require unacceptable tradeoffs, which brings us to…
Exposing enough custom properties that basic, common customizations don’t require parts.
Nest slots liberally: You should not have to replace an entire part of the UI just to customize its content.
Nested slots allow you to provide UI extension points at different levels of abstraction.
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 components as an example.
They are the poster child for the kind of straightforward, simple component that does one thing well, right?
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 straightforward, there are so many other things that need to be done:
Dynamically responding to changes (in attributes, slots, nested content, etc) like regular HTML elements takes work, especially if you want to do it 100% properly, which is rarely a good idea (more on that below).
Libraries like Lit make some of it easier, but not trivial.
Accessibility and i18n often take orders of magnitude more work than core functionality, especially together.
Designing & implementing style and UI customization hooks
Figuring out the right tradeoffs between performance and all of the above
And this is without any additional functionality creeping up.
A good component has sensible defaults, but allows customization of everything users may reasonably want to customize.
There is nothing more annoying than finding a web component that does almost what you want, but doesn’t allow you to customize the one thing you really need to customize.
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.
This opened it up to usability, accessibility, and i18n issues:
What if you want it to link to the post itself, or even an entirely different URL?
How to customize the link attributes, e.g. rel or target?
a11y: The link did not have a title at the time, only the icon had alt text.
This meant assistive technologies would read it like “Butterfly blue heart fifteen”.
How to word the link title to best communicate what the link does to assistive technologies without excessive verbosity?
And then, how to allow users to customize the link title for i18n?
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 somewhat 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 100% perfectly would incur a performance penalty for little gain.
But on balance, it so far seems the tradeoffs are worth it.
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?
A bigger question is “Do they need to?”.
These links are entirely auxiliary;
in Salma’s original widget avatars were not links at all.
Even if someone wants to explore the profiles of people who liked a post for some reason,
the Bluesky “Liked By” page (already linked via <bluesky-likes>) is a much better fit for this.
When using a pointing device, links are free.
If you don’t interact with them, they don’t get in your way, so you may as well have them even if few users will need them.
But when something is part of the tabbing order, there is now a cost to it.
Is the value of being able to tab to it outweighed by the friction of having to tab past it?
On the other hand, it feels wrong to have links that are not exposed at all to keyboard and assistive tech users.
Even if they are auxiliary, making them entirely inaccessible feels like we’re talking away their agency.
I decided to err on the side of exposing the links to keyboard users,
and added a description, via a description slot with default fallback content, to explain to SR users what is being displayed,
and a skip link after it, which is visible when focused.
Why not use the default slot for the description?
The default slot can be very convenient when nothing else is slotted.
However, it is very annoying to slot things in other slots without slotting anything in the default slot.
Consider this:
It may not look like it, but here we’ve also slotted a few blank text nodes to the default slot,
which would obliterate the SR-accessible default description with no visible signs to the developer.
And since 2/5 devs don’t test at all for screen readers, they would be unlikely to notice.
Default slots are great because they allow users to specify content without having to understand slots — it’s just how HTML works.
However, because of this issue, I mainly recommend using them for things one nearly always wants to specify when using the component.
If actual content is slotted into it, the additional blank nodes are not a problem.
You could also choose to go for the default slot if you don’t have any other slots, though that’s a little more dangerous,
as you may always want to add more slots later.
It’s still not an ideal user experience though.
A skip link offers you the choice of skipping only at the beginning.
What happens if you tab through 30 links, and then decide you’ve had too much?
Or when you’re tabbing backwards, via Shift+Tab?
Then you’re still stuck wading through all links with no escape and no respite.
In the end, perhaps I should bite the bullet and implement more sophisticated keyboard navigation,
similar to how native form controls work (imagine a <select> having tab stops for every <option>!).
But I have already spent more than is reasonable on these components, so it’s time to let them ride the trains,
and leave the rest to PRs.
For now, I implemented Home and End keys to jump to the first and last link respectively, so that at least users have an out.
But as a former TAG member, I can’t help but see this as a gap in the platform.
It should not be this hard to create accessible components.
It should not require jumping through hoops, and the process should not be a minefield.
Good keyboard accessibility benefits everyone, and the primitives the web platform currently provides to enable that are egregiously insufficient.
Difficulty jumps to eleven when you want to make a component localizable.
As a minimum, it means any UI text, no matter where it appears, must be customizable.
This is desirable anyway for customizability, but it becomes essential for localization.
The quick and dirty way is to provide slots for element content and attributes for content that appears in attributes (e.g. titles, aria-labels, etc).
Avoid providing attributes as the only way to customize content.
This means they cannot contain HTML, which is often necessary for localization, and always desirable for customization.
That said, attributes are totally fine as a shortcut for making common cases easy.
E.g. a common pattern is to provide both an attribute and a label with the same name for commonly customizable things (e.g. labels).
However, this is often not enough.
For example, 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).
The web platform thankfully already provides a low-level primitive for formatting numbers: Intl.NumberFormat,
which you can also access via number.toLocaleString().
For example, to format 1234567 as 1.2M , you can do
// Try it in the console!
(1234567).toLocaleString("en", {notation: "compact"})
This is great for English UIs, but what about other languages?
If you answered “Oh, we’ll just pass this.lang to instead of a hardcoded "en"”,
you’d be wrong, at least for the general case.
That gives you the element language only when it’s directly specified on the element via a lang attribute.
However, usually the lang attribute is not specified on every element,
but on an ancestor, and it inherits down.
Something like is a good compromise:
const lang = this.lang
|| this.parentNode.closest("[lang]")?.lang
|| this.ownerDocument.documentElement.lang
|| "en";
This gets you the element’s language correctly if it’s:
specified on the element itself
specified on an ancestor element within the same shadow tree
specified on the root element of the document
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 also not the root element.
I’d wager that case is very rare, and there is always the escape hatch of specifying the lang attribute on the component itself.
If the route above is a shortcut and misses some cases, you may be wondering what it would take to cover every possible case.
Maybe it’s just for the lulz, or maybe you’re working under very strict guidelines that require you to fully emulate how a native element would behave.
I advise against following or even reading this section.
Proceed at your own risk.
Or save your mental health and skip it.
Unless you’re in the WHATWG, in which case please, go ahead.
So what would doing it 100% properly look like?
First, we’d want to take nested shadow roots into account, using 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 and 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 continue.
I 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 element between that ancestor and your component?
I.e. you go from this:
Your component language is now es, but nothing changed in the element you were observing (#a), so nothing notified your component.
What is your recourse?
I told you to not think about it. You didn’t listen. It’s still not too late to skip this section and escape the horrors that lie ahead.
Still here? Damn, you’re stubborn.
Fine, here’s how to do it with mutation observers if you really need to. But be warned, it’s going to hurt.
Mutation observers cannot observe ancestors, so the only way to detect changes that way would be to observe not just the element with the lang attribute
but also its entire subtree.
Oh and if the path from your component to that ancestor involves shadow trees, you need to observe them separately,
because mutation observers don’t reach past shadow trees (proposal to change that).
😮💨
Surely, that should do it, right?
WRONG again.
I told you it would hurt.
Consider the scenario where the ancestor with the lang attribute is removed.
Mutation observers cannot observe element removal (proposal to fix that),
so if you go from this:
…nothing will notify your component if you’re just observing #a and its descendants.
So the only way to get it right in all cases is to observe the entire tree, from the document root down to your component, including all shadow trees between your component and the root.Feeling nauseous yet?
There is one alternative.
So, the browser knows what the element’s language is, but the only way it exposes it is the :lang() pseudo-class,
which doesn’t allow you to read it, but only check whether an element matches a given language.
While not ideal, we can hack this to observe language changes.
Coupled with the earlier snippet to detect the current language,
this allows us to detect changes to the component’s language without the huge performance impact of observing the entire page.
How can we do that?
Once you’ve detected the component language, generate a rule that sets a CSS variable.
E.g. suppose you detected el, you’d add this to your shadow DOM:
:host(:lang(el)) {
--lang: el;
}
Then, we register the --lang property,
and observe changes to it via Style Observer or just raw transition events.
When a change is detected, run the detection snippet again and add another CSS rule.
When registering component CSS properties, make sure to register them globally (e.g. via CSS.registerProperty()),
as @property does not currently work in shadow DOM.
This is already spec’ed, but not yet implemented by browsers.
Now, should you do this?
Just because you can, doesn’t mean you should.
In the vast majority of cases, a few false positives/negatives are acceptable,
and the tradeoff and performance impact of introducing all this complexity is absolutely not worth it.
I can only see it being a good idea in very specific cases, when you have a reason to strive for this kind of perfection.
Most 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,
and how much complexity and performance impact you’re willing to sacrifice for it.
But going all the way is rarely a good balance of tradeoffs.
That said, this should be easier.
Reading a component’s language should not require balancing tradeoffs for crying out loud!
There is some progress on that front.
In September at TPAC we got WHATWG consensus
on standardizing a way to read the current language / direction and react to future changes.
To my knowledge, not much has happened since, but it’s a start.
Perhaps this dramatic reenactment generates some empathy among WHATWG folks on what web components developers have to go through.
Hopefully, I have demonstrated that
if you’re not careful, building a web component can become a potentially unbounded task.
Some tasks are definitely necessary, e.g. accessibility, i18n, performance, etc,
but there comes a point where you’re petting.
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!
Thanks to Léonie Watson for some of the early a11y feedback, and to Dmitry Sharabin for helping with the initial API exploration.