Unmasking The Magic: The Wizard Of Oz Method For UX Research Unmasking The Magic: The Wizard Of Oz Method For UX Research Victor Yocco 2025-07-10T10:00:00+00:00 2025-07-16T16:32:47+00:00 New technologies and innovative concepts frequently enter the product development lifecycle, promising to revolutionize user experiences. However, even the […]
AccessibilityDesign Guidelines For Better Notifications UX Design Guidelines For Better Notifications UX Vitaly Friedman 2025-07-07T13:00:00+00:00 2025-07-09T15:33:43+00:00 In many products, setting notification channels on mute is a default, rather than an exception. The reason for that is their high frequency, which creates disruptions and eventually notification […]
AccessibilityTurning User Research Into Real Organizational Change Turning User Research Into Real Organizational Change Paul Boag 2025-07-01T10:00:00+00:00 2025-07-02T15:03:33+00:00 This article is sponsored by Lyssna We’ve all been there: you pour your heart and soul into conducting meticulous user research. You gather insightful data, create detailed […]
AccessibilityHow To Build A Multilingual Website With Nuxt.js How To Build A Multilingual Website With Nuxt.js Tim Benniks 2024-08-01T15:00:00+00:00 2025-06-25T15:04:30+00:00 This article is sponsored by Hygraph Internationalization, often abbreviated as i18n, is the process of designing and developing software applications in a way that they […]
Accessibility
2024-08-01T15:00:00+00:00
2025-06-25T15:04:30+00:00
This article is sponsored by Hygraph
Internationalization, often abbreviated as i18n, is the process of designing and developing software applications in a way that they can be easily adapted to various spoken languages like English, German, French, and more without requiring substantial changes to the codebase. It involves moving away from hardcoded strings and techniques for translating text, formatting dates and numbers, and handling different character encodings, among other tasks.
Internationalization can give users the choice to access a given website or application in their native language, which can have a positive impression on them, making it crucial for reaching a global audience.
In this tutorial, we’re making a website that puts these i18n pieces together using a combination of libraries and a UI framework. You’ll want to have intermediate proficiency with JavaScript, Vue, and Nuxt to follow along. Throughout this article, we will learn by examples and incrementally build a multilingual Nuxt website. Together, we will learn how to provide i18n support for different languages, lazy-load locale messages, and switch locale on runtime.
After that, we will explore features like interpolation, pluralization, and date/time translations.
And finally, we will fetch dynamic localized content from an API server using Hygraph as our API server to get localized content. If you do not have a Hygraph account please create one for free before jumping in.
As a final detail, we will use Vuetify as our UI framework, but please feel free to use another framework if you want. The final code for what we’re building is published in a GitHub repository for reference. And finally, you can also take a look at the final result in a live demo.
nuxt-i18n
Librarynuxt-i18n
is a library for implementing internationalization in Nuxt.js applications, and it’s what we will be using in this tutorial. The library is built on top of Vue I18n, which, again, is the de facto standard library for implementing i18n in Vue applications.
What makes nuxt-i18n
ideal for our work is that it provides the comprehensive set of features included in Vue I18n while adding more functionalities that are specific to Nuxt, like lazy loading locale messages, route generation and redirection for different locales, SEO metadata per locale, locale-specific domains, and more.
Start a new Nuxt.js project and set it up with a UI framework of your choice. Again, I will be using Vue to establish the interface for this tutorial.
Let us add a basic layout for our website and set up some sample Vue templates.
First, a “Blog” page:
<!-- pages/blog.vue -->
<template>
<div>
<v-card color="cardBackground">
<v-card-title class="text-overline">
Home
</v-card-title>
<v-card-text>
This is the home page description
</v-card-text>
</v-card>
</div>
</template>
Next, an “About” page:
<!-- pages/about.vue -->
<template>
<div>
<v-card color="cardBackground">
<v-card-title class="text-overline">
About
</v-card-title>
<v-card-text>
This is the about page description
</v-card-text>
</v-card>
</div>
</template>
This gives us a bit of a boilerplate that we can integrate our i18n work into.
The page templates look good, but notice how the text is hardcoded. As far as i18n goes, hardcoded content is difficult to translate into different locales. That is where the nuxt-i18n
library comes in, providing the language-specific strings we need for the Vue components in the templates.
We’ll start by installing the library via the command line:
npx nuxi@latest module add i18n
Inside the nuxt.config.ts
file, we need to ensure that we have @nuxtjs/i18n
inside the modules
array. We can use the i18n
property to provide module-specific configurations.
// nuxt.config.ts
export default defineNuxtConfig({
// ...
modules: [
...
"@nuxtjs/i18n",
// ...
],
i18n: {
// nuxt-i18n module configurations here
}
// ...
});
Since the nuxt-i18n
library is built on top of the Vue I18n library, we can utilize its features in our Nuxt application as well. Let us create a new file, i18n.config.ts
, which we will use to provide all vue-i18n
configurations.
// i18n.config.ts
export default defineI18nConfig(() => ({
legacy: false,
locale: "en",
messages: {
en: {
homePage: {
title: "Home",
description: "This is the home page description."
},
aboutPage: {
title: "About",
description: "This is the about page description."
},
},
},
}));
Here, we have specified internationalization configurations, like using the en
locale, and added messages for the en
locale. These messages can be used inside the markup in the templates we made with the help of a $t
function from Vue I18n.
Next, we need to link the i18n.config.ts
configurations in our Nuxt config file.
// nuxt.config.ts
export default defineNuxtConfig({
...
i18n: {
vueI18n: "./i18n.config.ts"
}
...
});
Now, we can use the $t
function in our components — as shown below — to parse strings from our internationalization configurations.
Note: There’s no need to import $t
since we have Nuxt’s default auto-import functionality.
<!-- i18n.config.ts -->
<template>
<div>
<v-card color="cardBackground">
<v-card-title class="text-overline">
{{ $t("homePage.title") }}
</v-card-title>
<v-card-text>
{{ $t("homePage.description") }}
</v-card-text>
</v-card>
</div>
</template>
We have the title and description served from the configurations. Next, we can add more languages to the same config. For example, here’s how we can establish translations for English (en
), French (fr
) and Spanish (es
):
// i18n.config.ts
export default defineI18nConfig(() => ({
legacy: false,
locale: "en",
messages: {
en: {
// English
},
fr: {
// French
},
es: {
// Spanish
}
},
}));
For a production website with a lot of content that needs translating, it would be unwise to bundle all of the messages from different locales in the main bundle. Instead, we should use the nuxt-i18
lazy loading feature asynchronously load only the required language rather than all of them at once. Also, having messages for all locales in a single configuration file can become difficult to manage over time, and breaking them up like this makes things easier to find.
Let’s set up the lazy loading feature in nuxt.config.ts
:
// etc.
i18n: {
vueI18n: "./i18n.config.ts",
lazy: true,
langDir: "locales",
locales: [
{
code: "en",
file: "en.json",
name: "English",
},
{
code: "es",
file: "es.json",
name: "Spanish",
},
{
code: "fr",
file: "fr.json",
name: "French",
},
],
defaultLocale: "en",
strategy: "no_prefix",
},
// etc.
This enables lazy loading and specifies the locales
directory that will contain our locale files. The locales
array configuration specifies from which files Nuxt.js should pick up messages for a specific language.
Now, we can create individual files for each language. I’ll drop all three of them right here:
// locales/en.json
{
"homePage": {
"title": "Home",
"description": "This is the home page description."
},
"aboutPage": {
"title": "About",
"description": "This is the about page description."
},
"selectLocale": {
"label": "Select Locale"
},
"navbar": {
"homeButton": "Home",
"aboutButton": "About"
}
}
// locales/fr.json
{
"homePage": {
"title": "Bienvenue sur la page d'accueil",
"description": "Ceci est la description de la page d'accueil."
},
"aboutPage": {
"title": "À propos de nous",
"description": "Ceci est la description de la page à propos de nous."
},
"selectLocale": {
"label": "Sélectionner la langue"
},
"navbar": {
"homeButton": "Accueil",
"aboutButton": "À propos"
}
}
// locales/es.json
{
"homePage": {
"title": "Bienvenido a la página de inicio",
"description": "Esta es la descripción de la página de inicio."
},
"aboutPage": {
"title": "Sobre nosotros",
"description": "Esta es la descripción de la página sobre nosotros."
},
"selectLocale": {
"label": "Seleccione el idioma"
},
"navbar": {
"homeButton": "Inicio",
"aboutButton": "Acerca de"
}
}
We have set up lazy loading, added multiple languages to our application, and moved our locale messages to separate files. The user gets the right locale for the right message, and the locale messages are kept in a maintainable manner inside the code base.
We have different locales, but to see them in action, we will build a component that can be used to switch between the available locales.
<!-- components/select-locale.vue -->
<script setup>
const { locale, locales, setLocale } = useI18n();
const language = computed({
get: () => locale.value,
set: (value) => setLocale(value),
});
</script>
<template>
<v-select
:label="$t('selectLocale.label')"
variant="outlined"
color="primary"
density="compact"
:items="locales"
item-title="name"
item-value="code"
v-model="language"
></v-select>
</template>
This component uses the useI18n
hook provided by the Vue I18n library and a computed property language
to get and set the global locale from a <select>
input. To make this even more like a real-world website, we’ll include a small navigation bar that links up all of the website’s pages.
<!-- components/select-locale.vue -->
<template>
<v-app-bar app :elevation="2" class="px-2">
<div>
<v-btn color="button" to="/">
{{ $t("navbar.homeButton") }}
</v-btn>
<v-btn color="button" to="/about">
{{ $t("navbar.aboutButton") }}
</v-btn>
</div>
<v-spacer />
<div class="mr-4 mt-6">
<SelectLocale />
</div>
</v-app-bar>
</template>
That’s it! Now, we can switch between languages on the fly.
We have a basic layout, but I thought we’d take this a step further and build a playground page we can use to explore more i18n features that are pretty useful when building a multilingual website.
Interpolation and pluralization are internationalization techniques for handling dynamic content and grammatical variations across different languages. Interpolation allows developers to insert dynamic variables or expressions into translated strings. Pluralization addresses the complexities of plural forms in languages by selecting the appropriate grammatical form based on numeric values. With the help of interpolation and pluralization, we can create more natural and accurate translations.
To use pluralization in our Nuxt app, we’ll first add a configuration to the English locale file.
// locales/en.json
{
// etc.
"playgroundPage": {
"pluralization": {
"title": "Pluralization",
"apple": "No Apple | One Apple | {count} Apples",
"addApple": "Add"
}
}
// etc.
}
The pluralization configuration set up for the key apple
defines an output — No Apple
— if a count of 0 is passed to it, a second output — One Apple
— if a count of 1 is passed, and a third — 2 Apples
, 3 Apples
, and so on — if the count passed in is greater than 1.
Here is how we can use it in your component: Whenever you click on the add button, you will see pluralization in action, changing the strings.
<!-- pages/playground.vue -->
<script setup>
let appleCount = ref(0);
const addApple = () => {
appleCount.value += 1;
};
</script>
<template>
<v-container fluid>
<!-- PLURALIZATION EXAMPLE -->
<v-card color="cardBackground">
<v-card-title class="text-overline">
{{ $t("playgroundPage.pluralization.title") }}
</v-card-title>
<v-card-text>
{{ $t("playgroundPage.pluralization.apple", { count: appleCount }) }}
</v-card-text>
<v-card-actions>
<v-btn
@click="addApple"
color="primary"
variant="outlined"
density="comfortable"
>{{ $t("playgroundPage.pluralization.addApple") }}</v-btn
>
</v-card-actions>
</v-card>
</v-container>
</template>
To use interpolation in our Nuxt app, first, add a configuration in the English locale file:
// locales/en.json
{
...
"playgroundPage": {
...
"interpolation": {
"title": "Interpolation",
"sayHello": "Hello, {name}",
"hobby": "My favourite hobby is {0}.",
"email": "You can reach out to me at {account}{'@'}{domain}.com"
},
// etc.
}
// etc.
}
The message for sayHello
expects an object passed to it having a key name
when invoked — a process known as named interpolation.
The message hobby
expects an array to be passed to it and will pick up the 0th element, which is known as list interpolation.
The message email
expects an object with keys account
, and domain
and joins both with a literal string "@"
. This is known as literal interpolation.
Below is an example of how to use it in the Vue components:
<!-- pages/playground.vue -->
<template>
<v-container fluid>
<!-- INTERPOLATION EXAMPLE -->
<v-card color="cardBackground">
<v-card-title class="text-overline">
{{ $t("playgroundPage.interpolation.title") }}
</v-card-title>
<v-card-text>
<p>
{{
$t("playgroundPage.interpolation.sayHello", {
name: "Jane",
})
}}
</p>
<p>
{{
$t("playgroundPage.interpolation.hobby", ["Football", "Cricket"])
}}
</p>
<p>
{{
$t("playgroundPage.interpolation.email", {
account: "johndoe",
domain: "hygraph",
})
}}
</p>
</v-card-text>
</v-card>
</v-container>
</template>
Translating dates and times involves translating date and time formats according to the conventions of different locales. We can use Vue I18n’s features for formatting date strings, handling time zones, and translating day and month names for managing date time translations. We can give the configuration for the same using the datetimeFormats
key inside the vue-i18n
config object.
// i18n.config.ts
export default defineI18nConfig(() => ({
fallbackLocale: "en",
datetimeFormats: {
en: {
short: {
year: "numeric",
month: "short",
day: "numeric",
},
long: {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
hour: "numeric",
minute: "numeric",
hour12: false,
},
},
fr: {
short: {
year: "numeric",
month: "short",
day: "numeric",
},
long: {
year: "numeric",
month: "short",
day: "numeric",
weekday: "long",
hour: "numeric",
minute: "numeric",
hour12: true,
},
},
es: {
short: {
year: "numeric",
month: "short",
day: "numeric",
},
long: {
year: "2-digit",
month: "short",
day: "numeric",
weekday: "long",
hour: "numeric",
minute: "numeric",
hour12: true,
},
},
},
}));
Here, we have set up short
and long
formats for all three languages. If you are coding along, you will be able to see available configurations for fields, like month and year, thanks to TypeScript and Intellisense features provided by your code editor. To display the translated dates and times in components, we should use the $d
function and pass the format to it.
<!-- pages.playground.vue -->
<template>
<v-container fluid>
<!-- DATE TIME TRANSLATIONS EXAMPLE -->
<v-card color="cardBackground">
<v-card-title class="text-overline">
{{ $t("playgroundPage.dateTime.title") }}
</v-card-title>
<v-card-text>
<p>Short: {{ (new Date(), $d(new Date(), "short")) }}</p>
<p>Long: {{ (new Date(), $d(new Date(), "long")) }}</p>
</v-card-text>
</v-card>
</v-container>
</template>
We saw how to implement localization with static content. Now, we’ll attempt to understand how to fetch dynamic localized content in Nuxt.
We can build a blog page in our Nuxt App that fetches data from a server. The server API should accept a locale and return data in that specific locale.
Hygraph has a flexible localization API that allows you to publish and query localized content. If you haven’t created a free Hygraph account yet, you can do that on the Hygraph website to continue following along.
Go to Project Settings → Locales and add locales for the API.
We have added two locales: English and French. Now we need aq localized_post
model in our schema that only two fields: title
and body.
Ensure to make these “Localized” fields while creating them.
Add permissions to consume the localized content, go to Project settings → Access → API Access → Public Content API, and assign Read permissions to the localized_post
model.
Now, we can go to the Hygrapgh API playground and add some localized data to the database with the help of GraphQL mutations. To limit the scope of this example, I am simply adding data from the Hygraph API playground. In an ideal world, a create/update mutation would be triggered from the front end after receiving user input.
Run this mutation in the Hygraph API playground:
mutation createLocalizedPost {
createLocalizedPost(
data: {
title: "A Journey Through the Alps",
body: "Exploring the majestic mountains of the Alps offers a thrilling experience. The stunning landscapes, diverse wildlife, and pristine environment make it a perfect destination for nature lovers.",
localizations: {
create: [
{locale: fr, data: {title: "Un voyage à travers les Alpes", body: "Explorer les majestueuses montagnes des Alpes offre une expérience palpitante. Les paysages époustouflants, la faune diversifiée et l'environnement immaculé en font une destination parfaite pour les amoureux de la nature."}}
]
}
}
) {
id
}
}
The mutation above creates a post with the en
locale and includes a fr
version of the same post. Feel free to add more data to your model if you want to see things work from a broader set of data.
Now that we have Hygraph API content ready for consumption let’s take a moment to understand how it’s consumed inside the Nuxt app.
To do this, we’ll install nuxt-graphql-client to serve as the app’s GraphQL client. This is a minimal GraphQL client for performing GraphQL operations without having to worry about complex configurations, code generation, typing, and other setup tasks.
npx nuxi@latest module add graphql-client
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
// ...
"nuxt-graphql-client"
// ...
],
runtimeConfig: {
public: {
GQL_HOST: 'ADD_YOUR_GQL_HOST_URL_HERE_OR_IN_.env'
}
},
});
Next, let’s add our GraphQL queries in graphql/queries.graphql
.
query getPosts($locale: [Locale!]!) {
localizedPosts(locales: $locale) {
title
body
}
}
The GraphQL client will automatically scan .graphql
and .gql
files and generate client-side code and typings in the .nuxt/gql
folder. All we need to do is stop and restart the Nuxt application. After restarting the app, the GraphQL client will allow us to use a GqlGetPosts
function to trigger the query.
Now, we will build the Blog page where by querying the Hygraph server and showing the dynamic data.
// pages/blog.vue
<script lang="ts" setup>
import type { GetPostsQueryVariables } from "#gql";
import type { PostItem, Locale } from "../types/types";
const { locale } = useI18n();
const posts = ref<PostItem[]>([]);
const isLoading = ref(false);
const isError = ref(false);
const fetchPosts = async (localeValue: Locale) => {
try {
isLoading.value = true;
const variables: GetPostsQueryVariables = {
locale: [localeValue],
};
const data = await GqlGetPosts(variables);
posts.value = data?.localizedPosts ?? [];
} catch (err) {
console.log("Fetch Error, Something went wrong", err);
isError.value = true;
} finally {
isLoading.value = false;
}
};
// Fetch posts on component mount
onMounted(() => {
fetchPosts(locale.value as Locale);
});
// Watch for locale changes
watch(locale, (newLocale) => {
fetchPosts(newLocale as Locale);
});
</script>
This code fetches only the current locale from the useI18n
hook and sends it to the fetchPosts
function when the Vue component is mounted. The fetchPosts
function will pass the locale to the GraphQL query as a variable and obtain localized data from the Hygraph server. We also have a watcher on the locale
so that whenever the global locale is changed by the user we make an API call to the server again and fetch posts in that locale.
And, finally, let’s add markup for viewing our fetched data!
<!-- pages/blog.vue -->
<template>
<v-container fluid>
<v-card-title class="text-overline">Blogs</v-card-title>
<div v-if="isLoading">
<v-skeleton-loader type="card" v-for="n in 2" :key="n" class="mb-4" />
</div>
<div v-else-if="isError">
<p>Something went wrong while getting blogs please check the logs.</p>
</div>
<div v-else>
<div
v-for="(post, index) in posts"
:key="post.title || index"
class="mb-4"
>
<v-card color="cardBackground">
<v-card-title class="text-h6">{{ post.title }}</v-card-title>
<v-card-text>{{ post.body }}</v-card-text>
</v-card>
</div>
</div>
</v-container>
</template>
Awesome! If all goes according to plan, then your app should look something like the one in the following video.
Check that out — we just made the functionality for translating content for a multilingual website! Now, a user can select a locale from a list of options, and the app fetches content for the selected locale and automatically updates the displayed content.
Did you think that translations would require more difficult steps? It’s pretty amazing that we’re able to cobble together a couple of libraries, hook them up to an API, and wire everything up to render on a page.
Of course, there are other libraries and resources for handling internationalization in a multilingual context. The exact tooling is less the point than it is seeing what pieces are needed to handle dynamic translations and how they come together.
Getting To The Bottom Of Minimum WCAG-Conformant Interactive Element Size Getting To The Bottom Of Minimum WCAG-Conformant Interactive Element Size Eric Bailey 2024-07-19T13:00:00+00:00 2025-06-25T15:04:30+00:00 There are many rumors and misconceptions about conforming to WCAG criteria for the minimum sizing of interactive elements. I’d like to […]
Accessibility
2024-07-19T13:00:00+00:00
2025-06-25T15:04:30+00:00
There are many rumors and misconceptions about conforming to WCAG criteria for the minimum sizing of interactive elements. I’d like to use this post to demystify what is needed for baseline compliance and to point out an approach for making successful and inclusive interactive experiences using ample target sizes.
Getting right to it: When it comes to pure Web Content Accessibility Guidelines (WCAG) conformance, the bare minimum pixel size for an interactive, non-inline element is 24×24 pixels. This is outlined in Success Criterion 2.5.8: Target Size (Minimum).
Success Criterion 2.5.8 is level AA, which is the most commonly used level for public, mass-consumed websites. This Success Criterion (or SC for short) is sometimes confused for SC 2.5.5 Target Size (Enhanced), which is level AAA. The two are distinct and provide separate guidance for properly sizing interactive elements, even if they appear similar at first glance.
SC 2.5.8 is relatively new to WCAG, having been released as part of WCAG version 2.2, which was published on October 5th, 2023. WCAG 2.2 is the most current version of the standard, but this newer release date means that knowledge of its existence isn’t as widespread as the older SC, especially outside of web accessibility circles. That said, WCAG 2.2 will remain the standard until WCAG 3.0 is released, something that is likely going to take 10–15 years or more to happen.
SC 2.5.5 calls for larger interactive elements sizes that are at least 44×44 pixels (compared to the SC 2.5.8 requirement of 24×24 pixels). At the same time, notice that SC 2.5.5 is level AAA (compared to SC 2.5.8, level AA) which is a level reserved for specialized support beyond level AA.
Sites that need to be fully WCAG Level AAA conformant are rare. Chances are that if you are making a website or web app, you’ll only need to support level AA. Level AAA is often reserved for large or highly specialized institutions.
The family of padding
-related properties in CSS can be used to extend the interactive area of an element to make it conformant. For example, declaring padding: 4px;
on an element that measures 16×16 pixels invisibly increases its bounding box to a total of 24×24 pixels. This, in turn, means the interactive element satisfies SC 2.5.8.
This is a good trick for making smaller interactive elements easier to click and tap. If you want more information about this sort of thing, I enthusiastically recommend Ahmad Shadeed’s post, “Designing better target sizes”.
I think it’s also worth noting that CSS margin
could also hypothetically be used to achieve level AA conformance since the SC includes a spacing exception:
The size of the target for pointer inputs is at least 24×24 CSS pixels, except where:
Spacing: Undersized targets (those less than 24×24 CSS pixels) are positioned so that if a 24 CSS pixel diameter circle is centered on the bounding box of each, the circles do not intersect another target or the circle for another undersized target;
[…]
The difference here is that padding extends the interactive area, while margin does not. Through this lens, you’ll want to honor the spirit of the success criterion because partial conformance is adversarial conformance. At the end of the day, we want to help people successfully click or tap interactive elements, such as buttons.
We tend to think of targets in terms of block elements — elements that are displayed on their own line, such as a button at the end of a call-to-action. However, interactive elements can be inline elements as well. Think of links in a paragraph of text.
Inline interactive elements, such as text links in paragraphs, do not need to meet the 24×24 pixel minimum requirement. Just as margin
is an exception in SC 2.5.8: Target Size (Minimum), so are inline elements with an interactive target:
The size of the target for pointer inputs is at least 24×24 CSS pixels, except where:
[…]
Inline: The target is in a sentence or its size is otherwise constrained×the line-height of non-target text;
[…]
If the differences between interactive elements that are inline and block are still confusing, that’s probably because the whole situation is even further muddied by third-party human interface guidelines requiring interactive sizes closer to what the level AAA Success Criterion 2.5.5 Target Size (Enhanced) demands.
For example, Apple’s “Human Interface Guidelines” and Google’s “Material Design” are guidelines for how to design interfaces for their respective platforms. Apple’s guidelines recommend that interactive elements are 44×44 points, whereas Google’s guides stipulate target sizes that are at least 48×48 using density-independent pixels.
These may satisfy Apple and Google requirements for designing interfaces, but are they WCAG-conformant Apple and Google — not to mention any other organization with UI guidelines — can specify whatever interface requirements they want, but are they copasetic with WCAG SC 2.5.5 and SC 2.5.8?
It’s important to ask this question because there is a hierarchy when it comes to accessibility compliance, and it contains legal levels:
Human interface guidelines often inform design systems, which, in turn, influence the sites and apps that are built by authors like us. But they’re not the “authority” on accessibility compliance. Notice how everything is (and ought to be) influenced by WCAG at the very top of the chain.
Even if these third-party interface guidelines conform to SC 2.5.5 and 2.5.8, it’s still tough to tell when they are expressed in “points” and “density independent pixels” which aren’t pixels, but often get conflated as such. I’d advise not getting too deep into researching what a pixel truly is. Trust me when I say it’s a road you don’t want to go down. But whatever the case, the inconsistent use of unit sizes exacerbates the issue.
I’ve also observed some developers attempting to use the pointer
media feature as a clever “trick” to detect when a touchscreen is present, then conditionally adjust an interactive element’s size as a way to get around the WCAG requirement.
After all, mouse cursors are for fine movements, and touchscreens are for more broad gestures, right? Not always. The thing is, devices are multimodal. They can support many different kinds of input and don’t require a special switch to flip or button to press to do so. A straightforward example of this is switching between a trackpad and a keyboard while you browse the web. A less considered example is a device with a touchscreen that also supports a trackpad, keyboard, mouse, and voice input.
You might think that the combination of trackpad, keyboard, mouse, and voice inputs sounds like some sort of absurd, obscure Frankencomputer, but what I just described is a Microsoft Surface laptop, and guess what? They’re pretty popular.
There is a difference between the two, even though they are often used interchangeably. Let’s delineate the two as clearly as possible:
The other end of this consideration is that people with motor control conditions — like hand tremors or arthritis — can and do use mice inputs. This means that fine input actions may be painful and difficult, yet ultimately still possible to perform.
People also use more precise input mechanisms for touchscreens all the time, including both official accessories and aftermarket devices. In other words, some devices designed to accommodate coarse input can also be used for fine detail work.
I’d be remiss if I didn’t also point out that people plug mice and keyboards into smartphones. We cannot automatically say that they only support coarse pointers:
My point is that a mode-based approach to inclusive design is a trap. This isn’t even about view–tap asymmetry. Creating entire alternate experiences based on assumed input mode reinforces an ugly “us versus them” mindset. It’s also far more work to set up, maintain, and educate others.
It’s better to proactively accommodate an unknown number of unknown people using an unknown suite of devices in unknown ways by providing an inclusive experience by default. Doing so has a list of benefits:
After all, that tap input might be coming from a tongue, and that click event might be someone raising their eyebrows.
A WCAG-conformant 24×24 minimum pixel size requirement for interactive elements is our industry’s best understanding of what can accommodate most access needs distributed across a global population accessing an unknown amount of content dealing with unknown topics in unknown ways under unknown circumstances.
The load-bearing word in that previous sentence is minimum. The guidance — and the pixel size it mandates — is likely a balancing act between:
Even the SC itself acknowledges this potential limitation:
“This Success Criterion defines a minimum size and, if this can’t be met, a minimum spacing. It is still possible to have very small and difficult-to-activate targets and meet the requirements of this Success Criterion.”
Larger interactive areas can be a good thing to strive for. This is to say a minimum of approximately 40 pixels may be beneficial for individuals who struggle with the smaller yet still WCAG-conformant size.
We should also be careful not to overcorrect by dropping in gigantic interactive elements in all of our work. If an interactive area is too large, it risks being activated by accident. This is important to note when an interactive element is placed in close proximity to other interactive elements and even more important to consider when activating those elements can result in irrevocable consequences.
There is also a phenomenon where elements, if large enough, are not interpreted or recognized as being interactive. Consequently, users may inadvertently miss them, despite large sizing.
Conformant and successful interactive areas — both large and small — require knowing the ultimate goals of your website or web app. When you arm yourself with this context, you are empowered to make informed decisions about the kinds of people who use your service, why they use the service, and how you can accommodate them.
For example, the Glow Baby app uses larger interactive elements because it knows the user is likely holding an adorable, albeit squirmy and fussy, baby while using the application. This allows Glow Baby to emphasize the interactive targets in the interface to accommodate parents who have their hands full.
In the same vein, SC SC 2.5.8 acknowledges that smaller touch targets — such as those used in map apps — may contextually be exempt:
For example, in digital maps, the position of pins is analogous to the position of places shown on the map. If there are many pins close together, the spacing between pins and neighboring pins will often be below 24 CSS pixels. It is essential to show the pins at the correct map location; therefore, the Essential exception applies.
[…]
When the “Essential” exception is applicable, authors are strongly encouraged to provide equivalent functionality through alternative means to the extent practical.
Note that this exemption language is not carte blanche to make your own work an exception to the rule. It is more of a mechanism, and an acknowledgment that broadly applied rules may have exceptions that are worth thinking through and documenting for future reference.
We also want to consider the larger context of the device itself as well as the environment the device will be used in.
Larger, more fixed position touchscreens compel larger interactive areas. Smaller devices that are moved around in space a lot (e.g., smartwatches) may benefit from alternate input mechanisms such as voice commands.
What about people who are driving in a car? People in this context probably ought to be provided straightforward, simple interactions that are facilitated via large interactive areas to prevent them from taking their eyes off the road. The same could also be said for high-stress environments like hospitals and oil rigs.
Similarly, devices and apps that are designed for children may require interactive areas that are larger than WCAG requirements for interactive areas. So would experiences aimed at older demographics, where age-derived vision and motor control disability factors tend to be more present.
Minimum conformant interactive area experiences may also make sense in their own contexts. Data-rich, information-dense experiences like the Bloomberg terminal come to mind here.
While you can control what components you include in a design system, you cannot control where and how they’ll be used by those who adopt and use that design system. Because of this, I suggest defensively baking accessible defaults into your design systems because they can go a long way toward incorporating accessible practices when they’re integrated right out of the box.
One option worth consideration is providing an accessible range of choices. Components, like buttons, can have size variants (e.g., small, medium, and large), and you can provide a minimally conformant interactive target on the smallest variant and then offer larger, equally conformant versions.
There is no magic number or formula to get you that perfect Goldilocks “not too small, not too large, but just right” interactive area size. It requires knowledge of what the people who want to use your service want, and how they go about getting it.
The best way to learn that? Ask people.
Accessibility research includes more than just asking people who use screen readers what they think. It’s also a lot easier to conduct than you might think! For example, prototypes are a great way to quickly and inexpensively evaluate and de-risk your ideas before committing to writing production code. “Conducting Accessibility Research In An Inaccessible Ecosystem” by Dr. Michele A. Williams is chock full of tips, strategies, and resources you can use to help you get started with accessibility research.
The bottom line is that
“Compliant” does not always equate to “usable.” But compliance does help set baseline requirements that benefit everyone.
“
To sum things up:
And, perhaps most importantly, all of this is about people and enabling them to get what they need.
How To Make A Strong Case For Accessibility How To Make A Strong Case For Accessibility Vitaly Friedman 2024-06-26T12:00:00+00:00 2025-06-25T15:04:30+00:00 Getting support for accessibility efforts isn’t easy. There are many accessibility myths, wrong assumptions, and expectations that make accessibility look like a complex, expensive, and […]
Accessibility
2024-06-26T12:00:00+00:00
2025-06-25T15:04:30+00:00
Getting support for accessibility efforts isn’t easy. There are many accessibility myths, wrong assumptions, and expectations that make accessibility look like a complex, expensive, and time-consuming project. Let’s fix that!
Below are some practical techniques that have been working well for me to convince stakeholders to support and promote accessibility in small and large companies.
.course-intro{–shadow-color:206deg 31% 60%;background-color:#eaf6ff;border:1px solid #ecf4ff;box-shadow:0 .5px .6px hsl(var(–shadow-color) / .36),0 1.7px 1.9px -.8px hsl(var(–shadow-color) / .36),0 4.2px 4.7px -1.7px hsl(var(–shadow-color) / .36),.1px 10.3px 11.6px -2.5px hsl(var(–shadow-color) / .36);border-radius:11px;padding:1.35rem 1.65rem}@media (prefers-color-scheme:dark){.course-intro{–shadow-color:199deg 63% 6%;border-color:var(–block-separator-color,#244654);background-color:var(–accent-box-color,#19313c)}}
This article is part of our ongoing series on UX. You might want to take a look at Smart Interface Design Patterns 🍣 and the upcoming live UX training as well. Use code BIRDIE to save 15% off.
A common way to address accessibility is to speak to stakeholders through the lens of corporate responsibility and ethical and legal implications. Personally, I’ve never been very successful with this strategy. People typically dismiss concerns that they can’t relate to, and as designers, we can’t build empathy with facts, charts, or legal concerns.
The problem is that people often don’t know how accessibility applies to them. There is a common assumption that accessibility is dull and boring and leads to “unexciting” and unattractive products. Unsurprisingly, businesses often neglect it as an irrelevant edge case.
So, I use another strategy. I start conversations about accessibility by visualizing it. I explain the different types of accessibility needs, ranging from permanent to temporary to situational — and I try to explain what exactly it actually means to our products. Mapping a more generic understanding of accessibility to the specifics of a product helps everyone explore accessibility from a point that they can relate to.
And then I launch a small effort — just a few usability sessions, to get a better understanding of where our customers struggle and where they might be blocked. If I can’t get access to customers, I try to proxy test via sales, customer success, or support. Nothing is more impactful than seeing real customers struggling in their real-life scenario with real products that a company is building.
From there, I move forward. I explain inclusive design, accessibility, neurodiversity, EAA, WCAG, ARIA. I bring people with disabilities into testing as we need a proper representation of our customer base. I ask for small commitments first, then ask for more. I reiterate over and over and over again that accessibility doesn’t have to be expensive or tedious if done early, but it can be very expensive when retrofitted or done late.
Throughout that entire journey, I try to anticipate objections about costs, timing, competition, slowdowns, dullness — and keep explaining how accessibility can reduce costs, increase revenue, grow user base, minimize risks, and improve our standing in new markets. For that, I use a few templates that I always keep nearby just in case an argument or doubts arise.
❌ “But accessibility is an edge case. Given the state of finances right now, unfortunately, we really can’t invest in it right now.”
🙅🏽♀️ “I respectfully disagree. 1 in 6 people around the world experience disabilities. In fact, our competitors [X, Y, Z] have launched accessibility efforts ([references]), and we seem to be lagging behind. Plus, it doesn’t have to be expensive. But it will be very expensive once we retrofit much later.”
❌ “We know that accessibility is important, but at the moment, we need to focus on efforts that will directly benefit business.”
🙅🏼♂️ “I understand what you are saying, but actually, accessibility directly benefits business. Globally, the extended market is estimated at 2.3 billion people, who control an incremental $6.9 trillion in annual disposable income. Prioritizing accessibility very much aligns with your goal to increase leads, customer engagement, mitigate risk, and reduce costs.” (via Yichan Wang)
❌ “Why should we prioritize accessibility? Looking at our data, we don’t really have any disabled users at all. Seems like a waste of time and resources.”
🙅♀️ “Well, if a product is inaccessible, users with disabilities can’t and won’t be using it. But if we do make our product more accessible, we open the door for prospect users for years to come. Even small improvements can have a high impact. It doesn’t have to be expensive nor time-consuming.”
❌ “Our application is very complex and used by expert users. Would it even work at all with screen readers?”
🙅🏻♀️ “It’s not about designing only for screen readers. Accessibility can be permanent, but it can also be temporary and situational — e.g., when you hold a baby in your arms or if you had an accident. Actually, it’s universally useful and beneficial for everyone.”
❌ “To increase our market share, we need features that benefit everyone and improve our standing against competition. We can’t win the market with accessibility.”
🙅🏾♂️ “Modern products succeed not by designing more features, but by designing better features that improve customer’s efficiency, success rate, and satisfaction. And accessibility is one of these features. For example, voice control and auto-complete were developed for accessibility but are now widely used by everyone. In fact, the entire customer base benefits from accessibility features.”
❌ “Our research clearly shows that our customers are young and healthy, and they don’t have accessibility needs. We have other priorities, and accessibility isn’t one of them.”
🙅♀️ “I respectfully disagree. People of all ages can have accessibility needs. In fact, accessibility features show your commitment to inclusivity, reaching out to every potential customer of any age, regardless of their abilities.
This not only resonates with a diverse audience but also positions your brand as socially responsible and empathetic. As you know, our young user base increasingly values corporate responsibility, and this can be a significant differentiator for us, helping to build a loyal customer base for years to come.” (via Yichan Wang)
❌ “At the moment, we need to focus on the core features of our product. We can always add accessibility later once the product is more stable.”
🙅🏼 “I understand concerns about timing and costs. However, it’s important to note that integrating accessibility from the start is far more cost-effective than retrofitting it later. If accessibility is considered after development is complete, we will face significant additional expenses for auditing accessibility, followed by potentially extensive work involving a redesign and redevelopment.
This process can be significantly more expensive than embedding accessibility from the beginning. Furthermore, delaying accessibility can expose your business to legal risks. With the increasing number of lawsuits for non-compliance with accessibility standards, the cost of legal repercussions could far exceed the expense of implementing accessibility now. The financially prudent move is to work on accessibility now.”
You can find more useful ready-to-use templates in Yichan Wang’s Designer’s Accessibility Advocacy Toolkit — a fantastic resource to keep nearby.
As mentioned above, nothing is more impactful than visualizing accessibility. However, it requires building accessibility research and accessibility practices from scratch, and it might feel like an impossible task, especially in large corporations. In “How We’ve Built Accessibility Research at Booking.com”, Maya Alvarado presents a fantastic case study on how to build accessibility practices and inclusive design into UX research from scratch.
Maya rightfully points out that automated accessibility testing alone isn’t reliable. Compliance means that a user can use your product, but it doesn’t mean that it’s a great user experience. With manual testing, we make sure that customers actually meet their goals and do so effectively.
Start by gathering colleagues and stakeholders interested in accessibility. Document what research was done already and where the gaps are. And then whenever possible, include 5–12 users with disabilities in accessibility testing.
Then, run a small accessibility initiative around key flows. Tap into critical touch points and research them. As you are making progress, extend to components, patterns, flows, and service design. And eventually, incorporate inclusive sampling into all research projects — at least 15% of usability testers should have a disability.
Companies often struggle to recruit testers with disabilities. One way to find participants is to reach out to local chapters, local training centers, non-profits, and public communities of users with disabilities in your country. Ask the admin’s permission to post your research announcement, and it won’t be rejected. If you test on site, add extra $25–$50 depending on disability transportation.
I absolutely love the idea of extending Microsoft’s Inclusive Design Toolkit to meet specific user needs of a product. It adds a different dimension to disability considerations which might be less abstract and much easier to relate for the entire organization.
As Maya noted, inclusive design is about building a door that can be opened by anyone and lets everyone in. Accessibility isn’t a checklist — it’s a practice that goes beyond compliance. A practice that involves actual people with actual disabilities throughout all UX research activities.
To many people, accessibility is a big mystery box. They might have never seen a customer with disabilities using their product, and they don’t really understand what it involves and requires. But we can make accessibility relatable, approachable, and visible by bringing accessibility testing to our companies — even if it’s just a handful of tests with people with disabilities.
No manager really wants to deliberately ignore the needs of their paying customers — they just need to understand these needs first. Ask for small commitments, and get the ball rolling from there.
Set up an accessibility roadmap with actions, timelines, roles and goals. Frankly, this strategy has been working for me much better than arguing about legal and moral obligations, which typically makes stakeholders defensive and reluctant to commit.
Fingers crossed! And a huge thank-you to everyone working on and improving accessibility in your day-to-day work, often without recognition and often fueled by your own enthusiasm and passion — thank you for your incredible work in pushing accessibility forward! 👏🏼👏🏽👏🏾
If you are interested in UX and design patterns, take a look at Smart Interface Design Patterns, our 10h-video course with 100s of practical examples from real-life projects — with a live UX training later this year. Everything from mega-dropdowns to complex enterprise tables — with 5 new segments added every year. Jump to a free preview. Use code BIRDIE to save 15% off.
100 design patterns & real-life
examples.
10h-video course + live UX training. Free preview.
Uniting Web And Native Apps With 4 Unknown JavaScript APIs Uniting Web And Native Apps With 4 Unknown JavaScript APIs Juan Diego Rodríguez 2024-06-20T18:00:00+00:00 2025-06-25T15:04:30+00:00 A couple of years ago, four JavaScript APIs that landed at the bottom of awareness in the State of JavaScript […]
Accessibility
2024-06-20T18:00:00+00:00
2025-06-25T15:04:30+00:00
A couple of years ago, four JavaScript APIs that landed at the bottom of awareness in the State of JavaScript survey. I took an interest in those APIs because they have so much potential to be useful but don’t get the credit they deserve. Even after a quick search, I was amazed at how many new web APIs have been added to the ECMAScript specification that aren’t getting their dues and with a lack of awareness and browser support in browsers.
That situation can be a “catch-22”:
An API is interesting but lacks awareness due to incomplete support, and there is no immediate need to support it due to low awareness.
“
Most of these APIs are designed to power progressive web apps (PWA) and close the gap between web and native apps. Bear in mind that creating a PWA involves more than just adding a manifest file. Sure, it’s a PWA by definition, but it functions like a bookmark on your home screen in practice. In reality, we need several APIs to achieve a fully native app experience on the web. And the four APIs I’d like to shed light on are part of that PWA puzzle that brings to the web what we once thought was only possible in native apps.
You can see all these APIs in action in this demo as we go along.
The Screen Orientation API can be used to sniff out the device’s current orientation. Once we know whether a user is browsing in a portrait or landscape orientation, we can use it to enhance the UX for mobile devices by changing the UI accordingly. We can also use it to lock the screen in a certain position, which is useful for displaying videos and other full-screen elements that benefit from a wider viewport.
Using the global screen
object, you can access various properties the screen uses to render a page, including the screen.orientation
object. It has two properties:
type
: The current screen orientation. It can be: "portrait-primary"
, "portrait-secondary"
, "landscape-primary"
, or "landscape-secondary"
.angle
: The current screen orientation angle. It can be any number from 0 to 360 degrees, but it’s normally set in multiples of 90 degrees (e.g., 0
, 90
, 180
, or 270
).On mobile devices, if the angle
is 0
degrees, the type
is most often going to evaluate to "portrait"
(vertical), but on desktop devices, it is typically "landscape"
(horizontal). This makes the type
property precise for knowing a device’s true position.
The screen.orientation
object also has two methods:
.lock()
: This is an async method that takes a type
value as an argument to lock the screen..unlock()
: This method unlocks the screen to its default orientation.And lastly, screen.orientation
counts with an "orientationchange"
event to know when the orientation has changed.
Let’s code a short demo using the Screen Orientation API to know the device’s orientation and lock it in its current position.
This can be our HTML boilerplate:
<main>
<p>
Orientation Type: <span class="orientation-type"></span>
<br />
Orientation Angle: <span class="orientation-angle"></span>
</p>
<button type="button" class="lock-button">Lock Screen</button>
<button type="button" class="unlock-button">Unlock Screen</button>
<button type="button" class="fullscreen-button">Go Full Screen</button>
</main>
On the JavaScript side, we inject the screen orientation type
and angle
properties into our HTML.
let currentOrientationType = document.querySelector(".orientation-type");
let currentOrientationAngle = document.querySelector(".orientation-angle");
currentOrientationType.textContent = screen.orientation.type;
currentOrientationAngle.textContent = screen.orientation.angle;
Now, we can see the device’s orientation and angle properties. On my laptop, they are "landscape-primary"
and 0°
.
If we listen to the window’s orientationchange
event, we can see how the values are updated each time the screen rotates.
window.addEventListener("orientationchange", () => {
currentOrientationType.textContent = screen.orientation.type;
currentOrientationAngle.textContent = screen.orientation.angle;
});
To lock the screen, we need to first be in full-screen mode, so we will use another extremely useful feature: the Fullscreen API. Nobody wants a webpage to pop into full-screen mode without their consent, so we need transient activation (i.e., a user click) from a DOM element to work.
The Fullscreen API has two methods:
Document.exitFullscreen()
is used from the global document object,Element.requestFullscreen()
makes the specified element and its descendants go full-screen.We want the entire page to be full-screen so we can invoke the method from the root element at the document.documentElement
object:
const fullscreenButton = document.querySelector(".fullscreen-button");
fullscreenButton.addEventListener("click", async () => {
// If it is already in full-screen, exit to normal view
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
});
Next, we can lock the screen in its current orientation:
const lockButton = document.querySelector(".lock-button");
lockButton.addEventListener("click", async () => {
try {
await screen.orientation.lock(screen.orientation.type);
} catch (error) {
console.error(error);
}
});
And do the opposite with the unlock button:
const unlockButton = document.querySelector(".unlock-button");
unlockButton.addEventListener("click", () => {
screen.orientation.unlock();
});
Yes! We can indeed check page orientation via the orientation
media feature in a CSS media query. However, media queries compute the current orientation by checking if the width is “bigger than the height” for landscape or “smaller” for portrait. By contrast,
The Screen Orientation API checks for the screen rendering the page regardless of the viewport dimensions, making it resistant to inconsistencies that may crop up with page resizing.
“
You may have noticed how PWAs like Instagram and X force the screen to be in portrait mode even when the native system orientation is unlocked. It is important to notice that this behavior isn’t achieved through the Screen Orientation API, but by setting the orientation
property on the manifest.json
file to the desired orientation type.
Another API I’d like to poke at is the Device Orientation API. It provides access to a device’s gyroscope sensors to read the device’s orientation in space; something used all the time in mobile apps, mainly games. The API makes this happen with a deviceorientation
event that triggers each time the device moves. It has the following properties:
event.alpha
: Orientation along the Z-axis, ranging from 0 to 360 degrees.event.beta
: Orientation along the X-axis, ranging from -180 to 180 degrees.event.gamma
: Orientation along the Y-axis, ranging from -90 to 90 degrees.
In this case, we will make a 3D cube with CSS that can be rotated with your device! The full instructions I used to make the initial CSS cube are credited to David DeSandro and can be found in his introduction to 3D transforms.
See the Pen [Rotate cube [forked]](https://codepen.io/smashingmag/pen/vYwdMNJ) by Dave DeSandro.
You can see raw full HTML in the demo, but let’s print it here for posterity:
<main>
<div class="scene">
<div class="cube">
<div class="cube__face cube__face--front">1</div>
<div class="cube__face cube__face--back">2</div>
<div class="cube__face cube__face--right">3</div>
<div class="cube__face cube__face--left">4</div>
<div class="cube__face cube__face--top">5</div>
<div class="cube__face cube__face--bottom">6</div>
</div>
</div>
<h1>Device Orientation API</h1>
<p>
Alpha: <span class="currentAlpha"></span>
<br />
Beta: <span class="currentBeta"></span>
<br />
Gamma: <span class="currentGamma"></span>
</p>
</main>
To keep this brief, I won’t explain the CSS code here. Just keep in mind that it provides the necessary styles for the 3D cube, and it can be rotated through all axes using the CSS rotate()
function.
Now, with JavaScript, we listen to the window’s deviceorientation
event and access the event orientation data:
const currentAlpha = document.querySelector(".currentAlpha");
const currentBeta = document.querySelector(".currentBeta");
const currentGamma = document.querySelector(".currentGamma");
window.addEventListener("deviceorientation", (event) => {
currentAlpha.textContent = event.alpha;
currentBeta.textContent = event.beta;
currentGamma.textContent = event.gamma;
});
To see how the data changes on a desktop device, we can open Chrome’s DevTools and access the Sensors Panel to emulate a rotating device.
To rotate the cube, we change its CSS transform
properties according to the device orientation data:
const currentAlpha = document.querySelector(".currentAlpha");
const currentBeta = document.querySelector(".currentBeta");
const currentGamma = document.querySelector(".currentGamma");
const cube = document.querySelector(".cube");
window.addEventListener("deviceorientation", (event) => {
currentAlpha.textContent = event.alpha;
currentBeta.textContent = event.beta;
currentGamma.textContent = event.gamma;
cube.style.transform = `rotateX(${event.beta}deg) rotateY(${event.gamma}deg) rotateZ(${event.alpha}deg)`;
});
This is the result:
Let’s turn our attention to the Vibration API, which, unsurprisingly, allows access to a device’s vibrating mechanism. This comes in handy when we need to alert users with in-app notifications, like when a process is finished or a message is received. That said, we have to use it sparingly; no one wants their phone blowing up with notifications.
There’s just one method that the Vibration API gives us, and it’s all we need: navigator.vibrate()
.
vibrate()
is available globally from the navigator
object and takes an argument for how long a vibration lasts in milliseconds. It can be either a number or an array of numbers representing a patron of vibrations and pauses.
navigator.vibrate(200); // vibrate 200ms
navigator.vibrate([200, 100, 200]); // vibrate 200ms, wait 100, and vibrate 200ms.
Let’s make a quick demo where the user inputs how many milliseconds they want their device to vibrate and buttons to start and stop the vibration, starting with the markup:
<main>
<form>
<label for="milliseconds-input">Milliseconds:</label>
<input type="number" id="milliseconds-input" value="0" />
</form>
<button class="vibrate-button">Vibrate</button>
<button class="stop-vibrate-button">Stop</button>
</main>
We’ll add an event listener for a click and invoke the vibrate()
method:
const vibrateButton = document.querySelector(".vibrate-button");
const millisecondsInput = document.querySelector("#milliseconds-input");
vibrateButton.addEventListener("click", () => {
navigator.vibrate(millisecondsInput.value);
});
To stop vibrating, we override the current vibration with a zero-millisecond vibration.
const stopVibrateButton = document.querySelector(".stop-vibrate-button");
stopVibrateButton.addEventListener("click", () => {
navigator.vibrate(0);
});
In the past, it used to be that only native apps could connect to a device’s “contacts”. But now we have the fourth and final API I want to look at: the Contact Picker API.
The API grants web apps access to the device’s contact lists. Specifically, we get the contacts.select()
async method available through the navigator
object, which takes the following two arguments:
properties
: This is an array containing the information we want to fetch from a contact card, e.g., "name"
, "address"
, "email"
, "tel"
, and "icon"
.options
: This is an object that can only contain the multiple
boolean property to define whether or not the user can select one or multiple contacts at a time.I’m afraid that browser support is next to zilch on this one, limited to Chrome Android, Samsung Internet, and Android’s native web browser at the time I’m writing this.
We will make another demo to select and display the user’s contacts on the page. Again, starting with the HTML:
<main>
<button class="get-contacts">Get Contacts</button>
<p>Contacts:</p>
<ul class="contact-list">
<!-- We’ll inject a list of contacts -->
</ul>
</main>
Then, in JavaScript, we first construct our elements from the DOM and choose which properties we want to pick from the contacts.
const getContactsButton = document.querySelector(".get-contacts");
const contactList = document.querySelector(".contact-list");
const props = ["name", "tel", "icon"];
const options = {multiple: true};
Now, we asynchronously pick the contacts when the user clicks the getContactsButton
.
const getContacts = async () => {
try {
const contacts = await navigator.contacts.select(props, options);
} catch (error) {
console.error(error);
}
};
getContactsButton.addEventListener("click", getContacts);
Using DOM manipulation, we can then append a list item to each contact and an icon to the contactList
element.
const appendContacts = (contacts) => {
contacts.forEach(({name, tel, icon}) => {
const contactElement = document.createElement("li");
contactElement.innerText = `${name}: ${tel}`;
contactList.appendChild(contactElement);
});
};
const getContacts = async () => {
try {
const contacts = await navigator.contacts.select(props, options);
appendContacts(contacts);
} catch (error) {
console.error(error);
}
};
getContactsButton.addEventListener("click", getContacts);
Appending an image is a little tricky since we will need to convert it into a URL and append it for each item in the list.
const getIcon = (icon) => {
if (icon.length > 0) {
const imageUrl = URL.createObjectURL(icon[0]);
const imageElement = document.createElement("img");
imageElement.src = imageUrl;
return imageElement;
}
};
const appendContacts = (contacts) => {
contacts.forEach(({name, tel, icon}) => {
const contactElement = document.createElement("li");
contactElement.innerText = `${name}: ${tel}`;
contactList.appendChild(contactElement);
const imageElement = getIcon(icon);
contactElement.appendChild(imageElement);
});
};
const getContacts = async () => {
try {
const contacts = await navigator.contacts.select(props, options);
appendContacts(contacts);
} catch (error) {
console.error(error);
}
};
getContactsButton.addEventListener("click", getContacts);
And here’s the outcome:
Note: The Contact Picker API will only work if the context is secure, i.e., the page is served over https://
or wss://
URLs.
There we go, four web APIs that I believe would empower us to build more useful and robust PWAs but have slipped under the radar for many of us. This is, of course, due to inconsistent browser support, so I hope this article can bring awareness to new APIs so we have a better chance to see them in future browser updates.
Aren’t they interesting? We saw how much control we have with the orientation of a device and its screen as well as the level of access we get to access a device’s hardware features, i.e. vibration, and information from other apps to use in our own UI.
But as I said much earlier, there’s a sort of infinite loop where a lack of awareness begets a lack of browser support. So, while the four APIs we covered are super interesting, your mileage will inevitably vary when it comes to using them in a production environment. Please tread cautiously and refer to Caniuse for the latest support information, or check for your own devices using WebAPI Check.
Scaling Success: Key Insights And Practical Takeaways Scaling Success: Key Insights And Practical Takeaways Addy Osmani 2024-06-04T12:00:00+00:00 2025-06-25T15:04:30+00:00 Building successful web products at scale is a multifaceted challenge that demands a combination of technical expertise, strategic decision-making, and a growth-oriented mindset. In Success at Scale, […]
Accessibility
2024-06-04T12:00:00+00:00
2025-06-25T15:04:30+00:00
Building successful web products at scale is a multifaceted challenge that demands a combination of technical expertise, strategic decision-making, and a growth-oriented mindset. In Success at Scale, I dive into case studies from some of the web’s most renowned products, uncovering the strategies and philosophies that propelled them to the forefront of their industries.
Here you will find some of the insights I’ve gleaned from these success stories, part of an ongoing effort to build a roadmap for teams striving to achieve scalable success in the ever-evolving digital landscape.
The foundation of scaling success lies in fostering the right mindset within your team. The case studies in Success at Scale highlight several critical mindsets that permeate the culture of successful organizations.
Successful teams prioritize the user experience above all else.
They invest in understanding their users’ needs, behaviors, and pain points and relentlessly strive to deliver value. Instagram’s performance optimization journey exemplifies this mindset, focusing on improving perceived speed and reducing user frustration, leading to significant gains in engagement and retention.
By placing the user at the center of every decision, Instagram was able to identify and prioritize the most impactful optimizations, such as preloading critical resources and leveraging adaptive loading strategies. This user-centric approach allowed them to deliver a seamless and delightful experience to their vast user base, even as their platform grew in complexity.
Scaling success relies on data, not assumptions.
Teams must embrace a data-driven approach, leveraging metrics and analytics to guide their decisions and measure impact. Shopify’s UI performance improvements showcase the power of data-driven optimization, using detailed profiling and user data to prioritize efforts and drive meaningful results.
By analyzing user interactions, identifying performance bottlenecks, and continuously monitoring key metrics, Shopify was able to make informed decisions that directly improved the user experience. This data-driven mindset allowed them to allocate resources effectively, focusing on the areas that yielded the greatest impact on performance and user satisfaction.
Scaling is an ongoing process, not a one-time achievement.
Successful teams foster a culture of continuous improvement, constantly seeking opportunities to optimize and refine their products. Smashing Magazine’s case study on enhancing Core Web Vitals demonstrates the impact of iterative enhancements, leading to significant performance gains and improved user satisfaction.
By regularly assessing their performance metrics, identifying areas for improvement, and implementing incremental optimizations, Smashing Magazine was able to continuously elevate the user experience. This mindset of continuous improvement ensures that the product remains fast, reliable, and responsive to user needs, even as it scales in complexity and user base.
Silos hinder scalability.
High-performing teams promote collaboration and inclusivity, ensuring that diverse perspectives are valued and leveraged. The Understood’s accessibility journey highlights the power of cross-functional collaboration, with designers, developers, and accessibility experts working together to create inclusive experiences for all users.
By fostering open communication, knowledge sharing, and a shared commitment to accessibility, The Understood was able to embed inclusive design practices throughout its development process. This collaborative and inclusive approach not only resulted in a more accessible product but also cultivated a culture of empathy and user-centricity that permeated all aspects of their work.
Beyond cultivating the right mindset, scaling success requires making strategic decisions that lay the foundation for sustainable growth.
Selecting the right technologies and frameworks can significantly impact scalability. Factors like performance, maintainability, and developer experience should be carefully considered. Notion’s migration to Next.js exemplifies the importance of choosing a technology stack that aligns with long-term scalability goals.
By adopting Next.js, Notion was able to leverage its performance optimizations, such as server-side rendering and efficient code splitting, to deliver fast and responsive pages. Additionally, the developer-friendly ecosystem of Next.js and its strong community support enabled Notion’s team to focus on building features and optimizing the user experience rather than grappling with low-level infrastructure concerns. This strategic technology choice laid the foundation for Notion’s scalable and maintainable architecture.
This best practice is so important when we want to ensure that pages load fast without over-eagerly delivering JavaScript a user may not need at that time. For example, Instagram made a concerted effort to improve the web performance of instagram.com, resulting in a nearly 50% cumulative improvement in feed page load time. A key area of focus has been shipping less JavaScript code to users, particularly on the critical rendering path.
The Instagram team found that the uncompressed size of JavaScript is more important for performance than the compressed size, as larger uncompressed bundles take more time to parse and execute on the client, especially on mobile devices. Two optimizations they implemented to reduce JS parse/execute time were inline requires (only executing code when it’s first used vs. eagerly on initial load) and serving ES2017+ code to modern browsers to avoid transpilation overhead. Inline requires improved Time-to-Interactive metrics by 12%, and the ES2017+ bundle was 5.7% smaller and 3% faster than the transpiled version.
While good progress has been made, the Instagram team acknowledges there are still many opportunities for further optimization. Potential areas to explore could include the following:
Continued efforts will be needed to keep instagram.com performing well as new features are added and the product grows in complexity.
Accessibility should be an integral part of the product development process, not an afterthought.
Wix’s comprehensive approach to accessibility, encompassing keyboard navigation, screen reader support, and infrastructure for future development, showcases the importance of building inclusivity into the product’s core.
By considering accessibility requirements from the initial design stages and involving accessibility experts throughout the development process, Wix was able to create a platform that empowered its users to build accessible websites. This holistic approach to accessibility not only benefited end-users but also positioned Wix as a leader in inclusive web design, attracting a wider user base and fostering a culture of empathy and inclusivity within the organization.
Investing in a positive developer experience is essential for attracting and retaining talent, fostering productivity, and accelerating development.
Apideck’s case study in the book highlights the impact of a great developer experience on community building and product velocity.
By providing well-documented APIs, intuitive SDKs, and comprehensive developer resources, Apideck was able to cultivate a thriving developer community. This investment in developer experience not only made it easier for developers to integrate with Apideck’s platform but also fostered a sense of collaboration and knowledge sharing within the community. As a result, ApiDeck was able to accelerate product development, leverage community contributions, and continuously improve its offering based on developer feedback.
Achieving optimal performance is a critical aspect of scaling success. The case studies in Success at Scale showcase various performance optimization techniques that have proven effective.
Building resilient web experiences that perform well across a range of devices and network conditions requires a progressive enhancement approach. Pinafore’s case study in Success at Scale highlights the benefits of ensuring core functionality remains accessible even in low-bandwidth or JavaScript-constrained environments.
By leveraging server-side rendering and delivering a usable experience even when JavaScript fails to load, Pinafore demonstrates the importance of progressive enhancement. This approach not only improves performance and resilience but also ensures that the application remains accessible to a wider range of users, including those with older devices or limited connectivity. By gracefully degrading functionality in constrained environments, Pinafore provides a reliable and inclusive experience for all users.
The book’s case study on Tinder highlights the power of sophisticated adaptive loading strategies. By dynamically adjusting the content and resources delivered based on the user’s device capabilities and network conditions, Tinder ensures a seamless experience across a wide range of devices and connectivity scenarios. Tinder’s adaptive loading approach involves techniques like dynamic code splitting, conditional resource loading, and real-time network quality detection. This allows the application to optimize the delivery of critical resources, prioritize essential content, and minimize the impact of poor network conditions on the user experience.
By adapting to the user’s context, Tinder delivers a fast and responsive experience, even in challenging environments.
Effective management of resources, such as images and third-party scripts, can significantly impact performance. eBay’s journey showcases the importance of optimizing image delivery, leveraging techniques like lazy loading and responsive images to reduce page weight and improve load times.
By implementing lazy loading, eBay ensures that images are only loaded when they are likely to be viewed by the user, reducing initial page load time and conserving bandwidth. Additionally, by serving appropriately sized images based on the user’s device and screen size, eBay minimizes the transfer of unnecessary data and improves the overall loading performance. These resource management optimizations, combined with other techniques like caching and CDN utilization, enable eBay to deliver a fast and efficient experience to its global user base.
Regularly monitoring and analyzing performance metrics is crucial for identifying bottlenecks and opportunities for optimization. The case study on Yahoo! Japan News demonstrates the impact of continuous performance monitoring, using tools like Lighthouse and real user monitoring to identify and address performance issues proactively.
By establishing a performance monitoring infrastructure, Yahoo! Japan News gains visibility into the real-world performance experienced by their users. This data-driven approach allows them to identify performance regression, pinpoint specific areas for improvement, and measure the impact of their optimizations. Continuous monitoring also enables Yahoo! Japan News to set performance baselines, track progress over time, and ensure that performance remains a top priority as the application evolves.
Creating inclusive web experiences that cater to diverse user needs is not only an ethical imperative but also a critical factor in scaling success. The case studies in Success at Scale emphasize the importance of accessibility and inclusive design.
Ensuring accessibility requires a combination of automated testing tools and manual evaluation. LinkedIn’s approach to automated accessibility testing demonstrates the value of integrating accessibility checks into the development workflow, catching potential issues early, and reducing the reliance on manual testing alone.
By leveraging tools like Deque’s axe and integrating accessibility tests into their continuous integration pipeline, LinkedIn can identify and address accessibility issues before they reach production. This proactive approach to accessibility testing not only improves the overall accessibility of the platform but also reduces the cost and effort associated with retroactive fixes. However, LinkedIn also recognizes the importance of manual testing and user feedback in uncovering complex accessibility issues that automated tools may miss. By combining automated checks with manual evaluation, LinkedIn ensures a comprehensive approach to accessibility testing.
Designing with accessibility in mind from the outset leads to more inclusive and usable products. Success With Scale’s case study on Intercom about creating an accessible messenger highlights the importance of considering diverse user needs, such as keyboard navigation and screen reader compatibility, throughout the design process.
By embracing inclusive design principles, Intercom ensures that their messenger is usable by a wide range of users, including those with visual, motor, or cognitive impairments. This involves considering factors such as color contrast, font legibility, focus management, and clear labeling of interactive elements. By designing with empathy and understanding the diverse needs of their users, Intercom creates a messenger experience that is intuitive, accessible, and inclusive. This approach not only benefits users with disabilities but also leads to a more user-friendly and resilient product overall.
Engaging with users with disabilities and incorporating their feedback is essential for creating truly inclusive experiences. The Understood’s journey emphasizes the value of user research and collaboration with accessibility experts to identify and address accessibility barriers effectively.
By conducting usability studies with users who have diverse abilities and working closely with accessibility consultants, The Understood gains invaluable insights into the real-world challenges faced by their users. This user-centered approach allows them to identify pain points, gather feedback on proposed solutions, and iteratively improve the accessibility of their platform.
By involving users with disabilities throughout the design and development process, The Understood ensures that their products not only meet accessibility standards but also provide a meaningful and inclusive experience for all users.
Promoting accessibility as a shared responsibility across the organization fosters a culture of inclusivity. Shopify’s case study underscores the importance of educating and empowering teams to prioritize accessibility, recognizing it as a fundamental aspect of the user experience rather than a mere technical checkbox.
By providing accessibility training, guidelines, and resources to designers, developers, and content creators, Shopify ensures that accessibility is considered at every stage of the product development lifecycle. This shared responsibility approach helps to build accessibility into the core of Shopify’s products and fosters a culture of inclusivity and empathy. By making accessibility everyone’s responsibility, Shopify not only improves the usability of their platform but also sets an example for the wider industry on the importance of inclusive design.
Scaling success requires a culture that promotes collaboration, knowledge sharing, and continuous learning. The case studies in Success at Scale highlight the impact of effective collaboration and knowledge management practices.
Breaking down silos and fostering cross-functional collaboration accelerates problem-solving and innovation. Airbnb’s design system journey showcases the power of collaboration between design and engineering teams, leading to a cohesive and scalable design language across web and mobile platforms.
By establishing a shared language and a set of reusable components, Airbnb’s design system enables designers and developers to work together more efficiently. Regular collaboration sessions, such as design critiques and code reviews, help to align both teams and ensure that the design system evolves in a way that meets the needs of all stakeholders. This cross-functional approach not only improves the consistency and quality of the user experience but also accelerates the development process by reducing duplication of effort and promoting code reuse.
Capturing and sharing knowledge across the organization is crucial for maintaining consistency and enabling the efficient onboarding of new team members. Stripe’s investment in internal frameworks and documentation exemplifies the value of creating a shared understanding and facilitating knowledge transfer.
By maintaining comprehensive documentation, code examples, and best practices, Stripe ensures that developers can quickly grasp the intricacies of their internal tools and frameworks. This documentation-driven culture not only reduces the learning curve for new hires but also promotes consistency and adherence to established patterns and practices. Regular knowledge-sharing sessions, such as tech talks and lunch-and-learns, further reinforce this culture of learning and collaboration, enabling team members to learn from each other’s experiences and stay up-to-date with the latest developments.
Establishing communities of practice around specific domains, such as accessibility or performance, promotes knowledge sharing and continuous improvement. Shopify’s accessibility guild demonstrates the impact of creating a dedicated space for experts and advocates to collaborate, share best practices, and drive accessibility initiatives forward.
By bringing together individuals passionate about accessibility from across the organization, Shopify’s accessibility guild fosters a sense of community and collective ownership. Regular meetings, workshops, and hackathons provide opportunities for members to share their knowledge, discuss challenges, and collaborate on solutions. This community-driven approach not only accelerates the adoption of accessibility best practices but also helps to build a culture of inclusivity and empathy throughout the organization.
Collaborating with the wider developer community and leveraging open-source solutions can accelerate development and provide valuable insights. Pinafore’s journey highlights the benefits of engaging with accessibility experts and incorporating their feedback to create a more inclusive and accessible web experience.
By actively seeking input from the accessibility community and leveraging open-source accessibility tools and libraries, Pinafore was able to identify and address accessibility issues more effectively. This collaborative approach not only improved the accessibility of the application but also contributed back to the wider community by sharing their learnings and experiences. By embracing open-source collaboration and learning from external experts, teams can accelerate their own accessibility efforts and contribute to the collective knowledge of the industry.
Achieving scalable success in the web development landscape requires a multifaceted approach that encompasses the right mindset, strategic decision-making, and continuous learning. The Success at Scale book provides a comprehensive exploration of these elements, offering deep insights and practical guidance for teams at all stages of their scaling journey.
By cultivating a user-centric, data-driven, and inclusive mindset, teams can prioritize the needs of their users and make informed decisions that drive meaningful results. Adopting a culture of continuous improvement and collaboration ensures that teams are always striving to optimize and refine their products, leveraging the collective knowledge and expertise of their members.
Making strategic technology choices, such as selecting performance-oriented frameworks and investing in developer experience, lays the foundation for scalable and maintainable architectures. Implementing performance optimization techniques, such as adaptive loading, efficient resource management, and continuous monitoring, helps teams deliver fast and responsive experiences to their users.
Embracing accessibility and inclusive design practices not only ensures that products are usable by a wide range of users but also fosters a culture of empathy and user-centricity. By incorporating accessibility testing, inclusive design principles, and user feedback into the development process, teams can create products that are both technically sound and meaningfully inclusive.
Fostering a culture of collaboration, knowledge sharing, and continuous learning is essential for scaling success. By breaking down silos, promoting cross-functional collaboration, and investing in documentation and communities of practice, teams can accelerate problem-solving, drive innovation, and build a shared understanding of their products and practices.
The case studies featured in Success at Scale serve as powerful examples of how these principles and strategies can be applied in real-world contexts. By learning from the successes and challenges of industry leaders, teams can gain valuable insights and inspiration for their own scaling journeys.
As you embark on your path to scaling success, remember that it is an ongoing process of iteration, learning, and adaptation. Embrace the mindsets and strategies outlined in this article, dive deeper into the learnings from the Success at Scale book, and continually refine your approach based on the unique needs of your users and the evolving landscape of web development.
Scaling successful web products requires a holistic approach that combines technical excellence, strategic decision-making, and a growth-oriented mindset. By learning from the experiences of industry leaders, as showcased in the Success at Scale book, teams can gain valuable insights and practical guidance on their journey towards sustainable success.
Cultivating a user-centric, data-driven, and inclusive mindset lays the foundation for scalability. By prioritizing the needs of users, making informed decisions based on data, and fostering a culture of continuous improvement and collaboration, teams can create products that deliver meaningful value and drive long-term growth.
Making strategic decisions around technology choices, performance optimization, accessibility integration, and developer experience investment sets the stage for scalable and maintainable architectures. By leveraging proven optimization techniques, embracing inclusive design practices, and investing in the tools and processes that empower developers, teams can build products that are fast and resilient.
Through ongoing collaboration, knowledge sharing, and a commitment to learning, teams can navigate the complexities of scaling success and create products that make a lasting impact in the digital landscape.
{
“sku”: “success-at-scale”,
“type”: “Book”,
“price”: “44.00”,
“prices”: [{
“amount”: “44.00”,
“currency”: “USD”,
“items”: [
{“amount”: “34.00”, “type”: “Book”},
{“amount”: “10.00”, “type”: “E-Book”}
]
}, {
“amount”: “44.00”,
“currency”: “EUR”,
“items”: [
{“amount”: “34.00”, “type”: “Book”},
{“amount”: “10.00”, “type”: “E-Book”}
]
}
]
}
$
44.00
Quality hardcover. Free worldwide shipping.
100 days money-back-guarantee.
{
“sku”: “success-at-scale-ebook”,
“type”: “E-Book”,
“price”: “19.00”,
“prices”: [{
“amount”: “19.00”,
“currency”: “USD”
}, {
“amount”: “19.00”,
“currency”: “EUR”
}
]
}
$
19.00
Free!
DRM-free, of course. ePUB, Kindle, PDF.
Included with your Smashing Membership.
Download PDF, ePUB, Kindle.
Thanks for being smashing! ❤️
In an effort to conserve resources here at Smashing, we’re trying something new with Success at Scale. The printed book is 304 pages, and we make an expanded PDF version available to everyone who purchases a print book. This accomplishes a few good things:
Smashing Books have always been printed with materials from FSC Certified forests. We are committed to finding new ways to conserve resources while still bringing you the best possible reading experience.
Producing a book takes quite a bit of time, and we couldn’t pull it off without the support of our wonderful community. A huge shout-out to Smashing Members for the kind, ongoing support. The eBook is and always will be free for Smashing Members. Plus, Members get a friendly discount when purchasing their printed copy. Just sayin’! 😉
Promoting best practices and providing you with practical tips to master your daily coding and design challenges has always been (and will be) at the core of everything we do at Smashing.
In the past few years, we were very lucky to have worked together with some talented, caring people from the web community to publish their wealth of experience as printed books that stand the test of time. Heather and Steven are two of these people. Have you checked out their books already?
Everything you need to know to put your users first and make a better web.
Learn how touchscreen devices really work — and how people really use them.
100 practical cards for common interface design challenges.
The Era Of Platform Primitives Is Finally Here The Era Of Platform Primitives Is Finally Here Atila Fassina 2024-05-28T12:00:00+00:00 2025-06-25T15:04:30+00:00 This article is sponsored by Netlify In the past, the web ecosystem moved at a very slow pace. Developers would go years without a new […]
Accessibility
2024-05-28T12:00:00+00:00
2025-06-25T15:04:30+00:00
This article is sponsored by Netlify
In the past, the web ecosystem moved at a very slow pace. Developers would go years without a new language feature or working around a weird browser quirk. This pushed our technical leaders to come up with creative solutions to circumvent the platform’s shortcomings. We invented bundling, polyfills, and transformation steps to make things work everywhere with less of a hassle.
Slowly, we moved towards some sort of consensus on what we need as an ecosystem. We now have TypeScript and Vite as clear preferences—pushing the needle of what it means to build consistent experiences for the web. Application frameworks have built whole ecosystems on top of them: SolidStart, Nuxt, Remix, and Analog are examples of incredible tools built with such primitives. We can say that Vite and TypeScript are tooling primitives that empower the creation of others in diverse ecosystems.
With bundling and transformation needs somewhat defined, it was only natural that framework authors would move their gaze to the next layer they needed to abstract: the server.
The UnJS folks have been consistently building agnostic tooling that can be reused in different ecosystems. Thanks to them, we now have frameworks and libraries such as H3 (a minimal Node.js server framework built with TypeScript), which enables Nitro (a whole server runtime powered by Vite, and H3), that in its own turn enabled Vinxi (an application bundler and server runtime that abstracts Nitro and Vite).
Nitro is used already by three major frameworks: Nuxt, Analog, and SolidStart. While Vinxi is also used by SolidStart. This means that any platform which supports one of these, will definitely be able to support the others with zero additional effort.
This is not about taking a bigger slice of the cake. But making the cake bigger for everyone.
Frameworks, platforms, developers, and users benefit from it. We bet on our ecosystem together instead of working in silos with our monolithic solutions. Empowering our developer-users to gain transferable skills and truly choose the best tool for the job with less vendor lock-in than ever before.
Such initiatives have probably been noticed by serverless platforms like Netlify. With Platform Primitives, frameworks can leverage agnostic solutions for common necessities such as Incremental Static Regeneration (ISR), Image Optimization, and key/value (kv
) storage.
As the name implies, Netlify Platform Primitives are a group of abstractions and helpers made available at a platform level for either frameworks or developers to leverage when using their applications. This brings additional functionality simultaneously to every framework. This is a big and powerful shift because, up until now, each framework would have to create its own solutions and backport such strategies to compatibility layers within each platform.
Moreover, developers would have to wait for a feature to first land on a framework and subsequently for support to arrive in their platform of choice. Now, as long as they’re using Netlify, those primitives are available directly without any effort and time put in by the framework authors. This empowers every ecosystem in a single measure.
Serverless means server infrastructure developers don’t need to handle. It’s not a misnomer, but a format of Infrastructure As A Service.
As mentioned before, Netlify Platform Primitives are three different features:
Let’s take a quick dive into each of these features and explore how they can increase our productivity with a serverless fullstack experience.
Every image in a /public
can be served through a Netlify function. This means it’s possible to access it through a /.netlify/images
path. So, without adding sharp or any image optimization package to your stack, deploying to Netlify allows us to serve our users with a better format without transforming assets at build-time. In a SolidStart, in a few lines of code, we could have an Image component that transforms other formats to .webp
.
import { type JSX } from "solid-js";
const SITE_URL = "https://example.com";
interface Props extends JSX.ImgHTMLAttributes<HTMLImageElement> {
format?: "webp" | "jpeg" | "png" | "avif" | "preserve";
quality?: number | "preserve";
}
const getQuality = (quality: Props["quality"]) => {
if (quality === "preserve") return"";
return `&q=${quality || "75"}`;
};
function getFormat(format: Props["format"]) {
switch (format) {
case "preserve":
return" ";
case "jpeg":
return `&fm=jpeg`;
case "png":
return `&fm=png`;
case "avif":
return `&fm=avif`;
case "webp":
default:
return `&fm=webp`;
}
}
export function Image(props: Props) {
return (
<img
{...props}
src={`${SITE_URL}/.netlify/images?url=/${props.src}${getFormat(
props.format
)}${getQuality(props.quality)}`}
/>
);
}
Notice the above component is even slightly more complex than bare essentials because we’re enforcing some default optimizations. Our getFormat
method transforms images to .webp
by default. It’s a broadly supported format that’s significantly smaller than the most common and without any loss in quality. Our get quality
function reduces the image quality to 75% by default; as a rule of thumb, there isn’t any perceivable loss in quality for large images while still providing a significant size optimization.
By default, Netlify caching is quite extensive for your regular artifacts – unless there’s a new deployment or the cache is flushed manually, resources will last for 365 days. However, because server/edge functions are dynamic in nature, there’s no default caching to prevent serving stale content to end-users. This means that if you have one of these functions in production, chances are there’s some caching to be leveraged to reduce processing time (and expenses).
By adding a cache-control header, you already have done 80% of the work in optimizing your resources for best serving users. Some commonly used cache control directives:
{
"cache-control": "public, max-age=0, stale-while-revalidate=86400"
}
public
: Store in a shared cache.max-age=0
: resource is immediately stale.stale-while-revalidate=86400
: if the cache is stale for less than 1 day, return the cached value and revalidate it in the background.{
"cache-control": "public, max-age=86400, must-revalidate"
}
public
: Store in a shared cache.max-age=86400
: resource is fresh for one day.must-revalidate
: if a request arrives when the resource is already stale, the cache must be revalidated before a response is sent to the user.Note: For more extensive information about possible compositions of Cache-Control
directives, check the mdn entry on Cache-Control.
The cache is a type of key/value storage. So, once our responses are set with proper cache control, platforms have some heuristics to define what the key
will be for our resource within the cache storage. The Web Platform has a second very powerful header that can dictate how our cache behaves.
The Vary response header is composed of a list of headers that will affect the validity of the resource (method
and the endpoint URL are always considered; no need to add them). This header allows platforms to define other headers defined by location, language, and other patterns that will define for how long a response can be considered fresh.
The Vary response header is a foundational piece of a special header in Netlify Caching Primitive. The Netlify-Vary
will take a set of instructions on which parts of the request a key should be based. It is possible to tune a response key not only by the header but also by the value of the header.
Accept-Language
header.This header offers strong fine-control over how your resources are cached. Allowing for some creative strategies to optimize how your app will perform for specific users.
This is a highly-available key/value store, it’s ideal for frequent reads and infrequent writes. They’re automatically available and provisioned for any Netlify Project.
It’s possible to write on a blob from your runtime or push data for a deployment-specific store. For example, this is how an Action Function would register a number of likes in store with SolidStart.
import { getStore } from "@netlify/blobs";
import { action } from "@solidjs/router";
export const upVote = action(async (formData: FormData) => {
"use server";
const postId = formData.get("id");
const postVotes = formData.get("votes");
if (typeof postId !== "string" || typeof postVotes !== "string") return;
const store = getStore("posts");
const voteSum = Number(postVotes) + 1)
await store.set(postId, String(voteSum);
console.log("done");
return voteSum
});
@netlify/blobs
API documentation for more examples and use-cases.With high-quality primitives, we can enable library and framework creators to create thin integration layers and adapters. This way, instead of focusing on how any specific platform operates, it will be possible to focus on the actual user experience and practical use-cases for such features. Monoliths and deeply integrated tooling make sense to build platforms fast with strong vendor lock-in, but that’s not what the community needs. Betting on the web platform is a more sensible and future-friendly way.
Let me know in the comments what your take is about unbiased tooling versus opinionated setups!
The Forensics Of React Server Components (RSCs) The Forensics Of React Server Components (RSCs) Lazar Nikolov 2024-05-09T13:00:00+00:00 2025-06-25T15:04:30+00:00 This article is sponsored by Sentry.io In this article, we’re going to look deeply at React Server Components (RSCs). They are the latest innovation in React’s ecosystem, […]
Accessibility
2024-05-09T13:00:00+00:00
2025-06-25T15:04:30+00:00
This article is sponsored by Sentry.io
In this article, we’re going to look deeply at React Server Components (RSCs). They are the latest innovation in React’s ecosystem, leveraging both server-side and client-side rendering as well as streaming HTML to deliver content as fast as possible.
We will get really nerdy to get a full understanding of how RSCs fit into the React picture, the level of control they offer over the rendering lifecycle of components, and what page loads look like with RSCs in place.
But before we dive into all of that, I think it’s worth looking back at how React has rendered websites up until this point to set the context for why we need RSCs in the first place.
The first React apps were rendered on the client side, i.e., in the browser. As developers, we wrote apps with JavaScript classes as components and packaged everything up using bundlers, like Webpack, in a nicely compiled and tree-shaken heap of code ready to ship in a production environment.
The HTML that returned from the server contained a few things, including:
<head>
and a blank <div>
in the <body>
used as a hook to inject the app into the DOM;<div>
.
A web app under this process is only fully interactive once JavaScript has fully completed its operations. You can probably already see the tension here that comes with an improved developer experience (DX) that negatively impacts the user experience (UX).
The truth is that there were (and are) pros and cons to CSR in React. Looking at the positives, web applications delivered smooth, quick transitions that reduced the overall time it took to load a page, thanks to reactive components that update with user interactions without triggering page refreshes. CSR lightens the server load and allows us to serve assets from speedy content delivery networks (CDNs) capable of delivering content to users from a server location geographically closer to the user for even more optimized page loads.
There are also not-so-great consequences that come with CSR, most notably perhaps that components could fetch data independently, leading to waterfall network requests that dramatically slow things down. This may sound like a minor nuisance on the UX side of things, but the damage can actually be quite large on a human level. Eric Bailey’s “Modern Health, frameworks, performance, and harm” should be a cautionary tale for all CSR work.
Other negative CSR consequences are not quite as severe but still lead to damage. For example, it used to be that an HTML document containing nothing but metadata and an empty <div>
was illegible to search engine crawlers that never get the fully-rendered experience. While that’s solved today, the SEO hit at the time was an anchor on company sites that rely on search engine traffic to generate revenue.
Something needed to change. CSR presented developers with a powerful new approach for constructing speedy, interactive interfaces, but users everywhere were inundated with blank screens and loading indicators to get there. The solution was to move the rendering experience from the client to the server. I know it sounds funny that we needed to improve something by going back to the way it was before.
So, yes, React gained server-side rendering (SSR) capabilities. At one point, SSR was such a topic in the React community that it had a moment in the spotlight. The move to SSR brought significant changes to app development, specifically in how it influenced React behavior and how content could be delivered by way of servers instead of browsers.
Instead of sending a blank HTML document with SSR, we rendered the initial HTML on the server and sent it to the browser. The browser was able to immediately start displaying the content without needing to show a loading indicator. This significantly improves the First Contentful Paint (FCP) performance metric in Web Vitals.
Server-side rendering also fixed the SEO issues that came with CSR. Since the crawlers received the content of our websites directly, they were then able to index it right away. The data fetching that happens initially also takes place on the server, which is a plus because it’s closer to the data source and can eliminate fetch waterfalls if done properly.
SSR has its own complexities. For React to make the static HTML received from the server interactive, it needs to hydrate it. Hydration is the process that happens when React reconstructs its Virtual Document Object Model (DOM) on the client side based on what was in the DOM of the initial HTML.
Note: React maintains its own Virtual DOM because it’s faster to figure out updates on it instead of the actual DOM. It synchronizes the actual DOM with the Virtual DOM when it needs to update the UI but performs the diffing algorithm on the Virtual DOM.
We now have two flavors of Reacts:
We’re still shipping React and code for the app to the browser because — in order to hydrate the initial HTML — React needs the same components on the client side that were used on the server. During hydration, React performs a process called reconciliation in which it compares the server-rendered DOM with the client-rendered DOM and tries to identify differences between the two. If there are differences between the two DOMs, React attempts to fix them by rehydrating the component tree and updating the component hierarchy to match the server-rendered structure. And if there are still inconsistencies that cannot be resolved, React will throw errors to indicate the problem. This problem is commonly known as a hydration error.
SSR is not a silver bullet solution that addresses CSR limitations. SSR comes with its own drawbacks. Since we moved the initial HTML rendering and data fetching to the server, those servers are now experiencing a much greater load than when we loaded everything on the client.
Remember when I mentioned that SSR generally improves the FCP performance metric? That may be true, but the Time to First Byte (TTFB) performance metric took a negative hit with SSR. The browser literally has to wait for the server to fetch the data it needs, generate the initial HTML, and send the first byte. And while TTFB is not a Core Web Vital metric in itself, it influences the metrics. A negative TTFB leads to negative Core Web Vitals metrics.
Another drawback of SSR is that the entire page is unresponsive until client-side React has finished hydrating it. Interactive elements cannot listen and “react” to user interactions before React hydrates them, i.e., React attaches the intended event listeners to them. The hydration process is typically fast, but the internet connection and hardware capabilities of the device in use can slow down rendering by a noticeable amount.
So far, we have covered two different flavors of React rendering: CSR and SSR. While the two were attempts to improve one another, we now get the best of both worlds, so to speak, as SSR has branched into three additional React flavors that offer a hybrid approach in hopes of reducing the limitations that come with CSR and SSR.
We’ll look at the first two — static site generation and incremental static regeneration — before jumping into an entire discussion on React Server Components, the third flavor.
Instead of regenerating the same HTML code on every request, we came up with SSG. This React flavor compiles and builds the entire app at build time, generating static (as in vanilla HTML and CSS) files that are, in turn, hosted on a speedy CDN.
As you might suspect, this hybrid approach to rendering is a nice fit for smaller projects where the content doesn’t change much, like a marketing site or a personal blog, as opposed to larger projects where content may change with user interactions, like an e-commerce site.
SSG reduces the burden on the server while improving performance metrics related to TTFB because the server no longer has to perform heavy, expensive tasks for re-rendering the page.
One SSG drawback is having to rebuild all of the app’s code when a content change is needed. The content is set in stone — being static and all — and there’s no way to change just one part of it without rebuilding the whole thing.
The Next.js team created the second hybrid flavor of React that addresses the drawback of complete SSG rebuilds: incremental static regeneration (ISR). The name says a lot about the approach in that ISR only rebuilds what’s needed instead of the entire thing. We generate the “initial version” of the page statically during build time but are also able to rebuild any page containing stale data after a user lands on it (i.e., the server request triggers the data check).
From that point on, the server will serve new versions of that page statically in increments when needed. That makes ISR a hybrid approach that is neatly positioned between SSG and traditional SSR.
At the same time, ISR does not address the “stale content” symptom, where users may visit a page before it has finished being generated. Unlike SSG, ISR needs an actual server to regenerate individual pages in response to a user’s browser making a server request. That means we lose the valuable ability to deploy ISR-based apps on a CDN for optimized asset delivery.
Up until this point, we’ve juggled between CSR, SSR, SSG, and ISR approaches, where all make some sort of trade-off, negatively affecting performance, development complexity, and user experience. Newly introduced React Server Components (RSC) aim to address most of these drawbacks by allowing us — the developer — to choose the right rendering strategy for each individual React component.
RSCs can significantly reduce the amount of JavaScript shipped to the client since we can selectively decide which ones to serve statically on the server and which render on the client side. There’s a lot more control and flexibility for striking the right balance for your particular project.
Note: It’s important to keep in mind that as we adopt more advanced architectures, like RSCs, monitoring solutions become invaluable. Sentry offers robust performance monitoring and error-tracking capabilities that help you keep an eye on the real-world performance of your RSC-powered application. Sentry also helps you gain insights into how your releases are performing and how stable they are, which is yet another crucial feature to have while migrating your existing applications to RSCs. Implementing Sentry in an RSC-enabled framework like Next.js is as easy as running a single terminal command.
But what exactly is an RSC? Let’s pick one apart to see how it works under the hood.
This new approach introduces two types of rendering components: Server Components and Client Components. The differences between these two are not how they function but where they execute and the environments they’re designed for. At the time of this writing, the only way to use RSCs is through React frameworks. And at the moment, there are only three frameworks that support them: Next.js, Gatsby, and RedwoodJS.
Server Components are designed to be executed on the server, and their code is never shipped to the browser. The HTML output and any props they might be accepting are the only pieces that are served. This approach has multiple performance benefits and user experience enhancements:
This architecture also makes use of HTML streaming, which means the server defers generating HTML for specific components and instead renders a fallback element in their place while it works on sending back the generated HTML. Streaming Server Components wrap components in <Suspense>
tags that provide a fallback value. The implementing framework uses the fallback initially but streams the newly generated content when it‘s ready. We’ll talk more about streaming, but let’s first look at Client Components and compare them to Server Components.
Client Components are the components we already know and love. They’re executed on the client side. Because of this, Client Components are capable of handling user interactions and have access to the browser APIs like localStorage
and geolocation.
The term “Client Component” doesn’t describe anything new; they merely are given the label to help distinguish the “old” CSR components from Server Components. Client Components are defined by a "use client"
directive at the top of their files.
"use client"
export default function LikeButton() {
const likePost = () => {
// ...
}
return (
<button onClick={likePost}>Like</button>
)
}
In Next.js, all components are Server Components by default. That’s why we need to explicitly define our Client Components with "use client"
. There’s also a "use server"
directive, but it’s used for Server Actions (which are RPC-like actions that invoked from the client, but executed on the server). You don’t use it to define your Server Components.
You might (rightfully) assume that Client Components are only rendered on the client, but Next.js renders Client Components on the server to generate the initial HTML. As a result, browsers can immediately start rendering them and then perform hydration later.
Client Components can only explicitly import other Client Components. In other words, we’re unable to import a Server Component into a Client Component because of re-rendering issues. But we can have Server Components in a Client Component’s subtree — only passed through the children
prop. Since Client Components live in the browser and they handle user interactions or define their own state, they get to re-render often. When a Client Component re-renders, so will its subtree. But if its subtree contains Server Components, how would they re-render? They don’t live on the client side. That’s why the React team put that limitation in place.
But hold on! We actually can import Server Components into Client Components. It’s just not a direct one-to-one relationship because the Server Component will be converted into a Client Component. If you’re using server APIs that you can’t use in the browser, you’ll get an error; if not — you’ll have a Server Component whose code gets “leaked” to the browser.
This is an incredibly important nuance to keep in mind as you work with RSCs.
Here’s the order of operations that Next.js takes to stream contents:
<Suspense>
.We will look at the RSC rendering lifecycle from the browser’s perspective momentarily. For now, the following figure illustrates the outlined steps we covered.
We’ll see this operation flow from the browser’s perspective in just a bit.
The RSC payload is a special data format that the server generates as it renders the component tree, and it includes the following:
There’s no reason to worry much about the RSC payload, but it’s worth understanding what exactly the RSC payload contains. Let’s examine an example (truncated for brevity) from a demo app I created:
1:HL["/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
2:HL["/_next/static/css/app/layout.css?v=1711137019097","style"]
0:"$L3"
4:HL["/_next/static/css/app/page.css?v=1711137019097","style"]
5:I["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
8:"$Sreact.suspense"
a:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
b:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
d:I["(app-pages-browser)/./src/app/global-error.jsx",["app/global-error","static/chunks/app/global-error.js"],""]
f:I["(app-pages-browser)/./src/components/clearCart.js",["app/page","static/chunks/app/page.js"],"ClearCart"]
7:["$","main",null,{"className":"page_main__GlU4n","children":[["$","$Lf",null,{}],["$","$8",null,{"fallback":["$","p",null,{"children":"🌀 loading products..."}],"children":"$L10"}]]}]
c:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}]...
9:["$","p",null,{"children":["🛍️ ",3]}]
11:I["(app-pages-browser)/./src/components/addToCart.js",["app/page","static/chunks/app/page.js"],"AddToCart"]
10:["$","ul",null,{"children":[["$","li","1",{"children":["Gloves"," - $",20,["$...
To find this code in the demo app, open your browser’s developer tools at the Elements tab and look at the <script>
tags at the bottom of the page. They’ll contain lines like:
self.__next_f.push([1,"PAYLOAD_STRING_HERE"]).
Every line from the snippet above is an individual RSC payload. You can see that each line starts with a number or a letter, followed by a colon, and then an array that’s sometimes prefixed with letters. We won’t get into too deep in detail as to what they mean, but in general:
HL
payloads are called “hints” and link to specific resources like CSS and fonts.I
payloads are called “modules,” and they invoke specific scripts. This is how Client Components are being loaded as well. If the Client Component is part of the main bundle, it’ll execute. If it’s not (meaning it’s lazy-loaded), a fetcher script is added to the main bundle that fetches the component’s CSS and JavaScript files when it needs to be rendered. There’s going to be an I
payload sent from the server that invokes the fetcher script when needed."$"
payloads are DOM definitions generated for a certain Server Component. They are usually accompanied by actual static HTML streamed from the server. That’s what happens when a suspended component becomes ready to be rendered: the server generates its static HTML and RSC Payload and then streams both to the browser.Streaming allows us to progressively render the UI from the server. With RSCs, each component is capable of fetching its own data. Some components are fully static and ready to be sent immediately to the client, while others require more work before loading. Based on this, Next.js splits that work into multiple chunks and streams them to the browser as they become ready. So, when a user visits a page, the server invokes all Server Components, generates the initial HTML for the page (i.e., the page shell), replaces the “suspended” components’ contents with their fallbacks, and streams all of that through one or multiple chunks back to the client.
The server returns a Transfer-Encoding: chunked
header that lets the browser know to expect streaming HTML. This prepares the browser for receiving multiple chunks of the document, rendering them as it receives them. We can actually see the header when opening Developer Tools at the Network tab. Trigger a refresh and click on the document request.
We can also debug the way Next.js sends the chunks in a terminal with the curl
command:
curl -D - --raw localhost:3000 > chunked-response.txt
You probably see the pattern. For each chunk, the server responds with the chunk’s size before sending the chunk’s contents. Looking at the output, we can see that the server streamed the entire page in 16 different chunks. At the end, the server sends back a zero-sized chunk, indicating the end of the stream.
The first chunk starts with the <!DOCTYPE html>
declaration. The second-to-last chunk, meanwhile, contains the closing </body>
and </html>
tags. So, we can see that the server streams the entire document from top to bottom, then pauses to wait for the suspended components, and finally, at the end, closes the body and HTML before it stops streaming.
Even though the server hasn’t completely finished streaming the document, the browser’s fault tolerance features allow it to draw and invoke whatever it has at the moment without waiting for the closing </body>
and </html>
tags.
We learned from the render lifecycle that when a page is visited, Next.js matches the RSC component for that page and asks React to render its subtree in HTML. When React stumbles upon a suspended component (i.e., async function component), it grabs its fallback value from the <Suspense>
component (or the loading.js
file if it’s a Next.js route), renders that instead, then continues loading the other components. Meanwhile, the RSC invokes the async component in the background, which is streamed later as it finishes loading.
At this point, Next.js has returned a full page of static HTML that includes either the components themselves (rendered in static HTML) or their fallback values (if they’re suspended). It takes the static HTML and RSC payload and streams them back to the browser through one or multiple chunks.
As the suspended components finish loading, React generates HTML recursively while looking for other nested <Suspense>
boundaries, generates their RSC payloads and then lets Next.js stream the HTML and RSC Payload back to the browser as new chunks. When the browser receives the new chunks, it has the HTML and RSC payload it needs and is ready to replace the fallback element from the DOM with the newly-streamed HTML. And so on.
In Figures 7 and 8, notice how the fallback elements have a unique ID in the form of B:0
, B:1
, and so on, while the actual components have a similar ID in a similar form: S:0
and S:1
, and so on.
Along with the first chunk that contains a suspended component’s HTML, the server also ships an $RC
function (i.e., completeBoundary
from React’s source code) that knows how to find the B:0
fallback element in the DOM and replace it with the S:0
template it received from the server. That’s the “replacer” function that lets us see the component contents when they arrive in the browser.
The entire page eventually finishes loading, chunk by chunk.
If a suspended Server Component contains a lazy-loaded Client Component, Next.js will also send an RSC payload chunk containing instructions on how to fetch and load the lazy-loaded component’s code. This represents a significant performance improvement because the page load isn’t dragged out by JavaScript, which might not even be loaded during that session.
At the time I’m writing this, the dynamic method to lazy-load a Client Component in a Server Component in Next.js does not work as you might expect. To effectively lazy-load a Client Component, put it in a “wrapper” Client Component that uses the dynamic
method itself to lazy-load the actual Client Component. The wrapper will be turned into a script that fetches and loads the Client Component’s JavaScript and CSS files at the time they’re needed.
I know that’s a lot of plates spinning and pieces moving around at various times. What it boils down to, however, is that a page visit triggers Next.js to render as much HTML as it can, using the fallback values for any suspended components, and then sends that to the browser. Meanwhile, Next.js triggers the suspended async components and gets them formatted in HTML and contained in RSC Payloads that are streamed to the browser, one by one, along with an $RC
script that knows how to swap things out.
By now, we should have a solid understanding of how RSCs work, how Next.js handles their rendering, and how all the pieces fit together. In this section, we’ll zoom in on what exactly happens when we visit an RSC page in the browser.
As we mentioned in the TL;DR section above, when visiting a page, Next.js will render the initial HTML minus the suspended component and stream it to the browser as part of the first streaming chunks.
To see everything that happens during the page load, we’ll visit the “Performance” tab in Chrome DevTools and click on the “reload” button to reload the page and capture a profile. Here’s what that looks like:
When we zoom in at the very beginning, we can see the first “Parse HTML” span. That’s the server streaming the first chunks of the document to the browser. The browser has just received the initial HTML, which contains the page shell and a few links to resources like fonts, CSS files, and JavaScript. The browser starts to invoke the scripts.
After some time, we start to see the page’s first frames appear, along with the initial JavaScript scripts being loaded and hydration taking place. If you look at the frame closely, you’ll see that the whole page shell is rendered, and “loading” components are used in the place where there are suspended Server Components. You might notice that this takes place around 800ms, while the browser started to get the first HTML at 100ms. During those 700ms, the browser is continuously receiving chunks from the server.
Bear in mind that this is a Next.js demo app running locally in development mode, so it’s going to be slower than when it’s running in production mode.
Fast forward few seconds and we see another “Parse HTML” span in the page load timeline, but this one it indicates that a suspended Server Component finished loading and is being streamed to the browser.
We can also see that a lazy-loaded Client Component is discovered at the same time, and it contains CSS and JavaScript files that need to be fetched. These files weren’t part of the initial bundle because the component isn’t needed until later on; the code is split into their own files.
This way of code-splitting certainly improves the performance of the initial page load. It also makes sure that the Client Component’s code is shipped only if it’s needed. If the Server Component (which acts as the Client Component’s parent component) throws an error, then the Client Component does not load. It doesn’t make sense to load all of its code before we know whether it will load or not.
Figure 12 shows the DOMContentLoaded
event is reported at the end of the page load timeline. And, just before that, we can see that the localhost
HTTP request comes to an end. That means the server has likely sent the last zero-sized chunk, indicating to the client that the data is fully transferred and that the streaming communication can be closed.
The main localhost
HTTP request took around five seconds, but thanks to streaming, we began seeing page contents load much earlier than that. If this was a traditional SSR setup, we would likely be staring at a blank screen for those five seconds before anything arrives. On the other hand, if this was a traditional CSR setup, we would likely have shipped a lot more of JavaScript and put a heavy burden on both the browser and network.
This way, however, the app was fully interactive in those five seconds. We were able to navigate between pages and interact with Client Components that have loaded as part of the initial main bundle. This is a pure win from a user experience standpoint.
RSCs mark a significant evolution in the React ecosystem. They leverage the strengths of server-side and client-side rendering while embracing HTML streaming to speed up content delivery. This approach not only addresses the SEO and loading time issues we experience with CSR but also improves SSR by reducing server load, thus enhancing performance.
I’ve refactored the same RSC app I shared earlier so that it uses the Next.js Page router with SSR. The improvements in RSCs are significant:
Looking at these two reports I pulled from Sentry, we can see that streaming allows the page to start loading its resources before the actual request finishes. This significantly improves the Web Vitals metrics, which we see when comparing the two reports.
The conclusion: Users enjoy faster, more reactive interfaces with an architecture that relies on RSCs.
The RSC architecture introduces two new component types: Server Components and Client Components. This division helps React and the frameworks that rely on it — like Next.js — streamline content delivery while maintaining interactivity.
However, this setup also introduces new challenges in areas like state management, authentication, and component architecture. Exploring those challenges is a great topic for another blog post!
Despite these challenges, the benefits of RSCs present a compelling case for their adoption. We definitely will see guides published on how to address RSC’s challenges as they mature, but, in my opinion, they already look like the future of rendering practices in modern web development.
Conducting Accessibility Research In An Inaccessible Ecosystem Conducting Accessibility Research In An Inaccessible Ecosystem Michele Williams 2024-04-25T12:00:00+00:00 2025-06-25T15:04:30+00:00 Ensuring technology is accessible and inclusive relies heavily on receiving feedback directly from disabled users. You cannot rely solely on checklists, guidelines, and good-faith guesses to get […]
Accessibility
2024-04-25T12:00:00+00:00
2025-06-25T15:04:30+00:00
Ensuring technology is accessible and inclusive relies heavily on receiving feedback directly from disabled users. You cannot rely solely on checklists, guidelines, and good-faith guesses to get things right. This is often hindered, however, by a lack of accessible prototypes available to use during testing.
Rather than wait for the digital landscape to change, researchers should leverage all the available tools they can use to create and replicate the testing environments they need to get this important research completed. Without it, we will continue to have a primarily inaccessible and not inclusive technology landscape that will never be disrupted.
Note: I use “identity first” disability language (as in “disabled people”) rather than “people first” language (as in “people with disabilities”). Identity first language aligns with disability advocates who see disability as a human trait description or even community and not a subject to be avoided or shamed. For more, review “Writing Respectfully: Person-First and Identity-First Language”.
When people advocate that UX Research should include disabled participants, it’s often with the mindset that this will happen on the final product once development is complete. One primary reason is because that’s when researchers have access to the most accessible artifact with which to run the study. However,
The real ability to ensure an accessible and inclusive system is not by evaluating a final product at the end of a project; it’s by assessing user needs at the start and then evaluating the iterative prototypes along the way.
“
In general, the iterative prototype phase of a project is when teams explore various design options and make decisions that will influence the final project outcome. Gathering feedback from representative users during this phase can help teams make informed decisions, including key pivots before significant development and testing resources are used.
During the prototype phase of user testing, the representative users should include disabled participants. By collecting feedback and perspectives of people with a variety of disabilities in early design testing phases, teams can more thoughtfully incorporate key considerations and supplement accessibility guidelines with real-world feedback. This early-and-often approach is the best way to include accessibility and inclusivity into a process and ensure a more accessible final product.
If you instead wait to include disabled participants in research until a product is near final, this inevitably leads to patchwork fixes of any critical feedback. Then, for feedback not deemed critical, it will likely get “backlogged” where the item priorities compete with new feature updates. With this approach, you’ll constantly be playing catch-up rather than getting it right up front and in an elegant and integrated way.
Not only does research with disabled participants often occur too late in a project, but it is also far too often viewed as separate from other research studies (sometimes referred to as the “main research”). It cannot be understated that this reinforces the notion of separate-and-not-equal as compared to non-disabled participants and other stakeholder feedback. This has a severe negative impact on how a team will view the priority of inclusive design and, more broadly, the value of disabled people. That is, this reinforces “ableism”, a devaluing of disabled people in society.
UX Research with diverse participants that include a wide variety of disabilities can go a long way in dismantling ableist views and creating vitally needed inclusive technology.
“
The problem is that even when a team is on board with the idea, it’s not always easy to do inclusive research, particularly when involving prototypes. While discovery research can be conducted with minimal tooling and summative research can leverage fully built and accessible systems, prototype research quickly reveals severe accessibility barriers that feel like they can’t be overcome.
Most technology we use has accessibility barriers for users with disabilities. As an example, the WebAIM Million report consistently finds that 96% of web homepages have accessibility errors that are fixable and preventable.
Just like websites, web, and mobile applications are similarly inaccessible, including those that produce early-stage prototypes. Thus, the artifacts researchers might want to use for prototype testing to help create accessible products are themselves inaccessible, creating a barrier for disabled research participants. It quickly becomes a vicious cycle that seems hard to break.
Currently, the most popular industry tool for initial prototyping is Figma. These files become the artifacts researchers use to conduct a research study. However, these files often fall short of being accessible enough for many participants with disabilities.
To be clear, I absolutely applaud the Figma employees who have worked very hard on including screen reader support and keyboard functionality in Figma prototypes. This represents significant progress towards removing accessibility barriers in our core products and should not be overlooked. Nevertheless, there are still limitations and even blockers to research.
For one, the Figma files must be created in a way that will mimic the website layout and code. For example, for screen reader navigation to be successful, the elements need to be in their correct reading order in the Layers panel (not solely look correct visually), include labeled elements such as buttons (not solely items styled to look like buttons), and include alternative text for images. Often, however, designers do not build iterative prototypes with these considerations in mind, which prevents the keyboard from navigating correctly and the screen reader from providing the necessary details to comprehend the page.
In addition, Figma’s prototypes do not have selectable, configurable text. This prevents key visual adjustments such as browser zoom to increase text size, dark mode, which is easier for some to view, and selecting text to have it read aloud. If a participant needs these kinds of adjustments (or others I list in the table below), a Figma prototype will not be accessible to them.
Table: Figma prototype limitations per assistive technology
Assistive Technology | Disability Category | Limitation |
---|---|---|
Keyboard-only navigation | Mobility | Must use proper element type (such as button or input) in expected page order to ensure operability |
Screen reader | Vision | Must include structure to ensure readability:
|
Dark mode/High contrast mode | Low Vision Neurodiversity |
Not available |
Browser zoom | Low Vision Neurodiversity Mobility |
Not available |
Screen reader used with mouse hover Read aloud software with text selection |
Vision Neurodiversity |
Cannot be used |
Voice control Switch control device |
Mobility | Cannot be used |
Having accessibility challenges with a prototype doesn’t mean we give up on the research. Instead, it means we need to get creative in our approach. This research is too important to keep waiting for the ideal set-up, particularly when our findings are often precisely what’s needed to create accessible technology.
Part of crafting a research study is determining what artifact to use during the study. Thus, when considering prototype research, it is a matter of creating the artifact best suited for your study. If this isn’t going to be, say, a Figma file you receive from designers, then consider what else can be used to get the job done.
Being able to include diverse perspectives from disabled research participants throughout a project’s creation is possible and necessary. Keeping in mind your research questions and the capabilities of your participants, there are research methods and strategies that can be made accessible to gather authentic feedback during the critical prototype design phase.
With that in mind, I propose five ways you can accomplish prototype research while working around inaccessible prototypes:
Not all research questions at this phase need a full working prototype to be answered, particularly if they are about the general product features or product wording and not the visual design. Oftentimes, a survey tool or similar type of evaluation can be just as effective.
For example, you can confirm a site’s navigation options are intuitive by describing a scenario with a list of navigation choices while also testing if key content is understandable by confirming the user’s next steps based on a passage of text.
Acme Company Website Survey
Complete this questionnaire to help us determine if our site will be understandable.
The Council’s Grant serves to advance Acme’s goals by sponsoring community events. In determining whether to fund an event, the Council also considers factors including, but not limited to:
To apply, download the form below.
Based on this wording, what would you include in your grant application?
[Input Field]
Just be sure you build a WCAG-compliant survey that includes accessible form layouts and question types. This will ensure participants can navigate using their assistive technologies. For example, Qualtrics has a specific form layout that is built to be accessible, or check out these accessibility tips for Google Forms. If sharing a document, note features that will enhance accessibility, such as using the ribbon for styling in Microsoft Word.
Tip: To find accessibility documentation for the software you’re using, search in your favorite search engine for the product name plus the word “accessibility” to find a product’s accessibility documentation.
The prototyping phase might be a good time to utilize co-design and participatory design methods. With these methods, you can co-create designs with participants using any variety of artifacts that match the capabilities of your participants along with your research goals. The feedback can range from high-level workflows to specific visual designs, and you can guide the conversation with mock-ups, equivalent systems, or more creative artifacts such as storyboards that illustrate a scenario for user reaction.
For the prototype artifacts, these can range from low- to high-fidelity. For instance, participants without mobility or vision impairments can use paper-and-pencil sketching or whiteboarding. People with somewhat limited mobility may prefer a tablet-based drawing tool, such as using an Apple pencil with an iPad. Participants with visual impairments may prefer more 3-dimensional tools such as craft supplies, modeling clay, and/or cardboard. Or you may find that simply working on a collaborative online document offers the best accessibility as users can engage with their personalized assistive technology to jot down ideas.
Notably, the types of artifacts you use will be beneficial across differing user groups. In fact, rather than limiting the artifacts, try to offer a variety of ways to provide feedback by default. By doing this, participants can feel more empowered and engaged by the activity while also reassuring them you have created an inclusive environment. If you’re not sure what options to include, feel free to confirm what methods will work best as you recruit participants. That is, as you describe the primary activity when they are signing up, you can ask if the materials you have will be operable for the participant or allow them to tell you what they prefer to use.
The discussion you have and any supplemental artifacts you use then depend on communication styles. For example, deaf participants may need sign language interpreters to communicate their views but will be able to see sample systems, while blind participants will need descriptions of key visual information to give feedback. The actual study facilitation comes down to who you are recruiting and what level of feedback you are seeking; from there, you can work through the accommodations that will work best.
I conducted two co-design sessions at two different project phases while exploring how to create a wearable blind pedestrian navigation device. Early in the project, when we were generally talking about the feature set, we brought in several low-fidelity supplies, including a Braille label maker, cardboard, clay, Velcro, clipboards, tape, paper, and pipe cleaners. Based on user feedback, I fashioned a clipboard hanging from pipe cleaners as one prototype.
Later in the project when we were discussing the size and weight, we taped together Arduino hardware pieces representing the features identified by the participants. Both outcomes are pictured below and featured in a paper entitled, “What Not to Wearable: Using Participatory Workshops to Explore Wearable Device Form Factors for Blind Users.”
Ultimately, the benefit of this type of study is the participant-led feedback. In this way, participants are giving unfiltered feedback that is less influenced by designers, which may lead to more thoughtful design in the end.
Very few projects are completely new creations, and often, teams use an existing site or application for project inspiration. Consider using similar existing systems and equivalent scenarios for your testing instead of creating a prototype.
By using an existing live system, participants can then use their assistive technology and adaptive techniques, which can make the study more accessible and authentic. Also, the study findings can range from the desirability of the available product features to the accessibility and usability of individual page elements. These lessons can then inform what design and code decisions to make in your system.
One caveat is to be aware of any accessibility barriers in that existing system. Particularly for website and web applications, you can look for accessibility documentation to determine if the company has reported any WCAG-conformance accessibility efforts, use tools like WAVE to test the system yourself, and/or mimic how your participants will use the system with their assistive technology. If there are workarounds for what you find, you may be able to avoid certain parts of the application or help users navigate past the inaccessible parts. However, if the site is going to be completely unusable for your participants, this won’t be a viable option for you.
If the system is usable enough for your testing, however, you can take the testing a step further by making updates on the fly if you or someone you collaborate with has engineering experience. For example, you can manipulate a website’s code with developer tools to add, subtract, or change the elements and styling on a page in real-time. (See “About browser developer tools”.) This can further enhance the feedback you give to your teams as it may more closely match your team’s intended design.
Notably, when conducting research focused on physical devices and hardware, you will not face the same obstacles to inaccessibility as with websites and web applications. You can use a variety of materials to create your prototypes, from cardboard to fabric to 3D printed material. I’ve sewn haptic vibration modules to a makeshift leather bracelet when working with wearables, for instance.
However, for web testing, it may be necessary to build a rapid prototype, especially to work around inaccessible artifacts such as a Figma file. This will include using a site builder that allows you to quickly create a replica of your team’s website. To create an accessible website, you’ll need a site builder with accessibility features and capabilities; I recommend WordPress, SquareSpace, Webflow, and Google Sites.
I recently used Google Sites to create a replica of a client’s draft pages in a matter of hours. I was adamant we should include disabled participants in feedback loops early and often, and this included after a round of significant visual design and content decisions. The web agency building the client’s site used Figma but not with the required formatting to use the built-in screen reader functionality. Rather than leave out blind user feedback at such a crucial time in the project, I started with a similar Google Sites template, took a best guess at how to structure the elements such as headings, recreated the anticipated column and card layouts as best I could, and used placeholder images with projected alt text instead of their custom graphics.
The screen reader testing turned into an impromptu co-design session because I could make changes in-the-moment to the live site for the participant to immediately test out. For example, we determined that some places where I used headings were not necessary, and we talked about image alt text in detail. I was able to add specific design and code feedback to my report, as well as share the live site (and corresponding code) with the team for comparison.
The downside to my prototype was that I couldn’t create the exact 1-to-1 visual design to use when testing with the other disabled participants who were sighted. I wanted to gather feedback on colors, fonts, and wording, so I also recruited low vision and neurodiverse participants for the study. However, my data was skewed because those participants couldn’t make the visual adjustments they needed to fully take in the content, such as recoloring, resizing, and having text read aloud. This was unfortunate, but we at least used the prototype to spark discussions of what does make a page accessible for them.
You may find you are limited in how closely you can replicate the design based on the tools you use or lack of access to developer assistance. When facing these limitations, consider what is most important to evaluate and determine if a paired-down version of the site will still give you valuable feedback over no site at all.
The Wizard of Oz (WoZ) research method involves the facilitators mimicking system interactions in place of a fully working system. With WoZ, you can create your system’s approximate functionality using equivalent accessible tools and processes.
As an example, I’ll refer you to the talk by an Ally Financial research team that used this method for participants who used screen readers. They pre-programmed screen reader prompts into a clickable spreadsheet and had participants describe aloud what keyboard actions they would take to then trigger the corresponding prompt. While not the ideal set-up for the participants or researchers, it at least brought screen reader user feedback (and recognition of the users themselves) to the early design phases of their work. For more, review their detailed talk “Removing bias with wizard of oz screen reader usability testing”.
This isn’t just limited to screen reader testing, however. In fact, I’ve also often used Wizard of Oz for Voice User Interface (VUI) design. For instance, when I helped create an Alexa “skill” (their name for an app on Amazon speech-enabled devices), our prototype wouldn’t be ready in time for user testing. So, I drafted an idea to use a Bluetooth speaker to announce prompts from a clickable spreadsheet instead. When participants spoke a command to the speaker (thinking it was an Alexa device), the facilitator would select the appropriate pre-recorded prompt or a generic “I don’t understand” message.
Any system can be mimicked when you break down its parts and pieces and think about the ultimate interaction for the user. Creating WoZ set-ups can take creativity and even significant time to put together, but the outcomes can be worth it, particularly for longer-term projects. Once the main pieces are created, the prototype set-up can be edited and reused indefinitely, including during the study or between participants. Also, the investment in an easily edited prototype pays off exponentially if it uncovers something prior to finishing the entire product. In fact, that’s the main goal of this phase of testing: to help teams know what to look out for before they go through the hard work of finishing the product.
Much has been documented about inclusive design to help teams craft technology for the widest possible audience. From the Web Content Accessibility Guidelines that help define what it means to be accessible to the Microsoft Inclusive Design Toolkits that tell the human stories behind the guidelines, there is much to learn even before a product begins.
However, the best approach is with direct user feedback. With this, we must recognize the conundrum many researchers are facing: We want to include disabled participants in UX research prior to a product being complete, but often, prototypes we have available for testing are inaccessible. This means testing with something that is essentially broken and will negatively impact our findings.
While it may feel like researchers will always be at a disadvantage if we don’t have the tools we need for testing, I think, instead, it’s time for us to push back. I propose we do this on two fronts:
The key is to get disabled perspectives on the record and in the dataset of team members making the decisions. By doing this, hopefully, we shift the culture to wanting and valuing this feedback and bringing awareness to what it takes to make it happen.
Ideally, the awareness raised from our bootstrap efforts will lead to more people helping reduce the current prototype barriers. For some of us, this means urging companies to prioritize accessibility features in their roadmaps. For those working within influential prototype companies, it can mean getting much-needed backing to innovate better in this area.
The current state of our inaccessible digital ecosystem can sometimes feel like an entanglement too big to unravel. However, we must remain steadfast and insist that this does not remain the status quo; disabled users are users, and their diverse and invaluable perspectives must be a part of our research outcomes at all phases.
Converting Plain Text To Encoded HTML With Vanilla JavaScript Converting Plain Text To Encoded HTML With Vanilla JavaScript Alexis Kypridemos 2024-04-17T13:00:00+00:00 2025-06-25T15:04:30+00:00 When copying text from a website to your device’s clipboard, there’s a good chance that you will get the formatted HTML when pasting […]
Accessibility
2024-04-17T13:00:00+00:00
2025-06-25T15:04:30+00:00
When copying text from a website to your device’s clipboard, there’s a good chance that you will get the formatted HTML when pasting it. Some apps and operating systems have a “Paste Special” feature that will strip those tags out for you to maintain the current style, but what do you do if that’s unavailable?
Same goes for converting plain text into formatted HTML. One of the closest ways we can convert plain text into HTML is writing in Markdown as an abstraction. You may have seen examples of this in many comment forms in articles just like this one. Write the comment in Markdown and it is parsed as HTML.
Even better would be no abstraction at all! You may have also seen (and used) a number of online tools that take plainly written text and convert it into formatted HTML. The UI makes the conversion and previews the formatted result in real time.
Providing a way for users to author basic web content — like comments — without knowing even the first thing about HTML, is a novel pursuit as it lowers barriers to communicating and collaborating on the web. Saying it helps “democratize” the web may be heavy-handed, but it doesn’t conflict with that vision!
We can build a tool like this ourselves. I’m all for using existing resources where possible, but I’m also for demonstrating how these things work and maybe learning something new in the process.
There are plenty of assumptions and considerations that could go into a plain-text-to-HTML converter. For example, should we assume that the first line of text entered into the tool is a title that needs corresponding <h1>
tags? Is each new line truly a paragraph, and how does linking content fit into this?
Again, the idea is that a user should be able to write without knowing Markdown or HTML syntax. This is a big constraint, and there are far too many HTML elements we might encounter, so it’s worth knowing the context in which the content is being used. For example, if this is a tool for writing blog posts, then we can limit the scope of which elements are supported based on those that are commonly used in long-form content: <h1>
, <p>
, <a>
, and <img>
. In other words, it will be possible to include top-level headings, body text, linked text, and images. There will be no support for bulleted or ordered lists, tables, or any other elements for this particular tool.
The front-end implementation will rely on vanilla HTML, CSS, and JavaScript to establish a small form with a simple layout and functionality that converts the text to HTML. There is a server-side aspect to this if you plan on deploying it to a production environment, but our focus is purely on the front end.
There are existing ways to accomplish this. For example, some libraries offer a WYSIWYG editor. Import a library like TinyMCE with a single <script>
and you’re good to go. WYSIWYG editors are powerful and support all kinds of formatting, even applying CSS classes to content for styling.
But TinyMCE isn’t the most efficient package at about 500 KB minified. That’s not a criticism as much as an indication of how much functionality it covers. We want something more “barebones” than that for our simple purpose. Searching GitHub surfaces more possibilities. The solutions, however, seem to fall into one of two categories:
<h1>
and <p>
tags.Even if a perfect solution for what we want was already out there, I’d still want to pick apart the concept of converting text to HTML to understand how it works and hopefully learn something new in the process. So, let’s proceed with our own homespun solution.
We’ll start with the HTML structure for the input and output. For the input element, we’re probably best off using a <textarea>
. For the output element and related styling, choices abound. The following is merely one example with some very basic CSS to place the input <textarea>
on the left and an output <div>
on the right:
See the Pen [Base Form Styles [forked]](https://codepen.io/smashingmag/pen/OJGoNOX) by Geoff Graham.
You can further develop the CSS, but that isn’t the focus of this article. There is no question that the design can be prettier than what I am providing here!
We’ll set an onkeyup
event handler on the <textarea>
to call a JavaScript function called convert()
that does what it says: convert the plain text into HTML. The conversion function should accept one parameter, a string, for the user’s plain text input entered into the <textarea>
element:
<textarea onkeyup='convert(this.value);'></textarea>
onkeyup
is a better choice than onkeydown
in this case, as onkeyup
will call the conversion function after the user completes each keystroke, as opposed to before it happens. This way, the output, which is refreshed with each keystroke, always includes the latest typed character. If the conversion is triggered with an onkeydown
handler, the output will exclude the most recent character the user typed. This can be frustrating when, for example, the user has finished typing a sentence but cannot yet see the final punctuation mark, say a period (.
), in the output until typing another character first. This creates the impression of a typo, glitch, or lag when there is none.
In JavaScript, the convert()
function has the following responsibilities:
<h1>
or <p>
HTML tag, whichever is most appropriate.<a>
tags, and replace image file names with <img>
elements.And from there, we display the output. We can create separate functions for each responsibility. Let’s name them accordingly:
html_encode()
convert_text_to_HTML()
convert_images_and_links_to_HTML()
Each function accepts one parameter, a string, and returns a string.
Use the html_encode()
function to HTML encode/sanitize the input. HTML encoding refers to the process of escaping or replacing certain characters in a string input to prevent users from inserting their own HTML into the output. At a minimum, we should replace the following characters:
<
with <
>
with >
&
with &
'
with '
"
with "
JavaScript does not provide a built-in way to HTML encode input as other languages do. For example, PHP has htmlspecialchars()
, htmlentities()
, and strip_tags()
functions. That said, it is relatively easy to write our own function that does this, which is what we’ll use the html_encode()
function for that we defined earlier:
function html_encode(input) {
const textArea = document.createElement("textarea");
textArea.innerText = input;
return textArea.innerHTML.split("<br>").join("n");
}
HTML encoding of the input is a critical security consideration. It prevents unwanted scripts or other HTML manipulations from getting injected into our work. Granted, front-end input sanitization and validation are both merely deterrents because bad actors can bypass them. But we may as well make them work a little harder.
As long as we are on the topic of securing our work, make sure to HTML-encode the input on the back end, where the user cannot interfere. At the same time, take care not to encode the input more than once. Encoding text that is already HTML-encoded will break the output functionality. The best approach for back-end storage is for the front end to pass the raw, unencoded input to the back end, then ask the back-end to HTML-encode the input before inserting it into a database.
That said, this only accounts for sanitizing and storing the input on the back end. We still have to display the encoded HTML output on the front end. There are at least two approaches to consider:
Let’s use the convert_text_to_HTML()
function we defined earlier to wrap each line in their respective HTML tags, which are going to be either <h1>
or <p>
. To determine which tag to use, we will split
the text input on the newline character (n
) so that the text is processed as an array of lines rather than a single string, allowing us to evaluate them individually.
function convert_text_to_HTML(txt) {
// Output variable
let out = '';
// Split text at the newline character into an array
const txt_array = txt.split("n");
// Get the number of lines in the array
const txt_array_length = txt_array.length;
// Variable to keep track of the (non-blank) line number
let non_blank_line_count = 0;
for (let i = 0; i < txt_array_length; i++) {
// Get the current line
const line = txt_array[i];
// Continue if a line contains no text characters
if (line === ''){
continue;
}
non_blank_line_count++;
// If a line is the first line that contains text
if (non_blank_line_count === 1){
// ...wrap the line of text in a Heading 1 tag
out += `<h1>${line}</h1>`;
// ...otherwise, wrap the line of text in a Paragraph tag.
} else {
out += `<p>${line}</p>`;
}
}
return out;
}
In short, this little snippet loops through the array of split text lines and ignores lines that do not contain any text characters. From there, we can evaluate whether a line is the first one in the series. If it is, we slap a <h1>
tag on it; otherwise, we mark it up in a <p>
tag.
This logic could be used to account for other types of elements that you may want to include in the output. For example, perhaps the second line is assumed to be a byline that names the author and links up to an archive of all author posts.
Next, we’re going to create our convert_images_and_links_to_HTML()
function to encode URLs and images as HTML elements. It’s a good chunk of code, so I’ll drop it in and we’ll immediately start picking it apart together to explain how it all works.
function convert_images_and_links_to_HTML(string){
let urls_unique = [];
let images_unique = [];
const urls = string.match(/https*://[^s<),]+[^s<),.]/gmi) ?? [];
const imgs = string.match(/[^"'>s]+.(jpg|jpeg|gif|png|webp)/gmi) ?? [];
const urls_length = urls.length;
const images_length = imgs.length;
for (let i = 0; i < urls_length; i++){
const url = urls[i];
if (!urls_unique.includes(url)){
urls_unique.push(url);
}
}
for (let i = 0; i < images_length; i++){
const img = imgs[i];
if (!images_unique.includes(img)){
images_unique.push(img);
}
}
const urls_unique_length = urls_unique.length;
const images_unique_length = images_unique.length;
for (let i = 0; i < urls_unique_length; i++){
const url = urls_unique[i];
if (images_unique_length === 0 || !images_unique.includes(url)){
const a_tag = `<a href="${url}" target="_blank">${url}</a>`;
string = string.replace(url, a_tag);
}
}
for (let i = 0; i < images_unique_length; i++){
const img = images_unique[i];
const img_tag = `<img src="${img}" alt="">`;
const img_link = `<a href="${img}">${img_tag}</a>`;
string = string.replace(img, img_link);
}
return string;
}
Unlike the convert_text_to_HTML()
function, here we use regular expressions to identify the terms that need to be wrapped and/or replaced with <a>
or <img>
tags. We do this for a couple of reasons:
convert_text_to_HTML()
function handles text that would be transformed to the HTML block-level elements <h1>
and <p>
, and, if you want, other block-level elements such as <address>
. Block-level elements in the HTML output correspond to discrete lines of text in the input, which you can think of as paragraphs, the text entered between presses of the Enter key.Regular expressions, though they are powerful and the appropriate tool to use for this job, come with a performance cost, which is another reason to use each expression only once for the entire text input.
Remember: All the JavaScript in this example runs each time the user types a character, so it is important to keep things as lightweight and efficient as possible.
I also want to make a note about the variable names in our convert_images_and_links_to_HTML()
function. images
(plural), image
(singular), and link
are reserved words in JavaScript. Consequently, imgs
, img
, and a_tag
were used for naming. Interestingly, these specific reserved words are not listed on the relevant MDN page, but they are on W3Schools.
We’re using the String.prototype.match()
function for each of the two regular expressions, then storing the results for each call in an array. From there, we use the nullish coalescing operator (??
) on each call so that, if no matches are found, the result will be an empty array. If we do not do this and no matches are found, the result of each match()
call will be null
and will cause problems downstream.
const urls = string.match(/https*://[^s<),]+[^s<),.]/gmi) ?? [];
const imgs = string.match(/[^"'>s]+.(jpg|jpeg|gif|png|webp)/gmi) ?? [];
Next up, we filter the arrays of results so that each array contains only unique results. This is a critical step. If we don’t filter out duplicate results and the input text contains multiple instances of the same URL or image file name, then we break the HTML tags in the output. JavaScript does not provide a simple, built-in method to get unique items in an array that’s akin to the PHP array_unique()
function.
The code snippet works around this limitation using an admittedly ugly but straightforward procedural approach. The same problem is solved using a more functional approach if you prefer. There are many articles on the web describing various ways to filter a JavaScript array in order to keep only the unique items.
We’re also checking if the URL is matched as an image before replacing a URL with an appropriate <a>
tag and performing the replacement only if the URL doesn’t match an image. We may be able to avoid having to perform this check by using a more intricate regular expression. The example code deliberately uses regular expressions that are perhaps less precise but hopefully easier to understand in an effort to keep things as simple as possible.
And, finally, we’re replacing image file names in the input text with <img>
tags that have the src
attribute set to the image file name. For example, my_image.png
in the input is transformed into <img src='my_image.png'>
in the output. We wrap each <img>
tag with an <a>
tag that links to the image file and opens it in a new tab when clicked.
There are a couple of benefits to this approach:
<figcaption>
, <cite>
, or similar element. But if, for whatever reason, you are unable to provide explicit attribution, you are at least providing a link to the image source.It may go without saying, but “hotlinking” images is something to avoid. Use only locally hosted images wherever possible, and provide attribution if you do not hold the copyright for them.
Before we move on to displaying the converted output, let’s talk a bit about accessibility, specifically the image alt
attribute. The example code I provided does add an alt
attribute in the conversion but does not populate it with a value, as there is no easy way to automatically calculate what that value should be. An empty alt
attribute can be acceptable if the image is considered “decorative,” i.e., purely supplementary to the surrounding text. But one may argue that there is no such thing as a purely decorative image.
That said, I consider this to be a limitation of what we’re building.
We’re at the point where we can finally work on displaying the HTML-encoded output! We’ve already handled all the work of converting the text, so all we really need to do now is call it:
function convert(input_string) {
output.innerHTML = convert_images_and_links_to_HTML(convert_text_to_HTML(html_encode(input_string)));
}
If you would rather display the output string as raw HTML markup, use a <pre>
tag as the output element instead of a <div>
:
<pre id='output'></pre>
The only thing to note about this approach is that you would target the <pre>
element’s textContent
instead of innerHTML
:
function convert(input_string) {
output.textContent = convert_images_and_links_to_HTML(convert_text_to_HTML(html_encode(input_string)));
}
We did it! We built one of the same sort of copy-paste tool that converts plain text on the spot. In this case, we’ve configured it so that plain text entered into a <textarea>
is parsed line-by-line and encoded into HTML that we format and display inside another element.
See the Pen [Convert Plain Text to HTML (PoC) [forked]](https://codepen.io/smashingmag/pen/yLrxOzP) by Geoff Graham.
We were even able to keep the solution fairly simple, i.e., vanilla HTML, CSS, and JavaScript, without reaching for a third-party library or framework. Does this simple solution do everything a ready-made tool like a framework can do? Absolutely not. But a solution as simple as this is often all you need: nothing more and nothing less.
As far as scaling this further, the code could be modified to POST
what’s entered into the <form>
using a PHP script or the like. That would be a great exercise, and if you do it, please share your work with me in the comments because I’d love to check it out.
Setting And Persisting Color Scheme Preferences With CSS And A “Touch” Of JavaScript Setting And Persisting Color Scheme Preferences With CSS And A “Touch” Of JavaScript Henry Bley-Vroman 2024-03-25T12:00:00+00:00 2025-06-25T15:04:30+00:00 Many modern websites give users the power to set a site-specific color scheme preference. A basic implementation is straightforward […]
Accessibility
2024-03-25T12:00:00+00:00
2025-06-25T15:04:30+00:00
Many modern websites give users the power to set a site-specific color scheme preference. A basic implementation is straightforward with JavaScript: listen for when a user changes a checkbox or clicks a button, toggle a class (or attribute) on the <body>
element in response, and write the styles for that class to override design with a different color scheme.
CSS’s new :has()
pseudo-class, supported by major browsers since December 2023, opens many doors for front-end developers. I’m especially excited about leveraging it to modify UI in response to user interaction without JavaScript. Where previously we have used JavaScript to toggle classes or attributes (or to set styles directly), we can now pair :has()
selectors with HTML’s native interactive elements.
Supporting a color scheme preference, like “Dark Mode,” is a great use case. We can use a <select>
element anywhere that toggles color schemes based on the selected <option>
— no JavaScript needed, save for a sprinkle to save the user’s choice, which we’ll get to further in.
First, we’ll support a user’s system-wide color scheme preferences by adopting a “Light Mode”-first approach. In other words, we start with a light color scheme by default and swap it out for a dark color scheme for users who prefer it.
The prefers-color-scheme
media feature detects the user’s system preference. Wrap “dark” styles in a prefers-color-scheme: dark
media query.
selector {
/* light styles */
@media (prefers-color-scheme: dark) {
/* dark styles */
}
}
Next, set the color-scheme
property to match the preferred color scheme. Setting color-scheme: dark
switches the browser into its built-in dark mode, which includes a black default background, white default text, “dark” styles for scrollbars, and other elements that are difficult to target with CSS, and more. I’m using CSS variables to hint that the value is dynamic — and because I like the browser developer tools experience — but plain color-scheme: light
and color-scheme: dark
would work fine.
:root {
/* light styles here */
color-scheme: var(--color-scheme, light);
/* system preference is "dark" */
@media (prefers-color-scheme: dark) {
--color-scheme: dark;
/* any additional dark styles here */
}
}
Now, to support overriding the system preference, let users choose between light (default) and dark color schemes at the page level.
HTML has native elements for handling user interactions. Using one of those controls, rather than, say, a <div>
nest, improves the chances that assistive tech users will have a good experience. I’ll use a <select>
menu with options for “system,” “light,” and “dark.” A group of <input type="radio">
would work, too, if you wanted the options right on the surface instead of a dropdown menu.
<select id="color-scheme">
<option value="system" selected>System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
Before CSS gained :has()
, responding to the user’s selected <option>
required JavaScript, for example, setting an event listener on the <select>
to toggle a class or attribute on <html>
or <body>
.
But now that we have :has()
, we can now do this with CSS alone! You’ll save spending any of your performance budget on a dark mode script, plus the control will work even for users who have disabled JavaScript. And any “no-JS” folks on the project will be satisfied.
What we need is a selector that applies to the page when it :has()
a select
menu with a particular [value]:checked
. Let’s translate that into CSS:
:root:has(select option[value="dark"]:checked)
We’re defaulting to a light color scheme, so it’s enough to account for two possible dark color scheme scenarios:
The first one is a page-preference-aware iteration of our prefers-color-scheme: dark
case. A “dark” system-level preference is no longer enough to warrant dark styles; we need a “dark” system-level preference and a “follow the system-level preference” at the page-level preference. We’ll wrap the prefers-color-scheme
media query dark scheme styles with the :has()
selector we just wrote:
:root {
/* light styles here */
color-scheme: var(--color-scheme, light);
/* page preference is "system", and system preference is "dark" */
@media (prefers-color-scheme: dark) {
&:has(#color-scheme option[value="system"]:checked) {
--color-scheme: dark;
/* any additional dark styles, again */
}
}
}
Notice that I’m using CSS Nesting in that last snippet. Baseline 2023 has it pegged as “Newly available across major browsers” which means support is good, but at the time of writing, support on Android browsers not included in Baseline’s core browser set is limited. You can get the same result without nesting.
:root {
/* light styles */
color-scheme: var(--color-scheme, light);
/* page preference is "dark" */
&:has(#color-scheme option[value="dark"]:checked) {
--color-scheme: dark;
/* any additional dark styles */
}
}
For the second dark mode scenario, we’ll use nearly the exact same :has()
selector as we did for the first scenario, this time checking whether the “dark” option — rather than the “system” option — is selected:
:root {
/* light styles */
color-scheme: var(--color-scheme, light);
/* page preference is "dark" */
&:has(#color-scheme option[value="dark"]:checked) {
--color-scheme: dark;
/* any additional dark styles */
}
/* page preference is "system", and system preference is "dark" */
@media (prefers-color-scheme: dark) {
&:has(#color-scheme option[value="system"]:checked) {
--color-scheme: dark;
/* any additional dark styles, again */
}
}
}
Now the page’s styles respond to both changes in users’ system settings and user interaction with the page’s color preference UI — all with CSS!
But the colors change instantly. Let’s smooth the transition.
Instantaneous style changes can feel inelegant in some cases, and this is one of them. So, let’s apply a CSS transition on the :root
to “ease” the switch between color schemes. (Transition styles at the :root
will cascade down to the rest of the page, which may necessitate adding transition: none
or other transition overrides.)
Note that the CSS color-scheme
property does not support transitions.
:root {
transition-duration: 200ms;
transition-property: /* properties changed by your light/dark styles */;
}
Not all users will consider the addition of a transition a welcome improvement. Querying the prefers-reduced-motion
media feature allows us to account for a user’s motion preferences. If the value is set to reduce
, then we remove the transition-duration
to eliminate unwanted motion.
:root {
transition-duration: 200ms;
transition-property: /* properties changed by your light/dark styles */;
@media screen and (prefers-reduced-motion: reduce) {
transition-duration: none;
}
}
Transitions can also produce poor user experiences on devices that render changes slowly, for example, ones with e-ink screens. We can extend our “no motion condition” media query to account for that with the update
media feature. If its value is slow
, then we remove the transition-duration
.
:root {
transition-duration: 200ms;
transition-property: /* properties changed by your light/dark styles */;
@media screen and (prefers-reduced-motion: reduce), (update: slow) {
transition-duration: 0s;
}
}
Let’s try out what we have so far in the following demo. Notice that, to work around color-scheme
’s lack of transition support, I’ve explicitly styled the properties that should transition during theme changes.
See the Pen [CSS-only theme switcher (requires :has()) [forked]](https://codepen.io/smashingmag/pen/YzMVQja) by Henry.
Not bad! But what happens if the user refreshes the pages or navigates to another page? The reload effectively wipes out the user’s form selection, forcing the user to re-make the selection. That may be acceptable in some contexts, but it’s likely to go against user expectations. Let’s bring in JavaScript for a touch of progressive enhancement in the form of…
Here’s a vanilla JavaScript implementation. It’s a naive starting point — the functions and variables aren’t encapsulated but are instead properties on window
. You’ll want to adapt this in a way that fits your site’s conventions, framework, library, and so on.
When the user changes the color scheme from the <select>
menu, we’ll store the selected <option>
value in a new localStorage
item called "preferredColorScheme"
. On subsequent page loads, we’ll check localStorage
for the "preferredColorScheme"
item. If it exists, and if its value corresponds to one of the form control options, we restore the user’s preference by programmatically updating the menu selection.
/*
* If a color scheme preference was previously stored,
* select the corresponding option in the color scheme preference UI
* unless it is already selected.
*/
function restoreColorSchemePreference() {
const colorScheme = localStorage.getItem(colorSchemeStorageItemName);
if (!colorScheme) {
// There is no stored preference to restore
return;
}
const option = colorSchemeSelectorEl.querySelector(`[value=${colorScheme}]`);
if (!option) {
// The stored preference has no corresponding option in the UI.
localStorage.removeItem(colorSchemeStorageItemName);
return;
}
if (option.selected) {
// The stored preference's corresponding menu option is already selected
return;
}
option.selected = true;
}
/*
* Store an event target's value in localStorage under colorSchemeStorageItemName
*/
function storeColorSchemePreference({ target }) {
const colorScheme = target.querySelector(":checked").value;
localStorage.setItem(colorSchemeStorageItemName, colorScheme);
}
// The name under which the user's color scheme preference will be stored.
const colorSchemeStorageItemName = "preferredColorScheme";
// The color scheme preference front-end UI.
const colorSchemeSelectorEl = document.querySelector("#color-scheme");
if (colorSchemeSelectorEl) {
restoreColorSchemePreference();
// When the user changes their color scheme preference via the UI,
// store the new preference.
colorSchemeSelectorEl.addEventListener("input", storeColorSchemePreference);
}
Let’s try that out. Open this demo (perhaps in a new window), use the menu to change the color scheme, and then refresh the page to see your preference persist:
See the Pen [CSS-only theme switcher (requires :has()) with JS persistence [forked]](https://codepen.io/smashingmag/pen/GRLmEXX) by Henry.
If your system color scheme preference is “light” and you set the demo’s color scheme to “dark,” you may get the light mode styles for a moment immediately after reloading the page before the dark mode styles kick in. That’s because CodePen loads its own JavaScript before the demo’s scripts. That is out of my control, but you can take care to improve this persistence on your projects.
Where things can get tricky is restoring the user’s preference immediately after the page loads. If the color scheme preference in localStorage
is different from the user’s system-level color scheme preference, it’s possible the user will see the system preference color scheme before the page-level preference is restored. (Users who have selected the “System” option will never get that flash; neither will those whose system settings match their selected option in the form control.)
If your implementation is showing a “flash of inaccurate color theme”, where is the problem happening? Generally speaking, the earlier the scripts appear on the page, the lower the risk. The “best option” for you will depend on your specific stack, of course.
:has()
?All major browsers support :has()
today Lean into modern platforms if you can. But if you do need to consider legacy browsers, like Internet Explorer, there are two directions you can go: either hide or remove the color scheme picker for those browsers or make heavier use of JavaScript.
If you consider color scheme support itself a progressive enhancement, you can entirely hide the selection UI in browsers that don’t support :has()
:
@supports not selector(:has(body)) {
@media (prefers-color-scheme: dark) {
:root {
/* dark styles here */
}
}
#color-scheme {
display: none;
}
}
Otherwise, you’ll need to rely on a JavaScript solution not only for persistence but for the core functionality. Go back to that traditional event listener toggling a class or attribute.
The CSS-Tricks “Complete Guide to Dark Mode” details several alternative approaches that you might consider as well when working on the legacy side of things.