There are many ways to approach a “Dark Mode” feature that respects a user’s system color scheme preferences and allows for per-site customization. With recent developments in CSS, core function
Javascript
Autonomy is an output of a technical system. Trustworthiness is an output of a design process. Here are concrete design patterns, operational frameworks, and organizational practices for building agen
Ux
What makes streaks so powerful and addictive? To design them well, you need to understand how they align with human psychology. Victor Ayomipo breaks down the UX and design principles behind effective
Ux
There are many ways to approach a “Dark Mode” feature that respects a user’s system color scheme preferences and allows for per-site customization. With recent developments in CSS, core function
Javascript
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.
Autonomy is an output of a technical system. Trustworthiness is an output of a design process. Here are concrete design patterns, operational frameworks, and organizational practices for building agen
Ux
In the first part of this series, we established the fundamental shift from generative to agentic artificial intelligence. We explored why this leap from suggesting to acting demands a new psychological and methodological toolkit for UX researchers, product managers, and leaders. We defined a taxonomy of agentic behaviors, from suggesting to acting autonomously, outlined the essential research methods, defined the risks of agentic sludge, and established the accountability metrics required to navigate this new territory. We covered the what and the why.
Now, we move from the foundational to the functional. This article provides the how: the concrete design patterns, operational frameworks, and organizational practices essential for building agentic systems that are not only powerful but also transparent, controllable, and worthy of user trust. If our research is the diagnostic tool, these patterns are the treatment plan. They are the practical mechanisms through which we can give users a palpable sense of control, even as we grant AI unprecedented autonomy. The goal is to create an experience where autonomy feels like a privilege granted by the user, not a right seized by the system.
Designing for agentic AI is designing for a relationship. This relationship, like any successful partnership, must be built on clear communication, mutual understanding, and established boundaries.
To manage the shift from suggestion to action, we utilize six patterns that follow the functional lifecycle of an agentic interaction:
Below, we will cover each pattern in detail, including recommendations for metrics for success. These targets are representative benchmarks based on industry standards; adjust them based on your specific domain risk.
This pattern is the conversational equivalent of saying, “Here’s what I’m about to do. Are you okay with that?” It’s the foundational moment of seeking consent in the user-agent relationship.
Before an agent takes any significant action, the user must have a clear, unambiguous understanding of what is about to happen. The Intent Preview, or Plan Summary, establishes informed consent. It is the conversational pause before action, transforming a black box of autonomous processes into a transparent, reviewable plan.
Psychological Underpinning
Presenting a plan before action reduces cognitive load and eliminates surprise, giving users a moment to verify the agent truly understands their intent.
Anatomy of an Effective Intent Preview:
Let’s revisit our travel assistant scenario from the first part of this series. We use this proactive assistant to illustrate how an agent handles a flight cancellation. The agent has detected a flight cancellation and has formulated a recovery plan.
The Intent Preview would look something like this:
Proposed Plan for Your Trip Disruption
I’ve detected that your 10:05 AM flight has been canceled. Here’s what I plan to do:
- Cancel Flight UA456
Process refund and confirm cancellation details.- Rebook on Flight DL789
Book a confirmed seat on a 2:30 PM non-stop flight, as this is the next available non-stop flight with a confirmed seat.- Update Hotel Reservation
Notify the Marriott that you will be arriving late.- Email Updated Itinerary
Send the new flight and hotel details to you and your assistant, Jane Doe.[ Proceed with this Plan ] [ Edit Plan ] [ Handle it Myself ]
This preview is effective because it provides a complete picture, from cancellation to communication, and offers three distinct paths forward: full consent (Proceed), a desire for modification (Edit Plan), or a full override (Handle it Myself). This multifaceted control is the bedrock of trust.
When to Prioritize This Pattern
This pattern is non-negotiable for any action that is irreversible (e.g., deleting user data), involves a financial transaction of any amount, shares information with other people or systems, or makes a significant change that a user cannot easily undo.
Risk of Omission
Without this, users feel ambushed by the agent’s actions and will disable the feature to regain control.
Metrics for Success:
While travel plans are a relatable baseline, this pattern becomes indispensable in complex, high-stakes environments where an error results in more than an inconvenience for an individual traveling. Many of us work in settings where wrong decisions may result in a system outage, putting a patient’s safety at risk, or numerous other catastrophic outcomes that unreliable technology would introduce.
Consider a DevOps Release Agent tasked with managing cloud infrastructure. In this context, the Intent Preview acts as a safety barrier against accidental downtime.
In this interface, the specific terminology (Drain Traffic, Rollback) replaces generalities, and the actions are binary and impactful. The user authorizes a major operational shift based on the agent’s logic, rather than approving a suggestion.
Every healthy relationship has boundaries. The Autonomy Dial is how the user establishes it with their agent, defining what they are comfortable with the agent handling on its own.
Trust is not a binary switch; it’s a spectrum. A user might trust an agent to handle low-stakes tasks autonomously but demand full confirmation for high-stakes decisions. The Autonomy Dial, a form of progressive authorization, allows users to set their preferred level of agent independence, making them active participants in defining the relationship.
Psychological Underpinning
Allowing users to tune the agent’s autonomy grants them a locus of control, letting them match the system’s behavior to their personal risk tolerance.
Implementation
This can be implemented as a simple, clear setting within the application, ideally on a per-task-type basis. Using the taxonomy from our first article, the settings could be:
An email assistant, for example, could have a separate autonomy dial for scheduling meetings versus sending emails on the user’s behalf. This granularity is key, as it reflects the nuanced reality of a user’s trust.
When to Prioritize This Pattern
Prioritize this in systems where tasks vary widely in risk and personal preference (e.g., financial management tools, communication platforms). It is essential for onboarding, allowing users to start with low autonomy and increase it as their confidence grows.
Risk of Omission
Without this, users who experience a single failure will abandon the agent completely rather than simply dialing back its permissions.
Metrics for Success:
After taking an action, a good partner explains their reasoning. This pattern is the open communication that follows an action, answering Why? before it’s even asked. “I did that because you’ve told me in the past that you prefer X.”
When an agent acts, especially autonomously, the immediate question in the user’s mind is often, Why did it do that? The Explainable Rationale pattern proactively answers this question, providing a concise justification for the agent’s decisions. This is not a technical log file. In my first article of this series, we discussed translating system primitives into user-facing language to prevent deception. This pattern is the practical application of that principle. It transforms the raw logic into a human-readable explanation grounded in the user’s own stated preferences and prior inputs.
Psychological Underpinning
When an agent’s actions are explainable, they feel logical rather than random, helping the user build an accurate mental model of how the agent thinks.
Effective Rationales:
Returning to the travel example, after the flight is rebooked autonomously, the user might see this in their notification feed:
I’ve rebooked your canceled flight.
- New Flight: Delta 789, departing at 2:30 PM.
- Why I took this action:
- Your original flight was canceled by the airline.
- You’ve pre-approved autonomous rebooking for same-day, non-stop flights.
[ View New Itinerary ] [ Undo this Action ]
The rationale is clear, defensible, and reinforces the idea that the agent is operating within the boundaries the user established.
When to Prioritize This Pattern
Prioritize it for any autonomous action where the reasoning isn’t immediately obvious from the context, especially for actions that happen in the background or are triggered by an external event (like the flight cancellation example).
Risk of Omission
Without this, users interpret valid autonomous actions as random behavior or ‘bugs,’ preventing them from forming a correct mental model.
Metrics for Success:
This pattern is about the agent being self-aware in the relationship. By communicating its own confidence, it helps the user decide when to trust its judgment and when to apply more scrutiny.
To help users calibrate their own trust, the agent should surface its own confidence in its plans and actions. This makes the agent’s internal state more legible and helps the user decide when to scrutinize a decision more closely.
Psychological Underpinning
Surfacing uncertainty helps prevent automation bias, encouraging users to scrutinize low-confidence plans rather than blindly accepting them.
Implementation:
When to Prioritize This Pattern
Prioritize when the agent’s performance can vary significantly based on the quality of input data or the ambiguity of the task. It is especially valuable in expert systems (e.g., medical aids, code assistants) where a human must critically evaluate the AI’s output.
Risk of Omission
Without this, users will fall victim to automation bias, blindly accepting low-confidence hallucinations, or anxiously double-check high-confidence work.
Metrics for Success:
Trust requires knowing you can recover from a mistake. The Undo function is the ultimate relationship safety net, assuring the user that even if the agent misunderstands, the consequences are not catastrophic.
The single most powerful mechanism for building user confidence is the ability to easily reverse an agent’s action. A persistent, easy-to-read Action Audit log, with a prominent Undo button for every possible action, is the ultimate safety net. It dramatically lowers the perceived risk of granting autonomy.
Psychological Underpinning
Knowing that a mistake can be easily undone creates psychological safety, encouraging users to delegate tasks without fear of irreversible consequences.
Design Best Practices:
When to Prioritize This Pattern
This is a foundational pattern that should be implemented in nearly all agentic systems. It is absolutely non-negotiable when introducing autonomous features or when the cost of an error (financial, social, or data-related) is high.
Risk of Omission
Without this, one error permanently destroys trust, as users realize they have no safety net.
Metrics for Success:
A smart partner knows when to ask for help instead of guessing. This pattern allows the agent to handle ambiguity gracefully by escalating to the user, demonstrating a humility that builds, rather than erodes, trust.
Even the most advanced agent will encounter situations where it is uncertain about the user’s intent or the best course of action. How it handles this uncertainty is a defining moment. A well-designed agent doesn’t guess; it escalates.
Psychological Underpinning
When an agent acknowledges its limits rather than guessing, it builds trust by respecting the user’s authority in ambiguous situations.
Escalation Patterns Include:
When to Prioritize This Pattern
Prioritize in domains where user intent can be ambiguous or highly context-dependent (e.g., natural language interactions, complex data queries). Use this whenever the agent operates with incomplete information or when multiple correct paths exist.
Risk of Omission
Without this, the agent will eventually make a confident, catastrophic guess that alienates the user.
Metrics for Success:
| Pattern | Best For | Primary Risk | Key Metric |
|---|---|---|---|
| Intent Preview | Irreversible or financial actions | User feels ambushed | >85% Acceptance Rate |
| Autonomy Dial | Tasks with variable risk levels | Total feature abandonment | Setting Churn |
| Explainable Rationale | Background or autonomous tasks | User perceives bugs | “Why?” Ticket Volume |
| Confidence Signal | Expert or high-stakes systems | Automation bias | Scrutiny Delta |
| Action Audit & Undo | All agentic systems | Permanent loss of trust | <5% Reversion Rate |
| Escalation Pathway | Ambiguous user intent | Confident, catastrophic guesses | >90% Recovery Success |
Table 1: Summary of Agentic AI UX patterns. Remember to adjust the metrics based on your specific domain risk and needs.
This is learning how to apologize effectively. A good apology acknowledges the mistake, fixes the damage, and promises to learn from it.
Errors are not a possibility; they are an inevitability.
“
When an agent makes a mistake, the error message is the apology. It must be designed with psychological precision. This moment is a critical opportunity to demonstrate accountability. From a service design perspective, this is where companies can use the service recovery paradox: the phenomenon where a customer who experiences a service failure, followed by a successful and empathetic recovery, can actually become more loyal than a customer who never experienced a failure at all. A well-handled mistake can be a more powerful trust-building event than a long history of flawless execution.
The key is treating the error as a relationship rupture that needs to be mended. This involves:
A well-designed repair UI might look like this:
We made a mistake on your recent transfer.
I apologize. I transferred $250 to the wrong account.✔ Corrective Action: The transfer has been reversed, and your $250 has been refunded.
✔ Next Steps: The incident has been flagged for internal review to prevent it from happening again.Need further help? [ Contact Support ]
The design patterns described above are the user-facing controls, but they cannot function effectively without a robust internal support structure. This is not about creating bureaucratic hurdles; it is about building a strategic advantage. An organization with a mature governance framework can ship more ambitious agentic features with greater speed and confidence, knowing that the necessary guardrails are in place to mitigate brand risk. This governance engine turns safety from a checklist into a competitive asset.
This engine should function as a formal governance body, an Agentic AI Ethics Council, comprising a cross-functional alliance of UX, Product, and Engineering, with vital support from Legal, Compliance, and Support. In smaller organizations, these ‘Council’ roles often collapse into a single triad of Product, Engineering, and Design leads.
This governance structure should maintain a set of living documents, including an Agent Risk Register that proactively identifies potential failure modes, Action Audit Logs that are regularly reviewed, and the formal Autonomy Policy Documentation.
For product managers and executives, integrating agentic AI can feel like a monumental task. The key is to approach it not as a single launch, but as a phased journey of building both technical capability and user trust in parallel. This roadmap allows your organization to learn and adapt, ensuring each step is built on a solid foundation.
The initial goal is to build the bedrock of trust without taking significant autonomous risks. In this phase, the agent’s power is limited to analysis and suggestion.
Once users are comfortable with the agent’s proposals, you can begin to introduce low-risk autonomy. This phase is about teaching users how the agent thinks and letting them set their own pace.
This is the final step, taken only after you have clear data from the previous phases demonstrating that users trust the system.
The emergence of agentic AI represents a new frontier in human-computer interaction. It promises a future where technology can proactively reduce our burdens and streamline our lives. But this power comes with profound responsibility.
“
As UX professionals, product managers, and leaders, our role is to act as the stewards of that trust. By implementing clear design patterns for control and consent, designing thoughtful pathways for repair, and building robust governance frameworks, we create the essential safety levers that make agentic AI viable. We are not just designing interfaces; we are architecting relationships. The future of AI’s utility and acceptance rests on our ability to design these complex systems with wisdom, foresight, and a deep-seated respect for the user’s ultimate authority.
What makes streaks so powerful and addictive? To design them well, you need to understand how they align with human psychology. Victor Ayomipo breaks down the UX and design principles behind effective
Ux
I’m sure you’ve heard of streaks or used an app with one. But ever wondered why streaks are so popular and powerful? Well, there is the obvious one that apps want as much of your attention as possible, but aside from that, did you know that when the popular learning app Duolingo introduced iOS widgets to display streaks, user commitment surged by 60%. Sixty percent is a massive shift in behaviour and demonstrates how “streak” patterns can be used to increase engagement and drive usage.
At its most basic, a streak is the number of consecutive days that a user completes a specific activity. Some people also define it as a “gamified” habit or a metric designed to encourage consistent usage.
But streaks transcend beyond being a metric or a record in an app; it is more psychological than that. Human instincts are easy to influence with the right factors. Look at these three factors: progress, pride, and fear of missing out (commonly called FOMO). What do all these have in common? Effort. The more effort you put into something, the more it shapes your identity, and that is how streaks crosses into the world of behavioural psychology.
Now, with great power comes great responsibility, and because of that, there’s a dark side to streaks.
In this article, we’ll be going into the psychology, UX, and design principles behind building an effective streak system. We’ll look at (1) why our brains almost instinctively respond to streak activity, (2) how to design streaks in ways that genuinely help users, and (3) the technical work involved in building a streak pattern.
To design and build an effective streak system, we need to understand how it aligns with how our brains are wired. Like, what makes it so effective to the extent that we feel so much intense dedication to protect our streaks?
There are three interesting, well-documented psychology principles that support what makes streaks so powerful and addictive.
This is probably the strongest force behind streaks. I say this because most times, you almost can’t avoid this in life.
Think of it this way: If a friend gives you $100, you’d be happy. But if you lost $100 from your wallet, that would hurt way more. The emotional weight of those situations isn’t equal. Loss hurts way more than gain feels good.
Let’s take it further and say that I give you $100 and ask you to play a gamble. There’s a 50% chance you win another $100 and a 50% chance you lose the original $100. Would you take it? I wouldn’t. Most people wouldn’t. That’s loss aversion.
If you think about it, it is logical, it is understandable, it is human.
The concept behind loss aversion is that we feel the pain of losing something twice as much as the pleasure of gaining something of equal value. In psychological terms, loss lingers more than gains do.
You probably see how this relates to streaks. To build a noticeable streak, it requires effort; as a streak grows, the motivation behind it begins to fade; or more accurately, it starts to become secondary.
Here’s an example: Say your friend has a three-day streak closing their “Move Rings” on their Apple Watch. They have almost nothing to lose beyond wanting to achieve their goal and be consistent. At the same time, you have an impressive 219-day streak going. Chances are that you are trapped by the fear of losing it. You most likely aren’t thinking about the achievement at this point; it’s more about protecting your invested effort, and that is loss aversion.
Duolingo explains how loss aversion contributes to a user’s reluctance to break a long streak, even on their laziest days. In a way, a streak can turn into a habit when loss aversion settles in.
Now that we understand the fear of losing the effort invested in longer streaks, another question is: What makes us do the thing in the first place, day after day, even before the streak gets big?
That’s what the Fogg Behaviour Model is about. It is relatively simple. A behaviour (B) only occurs when three factors — Motivation (M), Ability (A), and Prompt (P) — align at the same moment. Thus, the equation B=MAP.
If any of these factors, even one, is missing at that moment, the behaviour won’t happen.
So, for a streak system to be efficient and recurring, all three factors must be present:
Motivation
This is fragile and not something that is consistently present. There are days when you’re pumped to learn Spanish, and days you don’t even feel an iota of willpower to learn the language. Motivation by itself to build a habit is unreliable and a losing battle from day one.
Ability
To compensate for the limitations of motivation, ability is critical. In this context, ability means the ease of action, i.e, the effort is so easy that it’s unrealistic to say it isn’t possible. Most apps intentionally use this. Apple Fitness just needs you to stand for one minute in an hour to earn a tick towards your Stand goal. Duolingo only needs one completed lesson. These tasks do not require all that much effort. The barrier is so low that even on your worst days, you can do it. But the combined effort of an ongoing streak is where the idea of losing that streak kicks in.
Prompt
This is what completes the equation. Humans are naturally forgetful, so yes, ability can get us 90% there. But a prompt reminds us to act. Streaks are persistent by design, so users need to be constantly reminded to act. To see how powerful a prompt can be, Duolingo did an A/B test to see if a little red badge on the app’s icon increased consistent usage. It produced a 6% increase in daily active users. Just a red badge.
All this being said, there is a limitation to the Fogg model whereby critics and modern research have noticed that a design that relies too heavily on prompts, like aggressive notifications, risks creating mental fatigue. Constant notifications and overtime could cause users to churn. So, watch out for that.
How do you feel when you leave a task of project half-done? That irritates many people because unfinished tasks occupy more mental space than the things we complete. When something is done and gone, we tend to forget it. When something is left undone, it tends to weigh on our minds.
This is exactly why digital products use artificial progress indicators, like Upwork’s profile completion bar, to let a user know that their profile is only “60% complete”. It nudges the user to finish what they started.
Let’s look at another example. You have five tasks in a to-do list app, and at the end of the day, you only check four of them as completed. Many of us will feel unaccomplished because of that one unfinished task. That, right there, is the Zeigarnik effect.
The Zeigarnik effecthe was demonstrated by psychologist Bluma Zeigarnik, who described that we tend to keep incomplete tasks active in our memory longer than completed tasks.
A streak pattern naturally taps into this in UX design. Let’s say you are on day 63 of a learning streak. At that point, you’re in an ongoing pattern of unfinished business. Your brain would rarely forget about it as it sits in the back of your mind. At this point, your brain becomes the one sending you notifications.
When you put these psychological forces together, you begin to truly understand why streaks aren’t just a regular app feature; they are capable of reshaping human behaviour.
But somewhere along the line — I can’t say exactly when, as it differs for everyone — things reach a point where a streak shifts from “fun” to something you feel you can’t afford to lose. You don’t want 58 days of effort to go to waste, do you? That is what makes a streak system effective. If done right, streaks help users build astounding habits that accomplish a goal. It could be reading daily or hitting the gym consistently.
These repeated actions (sometimes small) compound over time and become evident in our daily lives. But there are two sides to every coin.
If you have been following along, you can already tell there’s a dark side to streak systems. Habit formation is about consistency with a repeated goal. Compulsion, however, is the consistency of working on a goal that is no longer needed but held onto out of fear or pressure. It is a razor-thin line.
You brush your teeth every morning without thinking; it is automatic and instinctive, with a clear goal of having good breath. That’s a streak that forms a good habit. An ethical streak system gives users space to breathe. If, for some reason, you don’t brush in the morning, you can brush at noon. Imperfection is allowed without fear of losing a long effort.
Compulsion takes the opposite route, whereby a streak makes you anxious, you feel guilty or even exhausted, and sometimes, it feels like you haven’t accomplished anything, despite all your work. You act not because you want to, but because you’re subconsciously terrified of seeing your progress reset to zero.
Someone even described this perfectly, “I felt that I was cheating, but simply did not care. I am nothing without my streak”. This shows the extreme hold streaks can have on an individual. To the extent that users begin to tie their self-worth to an arbitrary metric rather than the original goal or reason they started the streak in the first place. The streak becomes who they are, not just what they do.
A well-designed ethical streak system should feel like encouragement to the user, not pressure or obligation. This relates to the balance of intrinsic and extrinsic motivation. Extrinsic motivation (external rewards, avoiding punishment) might get users started, but intrinsic motivation (doing the task for a personal goal like learning Spanish because you genuinely want to communicate with a loved one) is stronger for long-term engagement.
A good system should gravitate towards intrinsic motivation with careful use of extrinsic elements, i.e., remind users of how far they have come, not threaten them with what they might lose. Again, it is a fine line.
A simple test when designing a streak system is to actually take some time and think whether your products make money by selling solutions to anxiety that your product created. If yes, there’s a high chance you are exploiting users.
So the next question becomes, If I choose to use streak, how do I design it in a way that genuinely helps users achieve their goals?
I believe this is where most projects either nail an effective streak system or completely mess it up. Let’s go through some UX principles of a good streak design.
You’ve probably heard this before, maybe from books like Atomic Habits, but it’s worth mentioning that one of the easiest ways habits can be formed is by making the action tiny and easy. This is similar to the ability factor we discussed from the Fogg Behaviour Model.
“
If a daily action requires willpower to complete, that action won’t make it past five days. Why? You can’t be motivated five days in a row.
Case in point: If you run a meditation app, you don’t need to make users go through a 20-minute session just to maintain the streak. Try a single minute, maybe even something as small as thirty seconds, instead.
As the saying goes, little drops of water make the mighty ocean. Small efforts compile into big achievements with time. That should be the goal: remove friction, especially when the moment might be difficult. When users are stressed or overwhelmed, let them know that simply showing up, even for a few seconds, counts as effort.
Humans are visual by nature. Most times, we need to see something to believe; there’s this need to visualize things to understand them better and put things into perspective.
This is why streak patterns often use visual elements, like graphs, checkmarks, progress rings, and grids, to visualize effort. Look at GitHub’s contribution graph. It is a simple visualization of consistency. Yet developers breathe it in like oxygen.
The key is not to make a streak system feel abstract. It should feel real and earned. For instance, Duolingo and Apple’s Fitness activity rings use clean animation designs on completion of a streak, and GitHub shows historical data of a user’s consistency over time.
I mentioned earlierthat humans are generally forgetful by nature, and that prompts can help maintain forward momentum. Without prompts, most new users forget to keep going. Life can get busy, motivation disappears, and things happen. Even long-time users benefit from prompts, though most times, they are already locked inside the habit loop. Nevertheless, even the most committed person can accidentally miss a day.
Your streak system most definitely needs reminders. The most-used prompt reminders are push notifications. Timing really matters when working with push notifications. The type of app matters, too. Sending a notification at 9 a.m. saying “You haven’t practiced today” is just weird for a learning app because many have things to do in the day before they even think about completing a lesson. If we’re talking about a fitness app, though, it is reasonable and maybe even expected to be reminded earlier in the day.
Push notifications vary significantly by app category. Fitness apps, for instance, see higher engagement with early morning notifications (7–8 AM), while productivity apps might perform better in early noon. The key is to A/B test your app’s timing based on your users’ behaviours rather than assuming things are one-size-fits-all. What works for a meditation app might not work for a coding tracker.
Other prompt methods are red dots on the app icon and even app widgets. Studies vary, but the average person unlocks their device between 50-150 times a day (PDF). If a user sees a red dot on an app or a widget that indicates a current streak every time they unlock their phone, it increases commitment.
Just don’t overdo it; the prompt should serve as a reminder, not a nag.
A streak system should try to celebrate milestones to reignite emotions, especially for users deep into a streak.
When a user hits Day 7, Day 30, Day 50, Day 100, Day 365, you should make a big deal out of it. Acknowledge achievements — especially for long-time users.
As we saw earlier, Duolingo figured this out and implemented an animated graphic that celebrates milestones with confetti. Some platforms even give substantial bonus rewards that validate users’ efforts. And this can be beneficial to apps, such that users tend to share their milestones publicly on social media.
Another benefit is the anticipation that comes before reaching milestones. It isn’t just keeping the streak alive endlessly; users have something to look forward to.
Life is unpredictable. People get distracted. Any good streak system should expect imperfection. One of the biggest psychological threats to a streak system is the hard reset to zero after just a single missed day.
An “ethical” streak system should provide the user with some slack. Let’s say you have a 90-day chess learning streak. You have been consistent for three good months, and one day, your phone dies while traveling, and just like that, 90 becomes 0 — everything, all that effort, is erased, and progress vanishes. The user might be completely devastated. The thought of rebuilding it from scratch is so demoralizing that the effort isn’t worth it. At worst, a user might abandon the app after feeling like a failure.
Consider adding a “grace” mechanism to your streak system:
Let’s compare two messages shown to users when a streak breaks:
Both convey the same information, but the emotional impact is different. The first message would most likely make a user feel demoralized and cause them to quit. The second message celebrates what has already been achieved and gently encourages the user to try again.
Before we go into the technical specifics of building a streak system, you should be aware of the challenges that you might face. Things can get complicated, as you might expect.
There is a reason why handling time and date is among the most difficult concepts developers deal with. There’s formatting, internationalization, and much more to consider.
Let me ask you this: What counts as a day?
We know the world runs on different time zones, and as if that is not enough, some regions have Daylight Saving Time (DST) that happens twice a year. Where do you even begin handling these edge cases? What counts as the “start” of tomorrow?
Some developers try to avoid this by using one central timezone, like UTC. For some users, this would yield correct results, but for some, it could be off by an hour, two hours, or more. This inconsistency ruins the user experience. Users care less how you handle the time behind the scenes; all they expect is that if they perform a streak action at 11:40 p.m., then it should register at that exact time, in their context. You should define “one day” based on the user’s local timezone, not the server time.
Sure, you can take the easy route and reset streaks globally for all users at midnight UTC, but you are very much creating unfairness. Someone in California always has eight extra hours to complete their task than someone living in London. That’s an unjust design flaw that punishes certain users because of their location. And what if that person in London is only visiting, completes a task, then returns to another timezone?
One effective solution to all these is to ask users to explicitly set their timezone during onboarding (preferably after first authentication). It’s a good idea to include a subtle note that providing timezone information is only used for the app to accurately track progress, rather than being used as personally identifiable data. And it’s another good idea to make that a changeable setting.
I suggest that anyone avoid directly handling timezone logic in an app. Use tried-and-true date libraries, like Moment.js or pytz (Python), etc. There’s no need to reinvent the wheel for something as complex as this.
Another challenge you should worry about is uncontrollable edge cases like users oversleeping, server downtime, lag, network failures, and so on. Using the idea of grace mechanisms, like the ones we discussed earlier, can help.
A grace window of two hours might help both user and developer, in the sense that users are not rigidly punished for uncontrollable life circumstances. For developers, grace windows are helpful in those uncontrollable moments when the server goes down in the middle of the night.
Above all, never trust the client. Always validate on the server-side. The server should be the single source of truth.
Again, I cannot stress this enough: Make sure to validate everything server-side. Users are humans, and humans might cheat if given the opportunity. It is unavoidable.
You might try:
This isn’t a code tutorial, so I will avoid dumping a bunch of code on you. I’ll keep this practical and describe how things generally operate a streak system engine as far as architecture, flow, and reliability.
As I’ve said several times, make the serverthe single source of truth for streak data. The architecture can go something like this on the server:
Meanwhile, on the client-side:
So, in short, the brain is on the server, and the client is for display purposes and submitting events. This saves you a lot of failures and edge cases, plus makes updates and fixes easier.
Let’s simulate a walkthrough of how a minimal efficient streak system engine would go when a user completes an action:
{ "current_streak": 48, "longest_streak": 50, "total_active_days": 120, "streak_extended": true, }
As a further measure, the server should either retry or reject and notify the client when anything fails during the process.
As mentioned before, users losing a streak due to bugs or server downtime is terrible UX, and users don’t expect to take the fall for it. Thus, your streak system should have safeguards for those scenarios.
If the server is down for maintenance (or whatever reason), consider allowing a temporary window of additional hours to get it fixed so actions can be submitted late and still count. You can also choose to notify users, especially if the situation is capable of affecting an ongoing streak.
Note: Establish an admin backdoor where data can be manually restored. Bugs are inevitable, and some users would call your app out or reach out to support that their streak broke for a reason they could not control. You should be able to manually restore the streaks if, after investigation, the user is right.
One thing remains clear: Streaks are really powerful because of how human psychology works on a fundamental level.
The best streak system out there is the one that users don’t think about consciously. It has become a routine of immediate results or visible progress, like brushing teeth, which becomes a regular habit.
And I’m just gonna say it: Not all products need a streak system. Should you really force consistency just because you want daily active users? The answer may very well be “no”.
What do you do when you need to convert plain text into formatted HTML? Perhaps you reach for Markdown or manually write in the element tags yourself. Or maybe you have one or two of the dozens of onl
Javascript
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.
Designing for mental health means designing for vulnerability. Empathy-Centred UX becomes not a “nice to have” but a fundamental design requirement. Here’s a practical framework for building tru
Ux
Imagine a user opening a mental health app while feeling overwhelmed with anxiety. The very first thing they encounter is a screen with a bright, clashing colour scheme, followed by a notification shaming them for breaking a 5-day “mindfulness streak,” and a paywall blocking the meditation they desperately need at that very moment. This experience isn’t just poor design; it can be actively harmful. It betrays the user’s vulnerability and erodes the very trust the app aims to build.
When designing for mental health, this becomes both a critical challenge and a valuable opportunity. Unlike a utility or entertainment app, the user’s emotional state cannot be treated as a secondary context. It is the environment your product operates in.
With over a billion people living with mental health conditions and persistent gaps in access to care, safe and evidence-aligned digital support is increasingly relevant. The margin for error is negligible. Empathy-Centred UX becomes not a “nice to have” but a fundamental design requirement. It is an approach that moves beyond mere functionality to deeply understand, respect, and design for the user’s intimate emotional and psychological needs.
But how do we translate this principle into practice? How do we build digital products that are not just useful, but truly trustworthy?
Throughout my career as a product designer, I’ve found that trust is built by consistently meeting the user’s emotional needs at every stage of their journey. In this article, I will translate these insights into a hands-on empathy-centred UX framework. We will move beyond theory to dive deeper into applicable tools that help create experiences that are both humane and highly effective.
In this article, I’ll share a practical, repeatable framework built around three pillars:
Together, these pillars offer a grounded way to design mental health experiences that prioritise trust, emotional safety, and real user needs at every step.
Onboarding is “a first date” between a user and the app — and the first impression carries immense stakes, determining whether the user decides to continue engaging with the app. In mental health tech, with up to 20,000 mental-health-related apps on the market, product designers face a dilemma of how to integrate onboarding’s primary goals without making the design feel too clinical or dismissive for a user seeking help.
The Empathy Tool
In my experience, I have found it essential to design onboarding as the first supportive conversation. The goal is to help the user feel seen and understood by delivering a small dose of relief quickly, not just overload them with data and the app’s features.
At Teeni, an app for parents of teenagers, onboarding requires an approach that solves two problems: (1) acknowledge the emotional load of parenting teens and show how the app can share that load; (2) collect just enough information to make the first feed relevant.
Interviews surfaced a recurring feeling among parents: “I’m a bad parent, I’ve failed at everything.” My design idea was to provide early relief and normalisation through a city-at-night metaphor with lit windows: directly after the welcome page, a user engages with three brief, animated and optional stories based on frequent challenges of teenage parenting, in which they can recognise themselves (e.g., a story of a mother learning to manage her reaction to her teen rolling their eyes). This narrative approach reassures parents that they are not alone in their struggles, normalising and helping them cope with stress and other complex emotions from the very beginning.
Note: Early usability sessions indicated strong emotional resonance, but post-launch analytics showed that the optionality of the storytelling must be explicit. The goal is to balance the storytelling to avoid overwhelming the distressed parent, directly acknowledging their reality: “Parenting is tough. You’re not alone.”
To tailor guidance to each family, we defined the minimal data needed for personalisation. On the first run, we collect only the essentials for a basic setup (e.g., parent role, number of teens, and each teen’s age). Additional, yet still important, details (specific challenges, wishes, requests) are gathered gradually as users progress through the app, avoiding long forms for those who need support immediately.
The entire onboarding is centred around a consistently supportive choice of words, turning a typically highly practical, functional process into a way to connect with the vulnerable user on a deeper emotional level, while keeping an explicit fast path.
This initial conversation sets the stage for trust. But this trust is fragile. The next step is to ensure the app’s very environment doesn’t break it.
A user experiencing anxiety or depression often shows reduced cognitive capacity, which affects their attention span and the speed with which they process information and lowers tolerance for dense layouts and fast, highly stimulating visuals. This means that high-saturation palettes, abrupt contrast changes, flashing, and dense text can feel overwhelming for them.
The Empathy Tool
When designing a user flow for a mental health app, I always apply the Web Content Accessibility Guidelines 2.2 as a foundational baseline. On top of that, I choose a “low-stimulus”, “familiar and safe” visual language to minimise the user’s cognitive load and create a calm, predictable, and personalised environment. Where appropriate, I add subtle, opt-in haptics and gentle micro-interactions for sensory grounding, and offer voice features as an option in high-stress moments (alongside low-effort tap flows) to enhance accessibility.
Imagine you need to guide your users “by the hand”: we want to make sure their experience is as effortless as possible, and they are quickly guided to the support they need, so we avoid complicated forms and long wordings.
For the app focused on instant stress relief, Bear Room, I tested a “cosy room” design. My initial hypothesis was validated through a critical series of user interviews: the prevailing design language of many mental health apps appeared misaligned with the needs of our audience. Participants grappling with conditions such as PTSD and depression repeatedly described competing apps as “too bright, too happy, and too overwhelming,” which only intensified their sense of alienation instead of providing solace. This suggested a mismatch for our segment, which instead sought a sense of safety in the digital environment.
This feedback informed a low-arousal design strategy. Rather than treating “safe space” as a visual theme, we approached it as a holistic sensory experience. The resulting interface is a direct antithesis to digital overload; it gently guides the user through the flow, keeping in mind that they are likely in a state where they lack the capacity to concentrate. The text is divided into smaller parts and is easily scannable and quickly defined. The emotional support tools — such as a pillow — are highlighted on purpose for convenience.
The interface employs a carefully curated, non-neon, earthy palette that feels grounding rather than stimulating, and it rigorously eliminates any sudden animations or jarring bright alerts that could trigger a stress response. This deliberate calmness is not an aesthetic afterthought but the app’s most critical feature, establishing a foundational sense of digital safety.
To foster a sense of personal connection and psychological ownership, the room introduces three opt-in “personal objects”: Mirror, Letter, and Frame. Each invites a small, successful act of contribution (e.g., leaving a short message to one’s future self or curating a set of personally meaningful photos), drawing on the IKEA effect (PDF).
For instance, Frame functions as a personal archive of comforting photo albums that users can revisit when they need warmth or reassurance. Because Frame is represented in the digital room as a picture frame on the wall, I designed an optional layer of customisation to deepen this connection: users can replace the placeholder with an image from their collection — a loved one, a pet, or a favourite landscape — displayed in the room each time they open the app. This choice is voluntary, lightweight, and reversible, intended to help the space feel more “mine” and deepen attachment without increasing cognitive load.
Note: Always adapt to the context. Try to avoid making the colour palette too pastel. It is useful to balance the brightness based on the user research, to protect the right level of the app’s contrast.
In Food for Mood, I used a visual metaphor: coloured bubbles representing goals and emotional states (e.g., a dense red bubble for “Performance”). This allows users to externalise and visualise complex feelings without the cognitive burden of finding the right words. It’s a UI that speaks the language of emotion directly.
In an informal field test with young professionals (the target audience) in a co-working space, participants tried three interactive prototypes and rated each on simplicity and enjoyment. The standard card layout scored higher on simplicity, but the bubble carousel scored better on engagement and positive affect — and became the preferred option for the first iteration. Given that the simplicity trade-off was minimal (4⁄5 vs. 5⁄5) and limited to the first few seconds of use, I prioritised the concept that made the experience feel more emotionally rewarding.
Adding a touch of tactile micro-interactions like bubble-wrap popping in Bear Room, may also offer users moments of kinetic relief. Integrating deliberate, tactile micro-interactions, such as the satisfying bubble-wrap popping mechanic, provides a focused act that can help an overwhelmed user feel more grounded. It offers a moment of pure, sensory distraction for a person stuck in a torrent of stressful thoughts. This isn’t about gamification in the traditional, points-driven sense; it’s about offering a controlled, sensory interruption to the cycle of anxiety.
Note: Make tactile effects opt-in and predictable. Unexpected sensory feedback can increase arousal rather than reduce it for some users.
When a user is in a state of high anxiety or depression, it can become an extra effort for them to type something in the app or make choices. In moments when attention is impaired, and a simple, low-cognitive choice (e.g., ≤4 clearly labelled options) isn’t enough, voice input can offer a lower-friction way to engage and communicate empathy.
In both Teeni and Bear Room, voice was integrated as a primary path for flows related to fatigue, emotional overwhelm, and acute stress — always alongside a text input alternative. Simply putting feelings into words (affect labelling) has been shown to reduce emotional intensity for some users, and spoken input also provides a richer context for tailoring support.
For Bear Room, we give users a choice to share what’s on their mind via a prominent mic button (with text input available below. The app then analyses their response with AI (does not diagnose) and provides a set of tailored practices to help them cope. This approach gives users a space for the raw, unfiltered expression of emotion when texting feels too heavy.
Similarly, Teeni’s “Hot flow” lets parents vent frustration and describe a difficult trigger via voice. Based on the case description, AI gives a one-screen piece of psychoeducational content, and in a few steps, the app suggests an appropriate calming tool, uniting both emotional and relational support.
“
Note: Mental-health topics are highly sensitive, and many people feel uncomfortable sharing sensitive data with an app — especially amid frequent news about data breaches and data being sold to third parties. Before recording, show a concise notice that explains how audio is processed, where it’s processed, how long it’s stored, and that it is not sold or shared with third parties. Present this in a clear, consent step (e.g., GDPR-style). For products handling personal data, it’s also best practice to provide an obvious “Delete all data” option.
A safe interface builds trust in the moment. The final pillar is about earning the trust that brings users back, day after day.
Encouraging consistent use without manipulation often requires innovative solutions in mental health. The app, as a business, faces an ethical dilemma: its mission is to prioritise user wellbeing, which means it cannot indulge users simply to maximise their screen time. Streaks, points, and time limits can also induce anxiety and shame, negatively affecting the user’s mental health. The goal is not to maximise screen time, but to foster a supportive rhythm of use that aligns with the non-linear journey of mental health.
The Empathy Tool
I replace anxiety-inducing gamification with retention engines powered by empathy. This involves designing loops that intrinsically motivate users through three core pillars: granting them agency with customisable tools, connecting them to a supportive community, and ensuring the app itself acts as a consistent source of support, making return visits feel like a choice, not a chore or pressure.
In search of reimagining retention mechanics away from punitive streaks and towards a model of compassionate encouragement, the Bear Room team came up with the idea of the so-called “Key” economy. Unlike a streak that shames users for missing a day, users are envisioned to earn “keys” for logging in every third day — a rhythm that acknowledges the non-linear nature of healing and reduces the pressure of daily performance. Keys never gate SOS sets or essential coping practices. Keys only unlock more objects and advanced content; the core toolkit is always free. The app should also preserve users’ progress regardless of their level of engagement.
The system’s most empathetic innovation, however, lies in the ability for users to gift their hard-earned keys to others in the community who may be in greater need (still in the process of making). This intends to transform the act of retention from a self-focused chore into a generous, community-building gesture.
It aims to foster a culture of mutual support, where consistent engagement is not about maintaining a personal score, but about accumulating the capacity to help others.
Why it Works
Within Bear Room, users can write and receive supportive letters anonymously to other users around the world. This tool leverages AI-powered anonymity to create a safe space for radical vulnerability. It provides a real human connection while completely protecting user privacy, directly addressing the trust deficit. It shows users they are not alone in their struggles, a powerful retention driver.
Note: Data privacy is always a priority in product design, but (again) it’s crucial to approach it firsthand in mental health. In the case of the letter exchange, robust anonymity isn’t just a setting; it is the foundational element that creates the safety required for users to be vulnerable and supportive with strangers.
The “Teenager Translator” in Teeni became a cornerstone of our retention strategy by directly addressing the moment of crisis where parents were most likely to disengage. When a parent inputs their adolescent’s angry words like “What’s wrong with you? It’s my phone, I will watch what I want, just leave me alone!”, the tool instantly provides an empathetic translation of the emotional subtext, a de-escalation guide, and a practical script for how to respond.
This immediate, actionable support at the peak of frustration transforms the app from a passive resource into an indispensable crisis-management tool. By delivering profound value exactly when and where users need it most, it creates powerful positive reinforcement that builds habit and loyalty, ensuring parents return to the app not just to learn, but to actively navigate their most challenging moments.
Let’s return to the overwhelmed user from the introduction. They open an app that greets them with a tested, audience-aligned visual language, a validating first message, and a retention system that supports rather than punishes.
This is the power of an Empathy-Centred UX Framework. It forces us to move beyond pixels and workflows to the heart of the user experience: emotional safety. But to embed this philosophy in design processes, we need a structured, scalable approach. My designer path led me to the following three core pillars:
While we, as designers, don’t directly define the app’s success metrics, we cannot deny that our work influences the final outcomes. This is where our practical tools in mental health apps may come in partnership with the product owner’s goals. All the tools are designed based on hypotheses, evaluations of whether users need them, further testing, and metric analysis.
I would argue that one of the most critical success components for a mental health app is trust. Although it is not easy to measure, our role as designers lies precisely in creating a UX Framework that respects and listens to its users and makes the app fully accessible and inclusive.
The trick is to achieve a sustainable balance between helping users reach their wellness goals and the gaming effect, so they also benefit from the process and atmosphere. It is a blend of enjoyment from the process and fulfillment from the health benefits, where we want to make a routine meditation exercise something pleasant. Our role as product designers is to always keep in mind that the end goal for the user is to achieve a positive psychological effect, not to remain in a perpetual gaming loop.
Of course, we need to keep in mind that the more responsibility the app takes for its users’ health, the more requirements there arise for its design.
When this balance is struck, the result is more than just better metrics; it’s a profound positive impact on your users’ lives. In the end, empowering a user’s well-being is the highest achievement our craft can aspire to.
We love client-side rendering for the way it relieves the server of taxing operations, but serving an empty HTML page often leads to taxing user experiences during the initial page load. We love serve
Javascript
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.
A storm is brewing. Between the pitchblack view of the ground and the gloomy clouds above, this does not look good. But if you have ever heard of ‘every cloud has a silver lining’, this is the per
FreebiesA storm is brewing. Between the pitchblack view of the ground and the gloomy clouds above, this does not look good. But if you have ever heard of ‘every cloud has a silver lining’, this is the perfect illustration for it. Feeling down after a bad day? Maybe this wallpaper can help you up and bring some hope back into your life.
Get inspired for the rest of the week ahead.
This wallpaper is courtesy of Rishabh Agarwal, an avid photographer from India. He has a website dedicated to his love of photography at Rish Photography [http://rishabhagarwal.com]. If you are interested in his photographs, please contact him at his website.
If you would like to see your own beautiful artwork or photographs turned into wallpapers and shared amongst our readers like what we are doing here, drop us a line and we’ll see what we can do.
The post Freebie Release: Silver Lining Wallpaper appeared first on Hongkiat.
Gaining buy-in for accessibility can be challenging due to common myths and misunderstandings. For many, accessibility remains a big mystery. Here are some practical techniques for winning stakeholder
Accessibility
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.
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.
Application frameworks have built whole ecosystems on top of them. Let’s take a closer look at serverless platforms such as Netlify’s Platform Primitives and explore how they can increase our prod
Javascript
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!
WCAG provides guidance for making interactive elements more accessible by specifying minimum size requirements. In fact, the requirements are documented in two Success Criteria: 2.5.5 and 2.5.8. Despi
Accessibility
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
“
To sum things up:
And, perhaps most importantly, all of this is about people and enabling them to get what they need.