Dyslextiskt Kebabstuk

Amerikanska

How To Make Your Design System AI-Ready

Smashingmagazine - ons, 06/03/2026 - 15:00

AI-generated prototypes often don't deliver consistently decent results because of tiny inconsistencies scattered all across a design system. I's decisions made but not documented, hard-coded values never cleaned up, or relying too much on AI making sense of mock-ups or design flows on its own.

Yesterday I stumbled upon a useful practical guide by Hardik Pandya from Atlassian — on how to reduce drifts, minimize mistakes, maintain context, and improve the quality of AI-generated prototypes. Let’s see how it works.

1. Design Decisions Are Infrastructure

Unsurprisingly, better AI prototypes come from better data — but also from better human guidance. We shouldn’t assume that AI knows how to choose the right component and how to design with accessibility in mind. It needs priorities, a clear path on how we make decisions, design principles, examples, do’s and don’ts.

In fact, we should treat design decisions as infrastructure. That means that every time we make a decision — not just a design decision, but even a decision on how to actually prioritize our work and how we make decisions around here — it must find a path into the spec file that is then consumed by AI.

2. Auditing: FigmaLint

One of the useful tools to audit the quality of the design system is FigmaLint. It’s a useful free Figma plugin for auditing tokens, states, accessibility, binding tokens, renaming layers, detecting detached instances, missing interactive states and hard-coded values — and preparing the design documentation.

If you often have to work with vendors and third parties who supply you with their design systems and component libraries, that’s a great helper to have by your side — especially if you want to improve the quality of prototypes, AI-generated code, and AI-written documentation.

3. Three Layers: Spec Files + Token Layer + Auditing

To ensure quality, we establish design principles, guidelines, and rules in the form of “spec files”. It’s structured Markdown files that include spacing rules, color choices, component usage guidelines, priorities, etc. AI is going to read and reuse that spec file every time it’s going to generate a prototype.

Because the spec files are text files, it’s much more cost-effective but also much more accurate, just because we don’t rely on AI recognizing or decoding patterns from mock-ups but get specific guidelines instead. In fact, extending code is often a more effective way than generating code from mock-ups.

The token layer lists and keeps updated all tokens used throughout the design system. AI always chooses from a closed set of named variables instead of inventing plausible values ad hoc.

An audit script catches what AI gets wrong. It scans the prototype and flags every hard-coded value and flags it if necessary. It can be a regular software doing that, with AI waiting for its feedback to come back.

Finally, when a design system ships updates, a sync routine flags which spec files need updating. The goal is to make sure that AI always reads up-to-date, current specs, not the ones written against an outdated version.

4. Examples of AI-Ready Design Systems Wrapping Up

Ultimately, AI cannot magically resolve technical debt or design debt without proper guidance. It relies heavily on clear decisions, established priorities, and well-defined principles.

The more deliberate and precise designers are in guiding AI, the better the overall outcomes will be. This requires not just cleaning up and improving design systems but also maintaining them over time as decisions need to trickle down into Markdown files. We’ll be busy for years to come.

Meet “Design Patterns For AI Interfaces”

Meet Design Patterns For AI Interfaces, Vitaly’s new video course with 100s of real-life examples and UX guidelines to design AI features that people actually use — with a live UX training later this year. Jump to a free preview.

Meet Design Patterns For AI Interfaces, Vitaly’s video course on interface design & UX.

Video + UX Training$ 450.00 $ 799.00 Get Video + UX Training

30 video lessons (10h) + Live UX Training.
100 days money-back-guarantee.

Video only$ 275.00$ 395.00 Get the video course

30 video lessons (10h). Updated yearly.
Also available as a UX Bundle with 3 video courses.

Useful Resources
Kategorier: Amerikanska

June Is For Exploring (2026 Wallpapers Edition)

Smashingmagazine - sön, 05/31/2026 - 10:00

Everyone has a creative spark in them. Some bring their ideas to life with digital tools, others capture the perfect moment with a camera or love to grab pen and paper to create little doodles or pieces of lettering. And even if you don’t think of yourself as particularly creative, who knows? There might be a hidden talent waiting to be discovered!

That’s exactly what our monthly wallpapers series has been all about for over 15 years now. It’s a chance to step away from the everyday and dive into a fun, creative project. And this month is no different!

Talented artists and designers from all over the world have once again put their skills to work, creating unique and inspiring desktop wallpapers to brighten up your screens this June. You’ll find their designs below, along with some favorites from our archives that were just too good to leave out. A huge thank you to everyone who shared their creations with us this month — you’re smashing!

If you too would like to get featured in one of our upcoming wallpapers posts, please don’t hesitate to join in. We can’t wait to see what you’ll come up with! Happy June!

  • You can click on every image to see a larger preview.
  • We respect and carefully consider the ideas and motivation behind each and every artist’s work. This is why we give all artists the full freedom to explore their creativity and express emotions and experience through their works. This is also why the themes of the wallpapers weren’t anyhow influenced by us but rather designed from scratch by the artists themselves.
Drifting Into June

“June marks the beginning of summer and the end of the semester for many colleges. When I think of summer, I think of walks along the park with my family watching ducks swimming in the lake, and swimming in our pool with my siblings. Naturally from there I put them together to create an amusing scene of a duck slowly paddling around in an inner tube.” — Designed by Emma Kim from the United States.

Let The Ocean Influence You

“The ocean covers more than 70% of the surface of the Earth, yet we know barely anything about it. Maybe June can be the month you discover something new about yourself.” — Designed by Ginger IT Solutions from Serbia.

Dancing In The Kitchen

“June is such an iconic summer month, filled with sunshine, hope, and possibilities. When we raise both hands above our heads in celebratory dance, we symbolically release our cares for the day. When we do this dance in the kitchen, we not only release but also celebrate our bodies, our souls, and nourishment we’re about to give ourselves. This digital collage represents the freedom of movement, of dance, of joyful expression, of nourishment, creativity, and hope. May we all dance in the kitchen.” — Designed by Sue Jenkins from the United States.

Fruit & Gingham

“June reminds me of fresh fruit and bright colors, so I decided to watercolor some fruits, and it complimented well on a blue gingham picnic table background!” — Designed by Ella Peplowski from Ringwood, NJ.

Not In The Mood Forecast

Designed by Ricardo Gimenes from Spain.

Ballpark Patches

“June brings in warmer weather, so I designed a baseball-patch-inspired piece to celebrate the start of the season and the nice weather.” — Designed by Madison Evans from Scranton, PA.

A Very Special Amusement Park

“June brings summer, and it’s a great time to travel and experience new adventures! An amusement park is always a good idea, although some are best enjoyed from the comfort of your own home.” — Designed by Veronica Valenzuela from Spain.

Wavy Jellyfish

Designed by Jayden Evans from Scranton, Pennsylvania.

Buzzing Through June

“I first found this color palette and thought it was very springy, and when I think of spring I think of flowers and bees. So I wanted to create a design that incorporated both in a cute way.” — Designed by Caroline Flynn from the United States.

Tiny Paradise Under the Sun

“Summer is hidden in the simple moments — the shimmer of crystal water, the warmth of the sun on your skin, and the calm sound of a peaceful afternoon. The warm wooden deck and soft shade of the parasol create a peaceful corner made for daydreaming. Flowers bloom, lemonade stays cold, and the sunlight dances across the pool like a golden melody. It’s the season of relaxation, happiness, and small moments that feel unforgettable.” — Designed by PopArt Studio from Novi Sad, Serbia.

Patches Of Flowers

“I wanted to make something in Blender, and once I made these flowers I felt like it needed something more, so I added more texture and more colors to make these flowers more peaceful.” — Designed by Caroline Flynn from the United States.

Amsterdam

“Inspired by the upcoming 2027 edition of SmashingConf Amsterdam.” — Designed by Ricardo Gimenes from Spain.

Let Me Grow With You

Designed by James Lucia from Covington Township, Pennsylvania.

Travel Time

“June is our favorite time of the year because the keenly anticipated sunny weather inspires us to travel. Stuck at the airport, waiting for our flight but still excited about wayfaring, we often start dreaming about the new places we are going to visit. Where will you travel to this summer? Wherever you go, we wish you a pleasant journey!” — Designed by PopArt Studio from Serbia.

June Is For Nature

“In this illustration, Earth is planting a little tree — taking care, smiling, doing its part. It’s a reminder that even small acts make a difference. Since World Environment Day falls in June, there’s no better time to give back to the planet.” — Designed by Ginger IT Solutions from Serbia.

Tastes Of June

“A vibrant June wallpaper featuring strawberries and fresh oranges, capturing the essence of early summer with bright colors and seasonal charm.” — Designed by Libra Fire from Serbia.

A Bibliophile’s Shelf

“Some of my favorite things to do are reading and listening to music. I know that there are a lot of people that also enjoy these hobbies, so I thought it would be a perfect thing to represent in my wallpaper.” — Designed by Cecelia Otis from the United States.

Here Comes The Sun

Designed by Ricardo Gimenes from Spain.

Create Your Own Path

“Nice weather has arrived! Clean the dust off your bike and explore your hometown from a different angle! Invite a friend or loved one and share the joy of cycling. Whether you decide to go for a city ride or a ride in nature, the time spent on a bicycle will make you feel free and happy. So don’t wait, take your bike and call your loved one because happiness is greater only when it is shared. Happy World Bike Day!” — Designed by PopArt Studio from Serbia.

Deep Dive

“Summer rains, sunny days, and a whole month to enjoy. Dive deep inside your passions and let them guide you.” — Designed by Ana Masnikosa from Belgrade, Serbia.

Oh, The Places You Will Go!

“In celebration of high school and college graduates ready to make their way in the world!” — Designed by Bri Loesch from the United States.

Merry-Go-Round

Designed by Xenia Latii from Germany.

Join The Wave

“The month of warmth and nice weather is finally here. We found inspiration in the World Oceans Day which occurs on June 8th and celebrates the wave of change worldwide. Join the wave and dive in!” — Designed by PopArt Studio from Serbia.

Summer Surf

“Summer vibes…” — Designed by Antun Hirsman from Croatia.

Summer Party

Designed by Ricardo Gimenes from Spain.

Expand Your Horizons

“It’s summer! Go out, explore, expand your horizons!” — Designed by Dorvan Davoudi from Canada.

Handmade Pony Gone Wild

“This piece was inspired by the My Little Pony cartoon series. Because those ponies irritated me so much as a kid, I always wanted to create a bad-ass pony.” — Designed by Zaheed Manuel from South Africa.

Pineapple Summer Pop

“I love creating fun and feminine illustrations and designs. I was inspired by juicy tropical pineapples to celebrate the start of summer.” — Designed by Brooke Glaser from Honolulu, Hawaii.

All-Seeing Eye

Designed by Ricardo Gimenes from Spain.

Nine Lives

“I grew up with cats around (and drawing them all the time). They are so funny… one moment they are being funny, the next they are reserved. If you have place in your life for a pet, adopt one today!” — Designed by Karen Frolo from the United States.

Summer Coziness

“I’ve waited for this summer more than I waited for any other summer since I was a kid. I dream of watermelon, strawberries, and lots of colors.” — Designed by Kate Jameson from the United States.

Bauhaus

“I created a screenprint of one of the most famous buildings from the Bauhaus architect Mies van der Rohe for you. So, enjoy the Barcelona Pavillon for your June wallpaper.” — Designed by Anne Korfmacher from Germany.

Strawberry Fields

Designed by Nathalie Ouederni from France.

Papa Merman

“Dream away for a little while to a land where June never ends. Imagine the ocean, feel the joy of a happy and carefree life with a scent of shrimps and a sound of waves all year round. Welcome to the world of Papa Merman!” — Designed by GraphicMama from Bulgaria.

Solstice Sunset

“June 21 marks the longest day of the year for the Northern Hemisphere — and sunsets like these will be getting earlier and earlier after that!” — Designed by James Mitchell from the United Kingdom.

Getting Better Everyday

“Inspired by the eternal forward motion to get better and excel.” — Designed by Zachary Johnson-Medland from the United States.

Reef Days

“June brings the start of summer full of bright colors, happy memories, and traveling. What better way to portray the goodness of summer than through an ocean folk art themed wallpaper. This statement wallpaper gives me feelings of summer and I hope to share that same feeling with others.” — Designed by Taylor Davidson from Kentucky.

Ice Creams Away!

“Summer is taking off with some magical ice cream hot air balloons.” — Designed by Sasha Endoh from Canada.

Melting Away

Designed by Ricardo Gimenes from Spain.

Sunset With Crabs

“In the sunset, the crabs come to the surface. That little boat can’t sail, but after seeing the crabs it gets power and finally… it sails!” - Designed by Veronica Valenzuela from Spain.

World Environment Day

“On June 5th, we celebrate World Environment Day — a moment to pause and reflect on how we impact Earth’s health. A few activities represented in this visual include conserving energy and water, shopping and growing local, planting flowers and trees, and building a sustainable infrastructure.” — Designed by Mad Fish Digital from Portland, OR.

Shine Your Light

“Shine your light, Before the fight, Just like the sun, Cause we don’t have to run.” — Designed by Anh Nguyet Tran from Vietnam.

Get Featured Next Month

Feeling inspired? We’ll publish the July wallpapers on June 30, so if you’d like to be part of the collection, please don’t hesitate to submit your design. We are already looking forward to it!

Kategorier: Amerikanska

Algorithmic Theming Engines: Building Self-Correcting Color Systems With `contrast-color()`

Smashingmagazine - tors, 05/28/2026 - 15:00

The HTTP Archive Web Almanac has been tracking color contrast failures for years. The numbers have barely moved. After half a decade of design system tooling, accessibility linters, and entire JavaScript libraries dedicated to computing readable text colors, 70% of websites still fail basic WCAG contrast checks in 2025. The WebAIM Million paints an even grimmer picture — 83.9% of homepages flagged for low contrast text in 2026, up from 79.1% in 2025. The rate improves by maybe a few percentage points per year on one benchmark and actually gets worse on another. That’s not progress — that’s proof that relying on runtime JavaScript for something this fundamental doesn’t scale across the open web. We didn’t need better libraries. We’ve needed better CSS.

The contrast-color() function is that better CSS. One declaration. The browser runs the contrast math during style computation, before the page paints, and hands you the right text color. No library, no build step, no hydration flash.

Note: If you’ve seen it called color-contrast() in older articles and spec drafts — that name was changed, and the old syntax no longer works in any browser.

What It Does (And What It Doesn’t)

The Level 5 version is simple. You give it a color. It gives you back black or white, whichever has more contrast against your input.

.button { background-color: var(--brand-color); color: contrast-color(var(--brand-color)); }

Change --brand-color to neon green, text goes black. Change it to midnight navy, text goes white. Swap themes at runtime via JavaScript and the text adapts instantly — no event listeners, no recalculation.

A few things to know about the current version:

  • It returns a <color>, not a number. You get an actual color value (black or white), you can use anywhere CSS accepts a color.
  • Black or white only, for now. Candidate color lists and target ratios are planned for Level 6.
  • No keywords. If you’ve seen max in older blog posts, that was stripped from the spec. Using it will silently break your declaration.
  • As mentioned above, this function used to be called color-contrast() in early drafts. That name is dead — the CSSWG renamed it to follow the convention that CSS functions are named for what they return. color-mix() returns a color. contrast-color() returns a color. The old color-contrast() name sounded like it returned a contrast ratio (a number like 4.5), which was misleading. Any tutorial from 2021–2023 showing color-contrast() syntax won’t work in current browsers.
The Spec Split: Level 5 Versus Level 6

This function lives across two specifications. That’s unusual and worth understanding.

CSS Color Level 5 defines what browsers ship today. One color in, black or white out. The algorithm is deliberately marked “UA-defined”, meaning the browser decides what math to use internally. Right now, every engine uses WCAG 2.x relative luminance. But that “UA-defined” label isn’t accidental — it’s a planned escape hatch.

You’ll see APCA (Accessible Perceptual Contrast Algorithm) mentioned a lot in this context. APCA models how human eyes actually perceive contrast, factoring in font weight, spatial frequency, and ambient light — a genuine improvement over the WCAG 2.x formula. By not locking “use WCAG 2.x” into the Level 5 spec, browser vendors could swap to APCA later without breaking any existing code. If the spec had shipped with a wcag2() keyword as the default, every site using it would’ve been stuck on the old math permanently.

But APCA’s future is far less certain than the hype suggests. Adrian Roselli’s “WCAG3 Contrast as of April 2026” lays out the current situation clearly: APCA was pulled from the WCAG 3 working draft in mid-2023 after failing to gain enough Working Group support. The WCAG 3 spec currently says the contrast algorithm is “yet to be determined,” and the standard itself may not be finalized until 2030 or later. Roselli also filed a Chromium issue in May 2024 asking for the “Advanced Perceptual Contrast Algorithm” experiment flag to be removed from DevTools entirely, arguing that the implementation is outdated and risks misleading developers into thinking APCA is further along — or more official — than it actually is. That issue is still open.

None of this means APCA is dead. The research behind it is peer-reviewed and substantive, and its creator has noted that colors passing APCA guidelines greatly exceed WCAG 2 minimums in the vast majority of cases. But right now, there is no guarantee APCA will be the algorithm that replaces WCAG 2.x — and that uncertainty matters for contrast-color(). If a different algorithm wins out, or if WCAG 3 adopts something entirely new, the “UA-defined” label means browsers can adapt without breaking your code. It also means the Level 6 features — candidate color lists, target ratios, the tbd-fg/tbd-bg keywords — are all designed around an algorithm that may or may not materialize in its current form.

CSS Color Level 6 adds the extended syntax — candidate color lists and target contrast ratios:

/* Level 6 future syntax — not shipping yet */ color: contrast-color(var(--bg) tbd-bg wcag2(aa), #1a1a2e, #e2e8f0, #fbbf24);

The browser would evaluate each candidate left to right and pick the first that meets the 4.5:1 AA threshold. The tbd-fg and tbd-bg keywords indicate whether the base color is foreground or background, which matters for directional contrast models like APCA. This is all Working Draft territory — doubly so given APCA’s uncertain status. Use the Level 5 version for now.

Browser Support

This one’s in better shape than most new CSS features. All three major engines have shipped it in stable releases: Chrome 147 (April 2026), Firefox 146, and Safari 26.0. It reached Baseline Newly Available status in April 2026. Check caniuse for the full version matrix. All three engines pass the Web Platform Tests for contrast-color(), which means the edge cases (e.g., tie-breaking logic, color space conversion, syntax parsing) behave the same across browsers.

The raw global support percentage on caniuse looks low, but that mostly reflects enterprise browsers and people who never update. If you’re reading this, your browser almost certainly supports it already.

Progressive enhancement is straightforward using @supports:

.card { background: var(--bg); color: #fff; text-shadow: 0 0 4px rgb(0 0 0 / 0.8); } @supports (color: contrast-color(red)) { .card { color: contrast-color(var(--bg)); text-shadow: none; } }

Older browsers get white text with a dark shadow for legibility. Supporting browsers get the native calculation. Nobody sees broken text.

One thing to watch for: automated accessibility scanners (Lighthouse, Axe, etc.) can’t evaluate text-shadow. They only look at the computed color against background-color. So the fallback will still get flagged as a contrast failure in CI/CD pipelines, even if the shadow makes the text perfectly legible to human eyes. If your team runs automated a11y checks, you may need to allowlist that specific rule or add a comment explaining why the flag is a false positive.

A note on PostCSS:

There’s a plugin (@csstools/postcss-contrast-color-function) that evaluates contrast-color() at build time. It works for static colors like contrast-color(#ff0000). But the moment you use a custom property — contrast-color(var(--bg)) — the plugin can’t help because it has no access to runtime values. If your theming is dynamic (which is the whole point of doing this), skip the polyfill and rely on @supports. The Gotchas It Doesn’t Guarantee Perceptual or AAA Compliance

This can trip people up: “I used the contrast function, so my site passes accessibility checks now, right?”

Mathematically? Usually yes. There is a persistent myth that for certain “mid-tone” backgrounds, both black and white fail the standard WCAG 4.5:1 AA ratio. That’s mathematically false. Under the WCAG 2.x relative luminance formula, there is absolutely no background color where both pure black and pure white fail AA. One (or both) will always pass.

Take #2277d3 (a medium blue). It sits right on a mathematical knife-edge where both black and white actually pass AA (both hit roughly 4.58:1). contrast-color() will hand you whichever has the slight mathematical edge.

But here is the actual gotcha: the WCAG 2.x math has known perceptual blind spots. That same #2277d3 with black text mathematically passes AA, but to human eyes, it can be incredibly difficult to read. contrast-color() gives you mathematical compliance, which is great for automated audits, but that doesn’t always equal perceptual accessibility. (This is exactly why APCA exists and why the spec was designed to let browsers swap algorithms later.)

Furthermore, if you’re aiming for the stricter WCAG AAA standard (7.0:1), a true dead zone does exist. For backgrounds with a luminance between roughly 10% and 30%, neither black nor white will hit 7:1. In those cases, contrast-color() can’t save you — it just hands you the “least bad” failing option.

Transitions Snap, Not Fade

If you’re animating a background from white to black on hover:

.btn { background-color: #fff; color: contrast-color(#fff); /* black */ transition: background-color 1s, color 1s; } .btn:hover { background-color: #000; color: contrast-color(#000); /* white */ }

The background fades smoothly over one second. But because the Level 5 output is a discrete value (black or white), the text color can’t be interpolated. It snaps.

And here is the visual gotcha: the snap doesn’t happen halfway through. If you’ve been building themes for a while, you probably have muscle memory from the old Sass days, where we checked if lightness($bg) > 50%. That relied on HSL lightness, where 50% is the geometric midpoint.

But WCAG 2.x relative luminance is a non-linear scale. Under the WCAG formula, the mathematical tipping point — where black and white have identical contrast against the background — actually occurs at approximately 18% relative luminance (specifically ~17.9%).

Because of that, the visual behavior during a white-to-black fade is heavily skewed. The text doesn’t snap in the middle. It stays black for the vast majority of the animation, only snapping to white at the very tail-end of the transition when the background gets extremely dark. It’s a jarring, late hard cut.

You might assume transition-behavior: allow-discrete fixes this. It doesn’t. allow-discrete does not fix the jarring visual experience because it cannot interpolate a binary output; it only shifts the timing of the hard snap to the 50% mark of the animation duration. If you need smooth text color transitions, you’ll have to layer color-mix() or manage the crossfade yourself.

Tie Goes To White

If the background is a perfect middle gray where both black and white produce identical contrast ratios, the spec has a hardcoded tiebreaker: white wins. Not a big deal in practice, but worth knowing if you’re debugging gray palettes and the text isn’t doing what you expect.

Gradients And Images Are Out

The function takes a flat <color> value. You can’t pass it a gradient or a url(). contrast-color(linear-gradient(...)) is a parse error. If your background is a photo or a complex gradient, you still need JavaScript or manually color-pick for overlay text.

Transparent Colors Are Composited First

Pass a semi-transparent color, and the browser blends it against an assumed opaque canvas (usually white) before running the contrast math. It’s not ignoring your alpha channel — it’s compositing it. But the result might surprise you if you expected the function to “see through” to whatever’s actually behind the element.

Windows High Contrast Mode

If a user enables Windows High Contrast, the forced-colors: active media query kicks in and the browser aggressively overwrites author-defined colors. contrast-color() bows out — forced system colors like CanvasText take over completely. You don’t need to write manual media queries to undo your contrast logic; the browser handles the hierarchy.

Combining It With Other Color Functions

Black or white sounds limiting, but once you feed that output into other CSS color functions, you can build an entire component palette off a single custom property.

Brand-Tinted Contrast With Relative Color Syntax

Pure black text on a vibrant card looks fine. Pure white on a coral card can feel flat. What if the contrast text was a very dark or very light tint of the background color instead?

Kevin Hamer explored related territory in his CSS-Tricks piece “Approximating contrast-color() With Other CSS Features”, where he used OKLCH lightness and round() to approximate the black/white switch without contrast-color() — essentially oklch(from <color> round(1.21 - l) 0 0). That’s a polyfill strategy: get the binary light/dark decision working in browsers that don’t support the native function yet. What we’re doing here is different — we start with contrast-color()’s native output and then enrich it by injecting the background’s own hue:

.card { --bg-hue: 260; /* Indigo */ --bg: oklch(0.6 0.1 var(--bg-hue)); background: var(--bg); /* Pull L from the black/white contrast color, but inject subtle chroma and the background's hue */ color: oklch(from contrast-color(var(--bg)) l 0.05 var(--bg-hue)); }

When contrast-color() returns white, l is 1 (full lightness). When it returns black, l is 0. By pulling the background’s hue back in and adding a touch of chroma, you get text that reads as a deep dark indigo or a pale icy indigo instead of generic black/white. Hamer’s approach gives you the black/white decision without browser support; this one takes the decision the browser already made and gives it personality.

Fair warning: By tweaking the lightness and chroma of the black/white output, you can push a borderline contrast ratio into failing territory. Always run your tinted output through an accessibility linter before shipping.

Also worth noting: This example chains two very modern features — contrast-color() and oklch(from ...). If either one isn’t supported, the entire declaration fails silently. Your @supports block needs to test for both:

@supports (color: contrast-color(red)) and (color: oklch(from red l c h)) { /* Safe to use both */ } Softened Contrast With color-mix()

Similar idea, simpler API. Mix the sharp black/white output back into the background to soften it:

.alert { --bg: var(--alert-color); background: var(--bg); /* 80% contrast, 20% background = softer but readable */ color: color-mix(in oklch, contrast-color(var(--bg)) 80%, var(--bg)); /* 40% contrast for a subtle border */ border: 1px solid color-mix(in oklch, contrast-color(var(--bg)) 40%, var(--bg)); }

One custom property driving text, border, and potentially box-shadow or outline. Change --alert-color and the entire component recalculates.

This pattern also works well for ::placeholder text, which is a common pain point in dynamic theming. Placeholder text should be readable but visually softer than the input’s main text — color-mix() with contrast-color() gets you there:

input { --bg: var(--input-bg); background: var(--bg); color: contrast-color(var(--bg)); } input::placeholder { color: color-mix(in oklch, contrast-color(var(--bg)) 50%, var(--bg)); }

50% mix gives you a muted but legible placeholder that adapts automatically to whatever background the input sits on.

Theme-Aware Contrast With light-dark()

For apps that support system light/dark mode:

:root { color-scheme: light dark; --surface: light-dark(#fff, #121212); } .component { background: var(--surface); color: contrast-color(var(--surface)); }

When the operating system switches to dark mode, --surface resolves to #121212, and contrast-color() returns white. No media queries, no JavaScript theme detection. The whole chain resolves natively.

What You Can Remove From Your Bundle

The practical payoff: every one of these libraries existed because CSS couldn’t do contrast math. If you’re only using them for readable-text-color selection, you can pull them out of your runtime entirely:

Library Size What it did chroma-js ~14 kB Color parsing, luminance calc, readable color selection polished ~11 kB readableColor() for styled-components tinycolor2 ~5 kB Hex parsing, WCAG contrast ratio math

You might still need these for generating complex color scales, but the contrast-for-readability use case is now covered natively.

Beyond bundle size, there’s a performance angle that’s easy to overlook. Those JavaScript libraries don’t just cost you network bytes — they run on the main thread. Every time a theme changes or a component mounts with a dynamic background, your JS has to parse the color, compute luminance, decide black or white, and write the result back to the DOM. That’s main-thread work competing with layout, event handlers, and everything else your app is doing. contrast-color() moves all of that into the browser’s native style computation phase — heavily optimized C++ that runs before paint. For apps with lots of themed components, that’s a real difference in responsiveness.

There’s also a subtle bug that goes away: hydration flash. In React or Vue SSR apps, the server renders HTML without JavaScript. The client then hydrates, running JS to calculate contrast and inject the correct text color. For a brief window between initial paint and hydration, the text is either invisible or the wrong color. Moving contrast into CSS eliminates that entirely — the browser resolves the correct color during the initial paint, before JavaScript loads.

What We Used To Do

For context on what this replaces:

Sass era. You’d write a function that checked lightness($bg) > 50% and returned black or white at compile time. Worked for static themes. Completely useless for user-picked colors, CMS palettes, or dark mode, because the output was baked into the CSS file and could never change at runtime.

The variable toggle hack. When CSS custom properties shipped, people got creative. GitHub used a version of this for their issue label picker — splitting colors into --r, --g, --b channels, calculating Rec.709 luminance inside calc(), multiplying by negative infinity, and clamping to 0 or 1. It worked. It was also unreadable, unmaintainable, and would break silently if you got one parenthesis wrong. (Kevin Hamer’s OKLCH-based approximation is the most elegant version of this lineage — cleaner math, better perceptual alignment — but it’s still a workaround for a function that now ships natively.)

contrast-color() replaces all of these approaches with a single function call. And because the spec lets browsers upgrade the underlying algorithm, your code won’t need to change if and when a successor to WCAG 2.x contrast math lands — whether that’s APCA or something else entirely.



That 70% failure rate was never about developers refusing to care about contrast. It was about the distance between caring and shipping — the library, the build step, the runtime calculation, the hydration flash, the one component someone forgot to wire up. Every gap in that chain was a spot where accessibility quietly dropped out.

contrast-color() doesn’t make developers care more. It makes caring cost nothing.

Kategorier: Amerikanska

Your Prototype Is Not Being Honest With Your Users (And Here’s How To Fix It)

Smashingmagazine - mån, 05/25/2026 - 14:00

This article is a sponsored by ProtoPie

There’s a moment in almost every usability session where a participant pauses at the login screen, types something, and glances up: checking whether they’re “doing it right.” That pause is a clear sign. They’ve already clocked that this isn’t a real app, and every data point collected after that moment is filtered through that awareness.

In financial product testing, the problem is sharper. Finance users are trained to notice when something feels off: a balance that doesn’t add up, a field that accepts anything. When a banking prototype skips real authentication, participants don’t just disengage; they stop mid-session to flag it. The team walks away with findings that reflect how users behave in a demonstration, not in a real product.

The fix is narrower than you’d think. Identify the moment where participant trust is established and make that interaction real. In a banking app, that moment is the login.

This tutorial builds it: credentials that validate, a live error state, and a biometric animation that feels native — no code required.

What We’re Building: A Login That Behaves Like A Shipped Product

The login flow, built around Pie Bank, a mobile banking prototype, includes functional text inputs, a masked password field, credential validation, a live error state, and a Face ID animation timed to feel indistinguishable from iOS.

What you’ll need:

  • A login UI from Figma (or any supported design tool)
  • ProtoPie Studio — free to start, everything in this tutorial works on the free plan
  • A Lottie file for the Face ID animation (this one is what we used)
  • The finished Pie Bank prototype file — download it to follow alongside, or use it as a reference after you build
Step 1: Import From Figma Choose Scene, Not Flattened

In Figma, open the ProtoPie plugin with your login frame selected and choose Scene when exporting. Flattened collapses everything into a single image; Scene preserves your layer hierarchy so every element arrives in ProtoPie as a separate, targetable layer.

Before moving on: rename every layer meaningfully. “Input Username” not “Rectangle 14”. You’ll reference these names in formulas: vague names compound into real time lost.

Step 2: Swap Static Fields For Inputs That Actually Accept Text

ProtoPie’s native Input layer accepts real keyboard entry: participants type actual text, not tap a placeholder. Go to Text  → Input, drag an Input layer onto your canvas, and nest it inside your username field group. Match it visually: placeholder text Username, background fill and font to match your design.

Hit preview. Click the field. Type. That’s the prototype starting to behave like an app rather than depicting one.

Rename this layer Input Username, duplicate it, and nest the copy inside your password field group.

Step 3: One Property Change Masks The Password

On the duplicated layer, change placeholder text to Password and set Type to Text Password. ProtoPie handles the masking: no custom logic needed.

Preview both fields: username shows text, password shows dots. It already feels real, and you haven’t written a single condition.

Step 4: Build The Destination Scene Before Wiring Navigation

Add a new scene, even a blank one. The most common sequencing mistake in ProtoPie is trying to wire a navigation response before a destination exists. Create it first.

Step 5: Wire The Button: It Works, But It Still Lets Everyone Through

Select Log In, add a Tap trigger, set response to Jump, target your dashboard scene, transition Slide in from right to left.

Preview and tap. It navigates: but for any input, including nothing. The prototype is still lying. The next two steps are the fix.

Step 6: Add Variables So The Prototype Remembers What Was Typed

At the bottom-left of ProtoPie, add two Text type variables: username and password. Bind each to its input layer with a formula:

input("Input Username").text input("Input Password").text

Enable the debug icons: green overlays will show live variable values as you type. When you see your keystrokes appear in real time, the binding is confirmed.

Step 7: Add A Condition So Only Valid Credentials Get Through

Go back to the Tap trigger on the login button. Add a Condition with two rules, both must be true:

Move the Jump response inside this condition. Wrong credentials, empty fields, wrong format: none get through. Participants now have to actually log in. That single constraint changes the texture of every test session that follows.

Step 8: Build The Error State, The Interaction Most Prototypes Skip

Find your error message layer, rename it Error Text, set initial opacity to 0. Add a second condition (the inverse of the first), and inside it, a Change Property response setting Error Text opacity to 100.

Wrong credentials: error appears. Correct credentials: dashboard. Two outcomes: which is what makes this testable, not just demonstrable.

Step 9: Add the Face ID Animation, The Detail That Makes Testers Ask “Is This Real”

Go to Media, drag a Lottie layer onto canvas, load your Face ID file, and position it off-screen above the iPhone frame. On your Login with Face ID button, add a Tap trigger (rename it Tap Face ID) with four responses in sequence:

  • Move: Lottie container to Y: 60
  • Playback: Seek: time 0s (resets so it always plays from the start)
  • Playback: Play: Lottie file
  • Jump: to dashboard
Step 10: Stagger The Timing, This Is What Makes It Feel Native

Without delays, all four responses fire at once and the scene jumps before the animation plays. Add offsets:

Response Delay Move 0s Seek 0s Play 0.5s Jump 1s

Enable Reset selected scenes on Jump: without it, navigating back leaves the animation stuck at Y: 60.

Preview: tap Face ID, animation drops in, plays, screen transitions. A biometric login indistinguishable from the real thing.

You can download Pie Bank, Chapter 1: Login Flow and explore it freely. A Login This Real Changes What You Can Learn From Your Prototype

When authentication actually works, the error state becomes a genuine research touchpoint: do users understand the message, do they retry, do they reach for Face ID instead? These are questions a faked login can’t answer.

In stakeholder reviews, the flow speaks for itself. In engineering handoff, the interaction panel documents the behavior (conditional logic, variable bindings, timing) so engineers see intent, not interpretation.

This is why FinTech teams invest in login fidelity even when the login isn’t the feature being tested. It’s where participant trust is established. Get it right, and everything downstream produces better signal.

This tutorial is part of the FinTech Prototyping with ProtoPie series on the ProtoPie blog. The series builds Pie Bank from the ground up across four chapters, covering the dashboard, money transfer logic, and camera integration. If this tutorial was useful, the rest of the series goes further.

Kategorier: Amerikanska

Four Levels Of Customer Understanding

Smashingmagazine - fre, 05/22/2026 - 15:00

Many companies think they know fairly well what their users want and need, and how they make their decisions. Yet most of the time these are merely big assumptions and big hunches — with little real evidence to support them. In practice, obvious reasons might be true, but they rarely paint the full picture.

To understand our customers, we must triangulate across four levels of customer understanding by Hannah Shamji. It’s a useful way to think about the underlying reasons for user behavior, hidden motivations, and the complex layers of messy and noisy reality that are often overlooked. Let’s see how it works.

Don’t Ask Users Your Burning Questions

To learn about customers, it might seem reasonable to ask people what they think and draw conclusions from it. But it’s rarely an effective way to get actionable answers. In fact, as it turns out, what people think, feel, say, and _do_ are often very different things.

As Erika Hall wrote, asking a question directly is the worst way to get a true and useful answer to that question. We don’t always understand or are aware of our true motivations. We often apply our own context and interpretations to questions.

We also exaggerate (a lot!). We focus on edge cases and unrealistic scenarios, and we favor short-term goals over long-term goals. So if users say that they absolutely need to compare products in a table, it doesn’t mean that they couldn’t get to their underlying goal without it.

“Possible” vs. “Probable”

Just to indicate how tricky listening to words alone is: even little nuances in words chosen matter. In practice, users are rarely precise in expressing their thoughts, and a good example is the distinction between possible, plausible, and probable, as discovered by Thomas D'hooge.

A study on Dutch verbal probability terms shows how unreliable the choice of words is. While extreme words have some agreement, terms like “possible,” “maybe,” “uncertain,” or “likely” lead to a wide spread of interpretations. So we shouldn’t rely on what people say, but rather try to go deeper.

The Levels Of Understanding

To get a more realistic and less biased view of customers’ needs, we need to understand a broader picture across 4 levels:

  • Level 1: “What they say”
    Easier to collect, but mostly opinions, and most unreliable. People often explain their behavior through the lens of how they perceive it, or how they want it to be perceived, which isn’t always accurate. We shouldn’t rely too much on CRM data, surveys, or polls.
  • Level 2: “What they think and feel”
    Gives more context, but is still heavily shaped by memory and personal preferences. Good user research and interviews help us understand expectations and experiences.
  • Level 3: “What they do”
    We study actual behavior, actions taken or skipped, usage data, and analytics. We run task analysis and workflow analysis to understand how people use the product.
  • Level 4: “Why they do it”
    We study underlying motivations and root causes, through observations of real workflows and in-depth interviews. Typically, it requires a trustworthy relationship with the user, repeat interviews, and task walkthroughs.

Personally, I wouldn’t recommend NPS (alternative). It’s worth noting that different levels might reveal conflicting or contradictory data. To get a better understanding, we need to triangulate and reconcile data with mixed-method research.

Capturing Emotions And Nuance

Emotions are always difficult to capture, but they are easier to spot once you observe people doing what they need to do without external influence or interruptions. The ability to positively impact users grows by moving from sympathy to empathy or even compassion, as articulated by Sarah Gibbons.

In the past, I was using “speak-aloud” protocol and asked users to walk me through their thought process as they were completing tasks. But it actually turns out to be quite disruptive. Because people are focused on speaking at the same time while solving a task, many emotions remain hidden or obscured by their language.

So, when conducting usability testing, I don’t ask users to speak through their experience. Instead, I observe where they tap or hover with the mouse, where their mouse circles without an action, where they scroll, and how long. Eventually, when a user confirms that they are done or that they are stuck, I ask questions.

The Emotion Wheel (website) by Geoffrey Roberts is a helpful little tool for better describing a range of emotions during user interviews or design sessions. It certainly needs refinement for product design needs, but it helps us get more precise about the sentiment customers or colleagues might be experiencing, moving beyond just “good” or “bad”.

One helpful trick is to use mirroring — repeating what a user has said, or ask the same question twice, just paraphrasing it. Or navigating the emotions wheel (see above) to better capture and understand the emotion.

These strategies help uncover some of the issues that perhaps didn’t come up in the first answer. That’s also when a user tends to add more useful context and details as they explain their confusion.

Emotions Aren’t Everything

Some people strongly disagree:

“Our work is about others — their problems, their pain, their mess. Our job is to make sense of it and then do something about it. Not to emote or perform but to act on and solve it. There is a flawed belief that to build great things, you first need to emotionally fully absorb someone else’s experience.”

— Alin Buda

I think that Alin brings up a very strong argument, and personally, I find it difficult to disagree with. However, I do see user’s emotional response as a signal of how well the product is working for them. How engaged or detached they are in their journey, how they react to aesthetics, how confused or confident they are.

Ultimately, these are signals. To make a difference, we must go beyond emotions and explore what people actually do. Usually, this means relentlessly observing, diagnosing, and focusing on underlying user needs.

Observe And Diagnose, Don’t Validate

Instead of asking, we need to observe. Usually, I focus on small things that make or break an experience. I see where users lose time, repeat actions, hover without clicking, or click and then go back. Pay attention to subtle cues like scratching their neck, raising eyebrows, or expressions of worry, joy, or confusion.

Many companies talk about “validation” through user testing, but often that means simply confirming existing assumptions. But we should instead diagnose existing behavior without preconceived notions or affiliations. We don’t validate — we actually research instead.

That research means not just understanding customers’ real motivations, but also risks, doubts, concerns, worries, and perhaps even harms.

The only way to get there is by building a sincere, honest, and trustworthy relationship — one that feels right and resonates deeply. When customers truly care and want to help, getting to a real understanding becomes much, much easier.

Practical Ways To Uncover User Needs

We don’t need expensive tools to uncover user needs. David Travis provides a fantastic overview of helpful strategies to do just that. Here are some initiatives to spread the word about real user’s struggles or gain a deeper understanding of user needs:

  • Exposure hours, when every employee must be exposed to their customers for at least 2 hours every 6–12 weeks.
  • Live UX testing, where we invite everyone in the company to join and observe.
  • Co-design with users, where we show new features and ask users to rank them.
  • Helpdesk insights, where we ask for frequent complaints and questions from the support every 3–6 months.
  • Listening in, where we tune in on a customer service call, web chat, or eavesdrop where users hang out.

The core idea here is that you don’t need extensive and expensive tools to uncover user needs. You need to create spaces where customers’ struggles can be exposed and make these struggles visible across the entire company.

It can be short video clips of user sessions or a monthly newsletter with what we learned this month. Making these pain points visible can rally everyone from marketing to engineering to keep users’ struggles at the back of their minds.

Wrapping Up

To make an impact, we must go way beyond user feedback. It’s never enough to listen to surveys — we must observe customers’ actual behaviors and build relationships to truly understand their goals and their motivations.

And most importantly, we need to understand what questions we actually want to have answered. Not what “validation” we need to move on with the project, but what we don’t know and what we need to research.

Without it, everything else is merely hunches and assumptions — and often wrong and expensive ones.

Meet “Measure UX & Design Impact”

Meet Measure UX & Design Impact, Vitaly’s practical guide for designers and UX leads on how to track and visualize the incredible impact of your UX work on business — with a live UX training later this year. Jump to details.

Meet Measure UX and Design Impact, a practical video course for designers and UX leads. Video + UX Training$ 495.00 $ 799.00 Get Video + UX Training

25 video lessons (8h) + Live UX Training.
100 days money-back-guarantee.

Video only$ 250.00$ 350.00 Get the video course

25 video lessons (8h). Updated yearly.
Also available as a UX Bundle with 3 video courses.

Useful Resources Useful Books
  • Deploy Empathy: A practical guide to interviewing customers, by Michele Hansen
  • Humankind, by Rutger Bregman
Kategorier: Amerikanska

Advanced Tree Counting: Mathematical Layouts With `sibling-index()` And `sibling-count()`

Smashingmagazine - tors, 05/21/2026 - 10:00

You know that thing where you have a grid of cards, and you want them to fade in one after another? That staggered cascade effect. Looks great. Should be simple. And yet every time I’ve built it, the implementation has made me feel like I’m doing something fundamentally stupid.

What’s Coming

The current spec only counts all element siblings. But the CSSWG has documented a planned extension in issue #9572: an of <selector> argument, matching what :nth-child() already supports.

Something like sibling-index(of .active) would let you count only siblings matching a specific selector. An element that’s the eighth child overall but the third .active child would return 3. For dynamic UIs where you’re filtering or toggling visibility, that would keep the index sequential without requiring DOM manipulation.

There’s also been CSSWG discussion around children-count() and descendant-count() functions — the first would tell you how many children an element has (useful for parent-driven layouts), the second would count all descendants recursively. Both are still at the proposal stage, but they’d round out the tree-counting story: sibling-index() and sibling-count() give you the horizontal view (where am I among my peers?), while children-count() and descendant-count() would give you the vertical view (what’s below me?).

That feeling I mentioned at the top — writing ten :nth-child() rules for a staggered animation and wondering if you’re missing something obvious? You weren’t. The obvious thing just didn’t exist yet.

Kategorier: Amerikanska

Ten Data-Backed Truths Of User Experience ROI

Smashingmagazine - fre, 05/15/2026 - 12:00

In the high-stakes economy of today, the cost of a friction-heavy interface is no longer just “lost clicks”, but potentially millions in wasted engineering spend and lost business value. As a veteran UX designer who has helped build digital products since the early mobile-first era, I’ve watched business leaders shift from viewing design as a “cosmetic preference” to recognising that user experience is actually the primary engine of business survival.

A UX design role is as much about research and analytics as it is about pixels, and I believe that hard data is the only tool powerful enough to bridge the gap between design and the boardroom. Facts don’t just advocate for the user; they prove that UX is a non-negotiable requirement for a healthy bottom line. Even in the rooms where decisions are made, UX is frequently undervalued as a ‘visual’ role. I’ve learned that the most effective way to dismantle this myth is through data.

The following ten facts represent the current reality of the digital world. These are not just “design tips”; they are the clinical, data-backed pillars for financial growth in a saturated market. Some of these facts are also commonly used by designers as best practices.

For example, I once led a B2C mobile design project, where I was able to strip 1.2 seconds off the mobile load time by reducing and removing some of the visual assets. The result was an immediate 12% lift in completed transactions, proving that in UX, every tenth of a second is a direct lever for revenue.

1. Fixing Issues In The Design Phase Is 100 Times Cheaper

One of the most compelling financial arguments for UX is the 1:100 rule. Modern studies, such as from the IBM Systems Institute and Sugue Technologies, show that fixing an error after a product has been developed and launched can be up to 100 times more expensive than fixing it during the initial design and prototyping phase.

Think of UX as “engineering insurance.” By the time a developer touches the code, every interaction should have been validated. If you discover a fundamental navigation flaw after launch, you aren’t just paying for the fix; you’re paying for technical debt, lost developer time, and the revenue lost while users struggle with a broken flow.

2. Performance Impacts User Experience

In the current landscape, performance is the essential foundation of user experience. A beautiful interface is worthless if the user bounces before it renders. The data is uncompromising: 47% of users expect a page to load in two seconds or less, and missing this window is a financial catastrophe. A mere one-second delay can reduce conversions by 20% and satisfaction by 16%, while retail businesses lose an estimated $2.6 billion annually to slow load times. When mobile load time moves from one to three seconds, the bounce rate spikes by 32%, and by the third second, conversion rates typically plummet from 40% to 29%.

However, this volatility offers a massive lever for growth. Even a microscopic 0.1-second improvement can lift retail conversions by 8.4%, and travel site conversions by 10.1%. Improving your Largest Contentful Paint (LCP) by 31% — a benchmark 67% of websites achieved as of June 2025 — can drive a direct 8% increase in sales. As a long-time designer, I treat speed as a primary design element.

If the site isn’t instantaneous, the design hasn’t just failed — it effectively doesn’t exist.

3. Your Site Has 50 Milliseconds to Impress Your Customers

First impressions are both visceral and aesthetic. Research indicates that users form an opinion about a website’s visual appeal in approximately 50 milliseconds (0.05 seconds). That’s not a lot of time! This split-second “gut-feeling” is a survival mechanism that dictates whether a user stays to explore your value proposition or bounces immediately.

In the current market, 94% of first impressions are strictly design related. If your interface feels “off” or dated, users subconsciously project that lack of quality onto your entire product or service. Your content effectively doesn’t exist if your design hasn’t earned the five seconds of attention required to read it.

4. Hick’s Law: The Cost of Overwhelm

Stakeholders often think “more options” equals “more value.” Psychology proves the opposite. Hick’s Law states that the time it takes to make a decision increases with the number of options available.

Every extra menu item or form field is a “tax” on the user’s brain. As noted by Landbase, top-performing sites now achieve conversion rates exceeding 11%, while average performers struggle below 3%. Those performing well have applied personalization and optimization strategies to simplify the experience.

If you want to increase your revenue by tomorrow, find one field to delete from your checkout flow today.

5. White Space Improves Comprehension

“White space” is often viewed as wasted real estate by non-designers. In reality, it is a tool for focus. Strategic use of white space can increase a user’s content comprehension by up to 20%.

White space prevents “cognitive load” from peaking. By giving the user’s eyes a place to rest, you guide them toward the most important elements, usually your “Buy” or “Sign Up” button. In 2026, as attention spans have dropped to roughly 8 seconds, simplicity is the ultimate luxury and a major driver of engagement.

For example, in a fintech dashboard I worked on, analyst users were feeling overwhelmed by a ‘data dump’ layout in some of the dashboard components. I applied more white space around the data to lower their cognitive load. Simply giving the data room to breathe led to a 25% decrease in time-on-task and a significant boost in trial-to-paid conversions.

6. The Power Of “Fake” Progress

One of the most surprising psychological hacks in UX is that users will complete a task faster if they believe they have already made progress. This is known as the Goal Gradient Effect.

In a classic study, researchers found that a 10-stamp coffee card with two stamps already “pre-filled” was completed significantly faster than an 8-stamp card with zero pre-fills, even though the total spend required was identical. In digital design, showing a progress bar that starts at 15% (simply for creating an account) increases completion rates for onboarding by over 40%. We aren’t just designing screens — we are managing the user’s dopamine and sense of momentum.

7. Make Your Content Readable

Many stakeholders believe that cramming more text “above the fold” increases value. Data proves the opposite. Proper typography, specifically line spacing (leading) and paragraph width, can increase content comprehension and reading speed by up to 20%.

Optimal line height (generally 1.5x the font size) reduces “visual noise,” allowing the brain to process information with less cognitive effort. When users struggle to read your text due to tight spacing or small fonts, their “perceived effort” increases, leading to a higher bounce rate. Legibility is a conversion tool: if it’s hard to read, it’s hard to buy.

There are many ways to display more legible text. For example, if line spacing (leading) is too small or the font is too heavy, this also impacts readability.

8. Your Users Only Read 20% of Your Content

This truth meshes well with the previous one. Users do not read your website; they scan it. On a typical web page, users read only about 20% to 28% of the text.

Because modern users scan in an F-pattern or Spotted pattern, designing for reading is a tactical error. We must design for scanning.

This requires the following:

  • Bold headers that narrate the value proposition.
  • Bullet points for key benefits.
  • White space to connect users to key information (discussed in the previous truth).
  • High-contrast call-to-action (CTA) buttons. If your core message is buried in a paragraph, it is invisible to nearly 80% of your audience.
9. Why User Testing With 5 People Is the Magic Number

I have heard of companies that waste six-figure budgets on massive user studies with 100 people, only to get buried in noise. The reality is that testing with just 5 users typically uncovers 85% of usability problems.

This is a mathematical sweet spot. After the fifth user, you reach the point of diminishing returns — you spend more money to find fewer new bugs. The competitive advantage belongs to small and frequent user testing activities. Test with 5 people, iterate, and test with 5 more. It is the most cost-effective way to build a bulletproof product.

Personally, I have followed this guideline many times during user testing activities, and I can confidently say that testing with 5 people does deliver the majority of issues in your design.

10. The Financial ROI of 9,900%

Last, but definitely not least, the most staggering statistic in our industry remains consistent. On average, every $1 invested in UX returns $100. This 9,900% ROI isn’t magic, but the sum of increased conversion and reduced support.

A fully optimised UX design can improve conversion rates by up to 400%. Furthermore, intuitive design significantly lowers customer support requirements. When a product is self-explanatory, you don’t need a massive call centre to explain how to use it.

The Depth of UX Investment

Beyond these individual statistics, we must address the cumulative effect of a mature UX practice. In my years of practising, the most successful firms are those that treat UX as a continuous improvement loop rather than a one-off project. The data shows that companies with high design maturity see 32% higher revenue growth and 56% higher total returns to shareholders compared to their less design-focused peers.

This discrepancy exists because mature UX organisations move beyond “user delight” and into “user efficiency.” When you shave 30 seconds off a workflow for a team of 1,000 employees, you aren’t just making them happier; you are reclaiming hundreds of thousands of dollars in annual productivity. This internal ROI is often overlooked, but it is just as vital as consumer-facing conversion rates.

Furthermore, the “experience gap” is real. 80% of companies believe they deliver a “superior experience,” but only 8% of customers agree. This massive disconnect represents a significant market opportunity for those willing to look at the hard data. By bridging this gap through continuous user testing and performance optimisation, you aren’t just improving a product but capturing market share that your competitors are leaving on the table.

The Impact of AI

Today, we cannot talk about UX without talking about AI. However, AI hasn’t replaced these 10 facts, but it has accelerated the solution on some of these.

  • Agentic UX
    60% of designers are now building “AI agents” that take actions on behalf of the user, drastically reducing the impact of Hick’s Law by narrowing down choices before the user even sees them.
  • Real-Time Personalisation
    32% of teams use AI to personalise interfaces in real-time, meaning the F-Pattern scanning habits are catered to by moving the most relevant content to exactly where that specific user’s eyes are likely to land.
  • Automated ROI
    93% of designers are using generative AI tools to prototype faster, which brings the 1:100 Cost Ratio even lower by allowing us to find and fix errors before a single line of production code is written.

AI has turned UX from a static map into a living, breathing guide for users. But the fundamental rules of human psychology, such as our 50ms judgments and our need for white space, remain unchanged.

Conclusion

In summary, here is a list of the key truths to remember:

  1. Fixing issues in the design phase is 100 times cheaper.
  2. Performance impacts user experience.
  3. Your site has 50 milliseconds to impress your customers.
  4. Hick’s Law: The cost of overwhelm.
  5. White space improves comprehension.
  6. The power of “fake” progress.
  7. Make your content readable.
  8. Your users only read 20% of your content.
  9. Why user testing with 5 people is the magic number.
  10. The financial ROI of 9,900%.

As we move deeper into the late 2020s, the line between “design” and “business strategy” has vanished. The data is in, and companies that lead in design outperform their competitors by 1.7x in revenue growth.

UX design is no longer a team you hire to “make things look nice.” It is the research-driven, data-backed discipline that ensures your digital product isn’t just a cost centre, but a revenue-generating machine.

In fact, this has always been the case, but I hope that in presenting these cold, hard truths, it now becomes a reality for your business.

As I have found over the years, implementing factual design improvements does make a difference that intuition alone can’t replicate. We are past the era of subjective opinions. The data is clear, the psychology is proven, and the ROI is undeniable. The only question left is whether you’re ready to let the facts lead your design, or if you’ll let your competitors do it first.

Further Reading On SmashingMag
Kategorier: Amerikanska

Practical Interface Patterns For AI Transparency (Part 2)

Smashingmagazine - ons, 05/13/2026 - 15:00

In the first part of this series, we talked about the Decision Node Audit. We mapped out the internal workings of our AI system to pinpoint the exact moments it makes decisions based on probabilities. This told us when the system needs to be transparent with the user. Now, the big question is how to share that information.

You’ve got your Transparency Matrix ready. You know which behind-the-scenes API calls need a visible status update. Your engineers are on board with the technical aspects. The next step is designing the visual container for those updates.

We face a legacy problem. For thirty years, interface designers have relied on a single pattern to handle latency: the spinner. The spinning wheel, the throbber, the progress bar. These patterns communicate a specific technical reality. They tell the user that the system is retrieving data. The delay is caused by bandwidth or file size.

AI agents introduce a new kind of wait time. When an agent pauses for twenty seconds, it’s not just downloading something; it’s thinking. It’s figuring out the best steps, weighing options, and creating the content you asked for.

If we use a basic spinning icon for this “thinking time,” users get confused and anxious. They watch a looping animation and can’t tell if the system is stalled or crashed. They don’t know if the agent is handling a very complicated task or if it has simply failed.

To build user trust, we need to turn this waiting time into a moment for reassurance. Instead of a passive “something is happening,” we need to communicate an active, “Here is exactly how I am working to solve your problem.”

Writing Clear Status Updates

We often think of transparency as a visual design problem, but it’s really about the words we use. Simple, clear explanations (the microcopy) are what build trust and separate a reliable AI from one that feels broken.

We need to retire generic placeholders like Loading or Working. These words are remnants of the era of static software. Instead, we must construct our status updates using a specific formula that mirrors the agency of the system. Let’s stop using vague words like “Loading” or “Working.” Those terms belong to the past, when software was simple and static. Instead, we should create status updates that clearly tell the user what the system is actually doing and make the system’s actions transparent.

Imagine, for the sake of an example, you are deploying agentic AI that will help team members organize their calendars and plan recurring meetings on their behalf, once prompted.

When an AI displays a message like “Checking availability” for an unknown amount of time, users often feel lost because it doesn’t offer enough information. While they understand the AI is looking at a calendar, they don’t know whose calendar it is, what other steps are involved (before or after), or if the AI even remembered the people and purpose of the scheduling request. Waiting for the final result can be a tense, uneasy experience, like anticipating a gift that you suspect might be a prank.

Perplexity AI provides a strong example of doing status updates right. Figure 1 below shows that when users ask a question, the interface displays exactly what it is doing in real time. You see a list of activities updating as they are accomplished. Users do not need to guess what is happening as the AI works.

The Agentic Update Formula

To give people useful status updates, we need to connect what the system is doing with why it’s doing it. Keeping with our scheduling agent example, the system should break down that waiting period into at least four clear, separate steps.

  • First, the interface displays Checking your calendar to find open times for a recurring Thursday call with [Name(s)].
  • Then, it updates to: Cross-checking availability with [Name(s)] calendars.
  • Next, it might display: Syncing [Name(s)] schedules to secure your meeting time on [Data and Time].
  • Finally, at the conclusion, the agent might state they have successfully completed the task and request the user check their email to confirm the invite that’s been shared with the group having the recurring meeting.

This communication process grounds the technical process in the user’s actual life.

Making an AI’s progress easy to understand boils down to a three-part structure: a strong Action Word, what the AI is working on (the Specific Item), and any Limits or rules it has to follow.

Think about an AI helping you book a trip. A weak, unhelpful update would just be: Searching for flights...

A much better update uses the formula:

  • Action Word: Scanning
  • Specific Item: the prices on Lufthansa and United
  • Limits/Rules: to find anything under $600.

This approach clearly shows the user that the AI understood their request and is working within the set boundaries.

Matching Tone to the Risk Matrix

Should an AI sound like a person or act like a robot? The right answer depends on the task’s importance, which we can figure out using the Impact/Risk Matrix from our Decision Node Audit.

For simple, low-risk tasks, a friendly, conversational tone works best. For example, a scheduling assistant can say it’s checking your calendar for the best time. This creates a comfortable, easygoing experience for the user.

However, high-stakes tasks demand clear, mechanical accuracy. If the AI is managing a big financial transfer or a complicated database migration, users don’t want a playful interface; they want precision. A screen that says “I am thinking hard about your money” would possibly cause panic. Instead, the interface should use straightforward language like “Verifying account routing numbers.” By adjusting the AI’s “personality” to match the level of risk, we give users exactly the experience they need in that moment. While the Impact/Risk Matrix provides a necessary starting point, the ultimate determinant of the appropriate AI voice and tone is rigorous user research.

It’s impossible for any set of rules to predict the exact words or tone that will build trust or cause stress for every group of users or in every situation. That’s why hands-on research is essential. You need to:

This kind of research ensures the AI’s “personality” is comfortable and appropriate for the actual people who will be using the system in their specific context.

We’ve now covered the “what” — the critical microcopy, the clear action words, and the necessary limits that make an AI status update honest and informative. But words alone aren’t enough. A perfect sentence hidden in a poor interface is still a failure of transparency.

The next challenge is the “how” — designing the physical delivery system for that message. You can think of the status update formula as the engine, and the interface pattern as the car. A powerful engine needs a reliable, well-designed chassis to carry it down the road.

Interface Patterns: A Library For Agents

Once we have the right words, we need the right container. The key is matching the message’s weight to the pattern’s visibility. A tiny background task (like an agent gently tidying up your files) doesn’t need a loud, flashing banner. That message is best delivered subtly. A high-stakes, multi-step process (like moving money) potentially demands a more robust container that forces the user to pay attention.

By creating a library of these patterns, we ensure the right level of transparency is delivered at the right moment, turning the anxiety of waiting into a moment of informed confidence. Let’s review a few common, critical patterns.

The Living Breadcrumb: AI Working in the Background

For those low-importance tasks that an AI is handling quietly in the background, we need a way to show users it’s working without constantly distracting them. We can call this the living breadcrumb.

Think of an email app where an AI is drafting a reply for you. You don’t want a disruptive pop-up message. Instead, a small, subtle status indicator pulses within the application’s border or menu area.

The solution needs to go beyond a static icon. The living breadcrumb smoothly transitions between different text updates. It might pulse from Reading email to Drafting reply to Checking tone. It’s there if you want to check on its progress, offering a quiet assurance that the task is underway, but it won’t demand your immediate attention.

Dynamic Checklists

When dealing with critical, high-stakes tasks — like processing a complex financial transaction or migrating a large, intricate dataset — we recommend using a Dynamic Checklist (illustrated in Figure 3).

This pattern serves as a powerful anchor for the user, providing clarity and confidence about the process’s progress. Instead of a simple bar, the Dynamic Checklist lays out every planned step the AI agent will take. It clearly highlights the step that is currently in progress, marks preceding steps as complete, and lists future actions as pending.

For example:

  • Step 1: Verify Account Balance [Complete].
  • Step 2: Convert Currency [Processing].
  • Step 3: Transfer Funds [Pending].

The Dynamic Checklist offers a significant advantage over a traditional progress bar because it expertly manages unpredictable time. If the currency conversion (Step 2) unexpectedly requires an extra ten seconds, the user won’t feel sudden anxiety or panic. They have full visibility into the system’s exact location, understanding that the delay is occurring during the Converting Currency step. Because they recognize this is a potentially complex action, they are naturally more patient and trusting of the system’s ongoing work.

The pattern itself is a compelling UI idea, but designers must remember that its implementation transforms the task into a full-stack design requirement. Unlike a simple loading flag, the dynamic checklist requires a robust front-end state management system to listen for step-completion events, which are typically triggered by a back-end webhook structure. This ensures the interface is always reflecting the agent’s real-time position in the workflow.

The Thinking Toggle

Some users with higher information needs or higher needs for transparency may not trust a simple summary; they want to see the system’s raw processing. For this audience, we’ve designed the Thinking Toggle.

This is a simple progressive disclosure UI control, like a chevron or a “View Logs” button, that lets the user expand a friendly status update into a raw terminal view. It displays the sanitized logic logs of the AI agent, such as:

  • Querying API endpoint /v2/search;
  • Response received: 200 OK;
  • Filtering results by relevance score > 0.8.

Many people will never open this view. However, for the user who needs deep transparency, the very presence of this toggle is a signal of trust. It reassures them that the system is not concealing anything.

Keep in mind, with this deep transparency comes a critical technical risk. Even for your most expert audience, you must sanitize and abstract these raw logs before display. This step is non-negotiable to prevent accidentally exposing proprietary business logic, internal data structure names, or security tokens that could be exploited. This process ensures trust is built through honesty, not security vulnerability.

Designing For Partial Success

In standard software, things are often black or white. A file either saves or it doesn’t. But with AI agents, things are often grey. An agent might plan most of a trip perfectly, yet struggle to book that one special restaurant.

We need to design for when the AI is mostly successful.

Standard binary (yes or no) error messages are trust-killers because they suggest the AI failed completely. If an agent does 90% of a task and only misses the last 10%, a big red “Request Failed” banner is misleading.

Instead, the interface should clearly show what worked and what didn’t:

  • Flight booked: UA 492 [Success].
  • Hotel reserved: Marriott Downtown [Success].
  • Car rental: Hertz [Failed — No inventory].

This way, you only have to step in and fix the parts that failed, like booking the car yourself, while keeping all the good work the agent already did.

Disentangling The Tool

When an AI system doesn’t perform as expected, it’s crucial to be absolutely clear about the true reason for the failure. Users often mistakenly blame the AI itself for problems that are actually caused by an external service or tool the AI relies on.

For example, imagine a virtual assistant tries to look at your schedule, but the connection to the Google Calendar API is down. The error message shouldn’t make the assistant look like it failed to do its job.

  • Less helpful: “I could not check your calendar.” (This suggests the assistant is incompetent.)
  • More helpful and honest: “The Google Calendar connection is not responding. I will automatically try again in 30 seconds.”

The first message is frustrating because it makes the AI look like it failed. The second message, though, is much clearer. It explains that the AI is capable, but a broken tool outside its control is causing the issue. This distinction is really important because it keeps the user from losing faith in the AI, even when things go wrong.

The Audit Trail: Trust After The Fact

Real-time transparency is fleeting. If a user walks away from their desk while the agent is working, they miss the Dynamic Checklist. They return to a finished screen. If the result looks odd, they have no way to verify the work. This is why every agentic workflow requires a persistent Audit Trail.

We need to design a Show Work interaction. On the final result screen, provide a link or history log that allows the user to replay the decision logic.

  • See how this price was calculated;
  • View search sources.

This receipt is the ultimate safety net. It allows the user to spot-check the validity of the output. Even if they never click it, the mere presence of the receipt tells the user that the system stands behind its work.

ChatGPT provides an example of how now providing users with an easy way to audit the information AI uses can cause confusion or user frustration. ChatGPT remembers you in the way a file cabinet quietly fills up with notes about everything you’ve ever said, then uses those notes to shape every future conversation without telling you. This is called memory. According to developer Simon Willison, in April 2025, that memory was getting fed into every new conversation automatically.

The problem with ChatGPT’s memory at that time was that you couldn’t see what it remembers, or when it’s using that information, or how it’s influencing what you get back. There’s no log. No timeline. No plain-language list of “here’s what the AI has decided about you.”

The only way to glimpse the dossier was to know a specific prompt trick — essentially asking the model to quote its own hidden instructions back to you. Most users will never discover this. They’ll just notice, as Willison did, that ChatGPT placed a “Half Moon Bay” sign in the background of an image they generated (Figure 8) because it had silently cross-referenced their location from previous conversations. This is the absence of transparency (the ability to audit the memory with ease) disguised as personalization. You need to provide users with both.

The Audit Trail pattern is the ultimate solution to the memory audit problem demonstrated by ChatGPT. It is one of four core design solutions that, together, create a library of options for improving AI transparency.

Here is a quick summary of the key interface patterns discussed in this article, which are designed to transform AI waiting time from a moment of anxiety into an opportunity to build user confidence:

Pattern Best Use Case The User’s Anxiety The Trust Signal The Living Breadcrumb Low-stakes, background tasks (e.g., drafting emails, sorting files). Did the system stall or freeze? I am active, but I won't disturb you. The Dynamic Checklist High-stakes workflows with variable time (e.g., financial transfers, booking travel). Is it stuck? What step is taking so long? I have a plan, and I am currently executing Step 2. The Thinking Toggle Expert tools or complex data analysis (e.g., code generation, market research). Is this hallucinating or using real data? I have nothing to hide; here are my raw logs. The Audit Trail Post-task review for any outcome (e.g., final reports, completed bookings). How do I know this result is accurate? Here is the receipt of my work for you to verify.

Table 1: Four design patterns enhancing transparency.

The Reality of Attention: When Users Ignore the Interface

Even the most perfectly designed checklist or the clearest status message may still go ignored by many users.

When people are working on tons of tasks, especially professionals, they often tune out the interface. Think of an insurance underwriter creating fifty quotes a day — they’re not watching a progress bar. They click “Generate,” switch tabs to answer an email, and only come back when the task is done.

My research with these experts shows they judge the system based entirely on the final result. They have a good idea of what the answer should be. If a salesperson expects a premium between $500 and $600, and the system returns $550, they accept it right away, and trust is established.

These experts tell me that over time, as the AI continues to provide what they perceive as accurate outputs, usage will increase, and they will save time versus manual quoting. Essentially, the system is now viewed as an efficient accelerator of an otherwise monotonous yet mandatory task.

But if the system returns $900, the user stops. The output is not aligned with expectations, and that’s a problem they must solve. At that moment, the user switched tabs; they missed the little explanation about the high-risk surcharge that popped up in real-time. They didn’t see the specific rule that was triggered. If that explanation disappeared with the progress bar, the user has no way to understand the difference between expectation and outcome. They certainly won’t run the query again just to watch the animation play out.

They will run the quote by hand, effectively treating the AI’s output as useless and initiating a complete rework of their effort. This manual recalculation feels like a waste of time, which further erodes their confidence in the tool. Once this happens, the user is not interested in why the system chose $900; they are focused purely on validating or invalidating the system’s accuracy against their own, trusted methods. This lack of transparency, especially in moments of disagreement, is a primary barrier to adoption and consistent use. The audit trail allows us to provide persistent transparency and is the mechanism that prevents the AI from creating more work.

We need to keep this in mind, particularly when delivering AI-powered tools meant for enterprise use. If the tool delivers a result that misaligns with expectations, you rarely get a second chance. If the user must spend ten minutes investigating why the AI provided that number, they will stop using the AI.

Predictability, Reliability, and Understanding Are The Product

We are not building magic tricks. A magic trick relies on misdirection and hidden mechanics. We are building colleagues.

Think of a good colleague, they keep you in the loop. They let you know what they’re up to, what’s taking their time, and when they hit a snag. That honesty is what helps you trust them.

We can apply this to AI. By using the practical patterns we discussed: giving specific updates, showing a dynamic checklist, acknowledging partial wins, and keeping an audit trail, we stop seeing AI as a mysterious black box that just needs a nice coat of paint. Instead, we start treating it like a team member we can rely on and manage, which builds trust and a clear understanding.

The main reason for using these interface ideas is to achieve real transparency, going beyond explaining the AI’s complicated inner workings. Here, transparency means showing the user the AI’s process and performance right when they need to see it. This involves plainly communicating the AI’s current status, its known limits, and an easy-to-follow history of its decisions. This level of openness changes the interaction from just accepting what the AI does to actively working with it. It lets users understand why they got a certain result and how they can best step in or guide the system for the best possible outcome.

References
Kategorier: Amerikanska

The Architecture Of Local-First Web Development

Smashingmagazine - ons, 05/06/2026 - 12:00

Last October, I was sitting in a hotel room in Lisbon, the night before I was supposed to demo a project management tool my team had spent four months building. The hotel Wi-Fi was doing that thing where it connects but nothing actually loads. And I watched our app, this thing I was genuinely proud of, render a blank screen with a spinner. Then a timeout error. Then nothing.

I pulled out my phone, tethered to cellular, and got a shaky connection. The app loaded, but every click was a two-second wait. Create a task? Spinner. Move a task between columns? Spinner. I sat there thinking: we built a front end in React, a back end in Node, a Postgres database, a Redis cache, a GraphQL API with six resolvers just for the task board. All that infrastructure, and the damn thing can’t show me my own data without a round-trip to a server 3,000 miles away.

That was the night I started seriously looking at local-first architecture. Not because I read a blog post or saw a tweet. Because I was embarrassed.

I want to be upfront about something: I spent the first year or so dismissing local-first as academic. I read the Ink & Switch “Local-First Software” paper when it came out in 2019 and thought, “Cool research, not practical for real apps.” I was wrong. The tooling in 2019 genuinely wasn’t ready. But I was also being lazy, defaulting to the architecture I already knew. The paper laid out seven ideals for software: fast, multi-device, offline, collaboration, longevity, privacy, user ownership. And I remember thinking those sounded like a wish list, not engineering requirements.

Seven years later, I’ve shipped three production apps using local-first patterns. I’ve also ripped local-first out of two projects where it was the wrong call. I have opinions. Some of them are probably wrong. But they’re earned.

So here’s what I actually think about building local-first web apps in 2026, written for developers who’ve been doing this long enough to be skeptical of silver bullets.

What “Local-First” Actually Means (And The Confusion That Won’t Die)

I need to clear something up because I keep having this conversation at meetups. Local-first is not offline-first. It’s not “add a service worker and call it a day.” It’s not a synonym for PWA. I’ve seen all of these conflated in conference talks, and it drives me a little crazy.

Offline-first means your app handles network loss gracefully, but the server is still the source of truth. When the network comes back, the server wins. Cache-first (service workers caching responses) is a performance optimization. You’re serving stale data faster, which is great, but you haven’t changed who owns the data. PWAs are a delivery mechanism: installable, cached, push notifications. None of these is a data architecture.

Local-first is a data architecture. Your user’s device holds the primary copy of their data. The app reads and writes to a local database. Renders instantly. Syncs with servers or other devices in the background. The server, when it exists, is a sync peer with some special authority (authentication, backup, access control). But it’s not the gatekeeper.

The Ink & Switch paper defined seven ideals, and I think they still hold up. But the one that matters most in practice, the one that changes how you build everything, is this:

The client is not a thin view requesting permission to show data. The client is a node in a distributed system with its own database.

That distinction sounds subtle. It isn’t. It changes your entire stack.

Be Honest Early: When You Should Not Do This

I’m putting this near the top because I’ve watched too many developers (including myself, once) get excited about a new architecture and shoehorn it into projects where it doesn’t belong. I wasted about six weeks trying to make a local-first approach work for an internal analytics dashboard at a previous job. My colleague Sarah finally pulled me aside and said, “The data is generated on the server. There’s nothing to replicate to the client. What are you doing?” She was right.

Local-first is a bad fit when your data is primarily server-generated. Analytics dashboards, social media feeds, search results: the server produces this data, so the client consuming it via API requests is completely fine.

It’s wrong for systems that need strong transactional consistency. Banking, payment processing, and inventory management. If two people try to buy the last item in stock, you need a single authoritative database making that decision with ACID guarantees. Eventual consistency will lose you money, or worse.

It’s overkill for simple CRUD apps with no offline or collaboration needs. If you’re building an internal admin panel used by five people in an office with good internet, adding a sync engine is over-engineering. And it’s physically impractical for massive datasets that won’t fit on client devices.

But here’s where it shines: note-taking, document editing, collaborative design tools, project management, field apps with unreliable connectivity, basically anything where data privacy is a selling point, as well as anything with real-time collaboration. In other words, it’s great for user-generated data that benefits from instant interaction and should survive the server going down.

One more thing I wish someone had told me earlier: you don’t have to go all-in. I’ve had the best results using local-first for specific features within otherwise traditional apps. Offline drafts in a blog editor. Real-time collaborative notes inside a project management tool that’s otherwise standard REST.

The “spectrum of local-first” is a real thing, and starting with one feature is how I’d recommend anyone begin.

Replicas, Not Requests

If you’ve used Git, you already understand the mental model.

SVN (remember SVN?) was centralized. One server. You check out files, make changes, and commit to the server. Server down? Can’t commit. Can’t even see history.

Git gave every developer a full clone. You commit locally, branch locally, and merge locally. Push and pull when you’re ready. The remote repository is important, but it’s not the only copy of the truth.

Local-first web development is Git for application data. Every client device holds a replica (full or partial) of the relevant data. Writes happen locally. Sync is push/pull in the background. Conflicts get resolved through defined merge strategies.

I remember the first time this clicked for me in practice. I was prototyping a task board, and I wrote a function to add a task. In our old architecture, it would be:

  1. POST to API.
  2. Wait for the response.
  3. If success, update the local state.
  4. If failure, show error toast and maybe roll back optimistic update.

In the local-first version, it was: write to local SQLite, done. The UI updated instantly because it was reading from the same local database. Sync happened whenever. No loading state, no error handling for the write itself, no optimistic update logic (because there’s nothing to be “optimistic” about; the local write is the state).

The implications ripple through everything. You don’t need React Query or SWR for data fetching, because you’re not fetching. You don’t need Redux or Zustand for server-derived state, because the local database is your state. Your routing doesn’t trigger API calls. Authentication works differently because the server isn’t checking permissions on every read.

Here’s a visual comparison that might help if you’re the kind of person (like me) who thinks spatially:

On the left, every user interaction is a round-trip. Click, wait, render. On the right, reads and writes hit the local database directly. The sync server is still there, but it’s doing its work in the background. The user never waits for it. That’s the fundamental shift.

But I’m getting ahead of myself. Before we can talk about sync and conflicts, we need to talk about where the data actually lives on the client.

Where Data Lives on the Client

Forget localStorage. It’s synchronous (blocks the main thread), caps at 5-10 MB, and only stores strings. It’s fine for a theme preference. It’s not a database.

IndexedDB is the workhorse that nobody loves. It’s in every browser, it’s asynchronous, it can handle hundreds of megabytes, and its API is absolutely miserable to work with. I’ve used it directly a grand total of once. Now I use it through abstractions or, more often, I don’t use it at all.

Because the real story in 2026 is SQLite running in the browser via WebAssembly.

I know that sounds like a party trick, but it’s not. SQLite compiled to WASM, persisted to the Origin Private File System (OPFS), gives you a real relational database in the browser. Full SQL queries. Transactions. Indexes. The works.

OPFS is the newer API that makes this practical. It gives web apps a sandboxed file system with high-performance synchronous access (in Web Workers), which is exactly what SQLite needs. Before OPFS, you could run SQLite in memory and manually persist to IndexedDB, which worked but was slow and fragile.

Here’s roughly what initialization looks like in a real project (I’m using wa-sqlite here, which is the library I’ve had the best luck with):

import { SQLiteAPI } from 'wa-sqlite'; import { OPFSCoopSyncVFS } from 'wa-sqlite/src/examples/OPFSCoopSyncVFS.js'; async function initDatabase() { const module = await SQLiteAPI.initialize(); const vfs = new OPFSCoopSyncVFS('pm-tool-db'); await vfs.initialize(module); const db = await module.open_v2('workspace.db'); // HACK: wa-sqlite doesn't handle concurrent writes well on Safari, // so we serialize through a queue. See vlcn-io/wa-sqlite#247 await module.exec(db, PRAGMA journal&#95;mode=WAL); await module.exec(db, CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, status TEXT DEFAULT 'backlog', assignee&#95;id TEXT, project&#95;id TEXT NOT NULL, position REAL DEFAULT 0, created&#95;at TEXT DEFAULT (datetime('now')), updated&#95;at TEXT DEFAULT (datetime('now')) )); return db; }

In production, I wrap all database access in a write queue that serializes mutations. I also log every failed write to Sentry with the full SQL statement (scrubbed of PII, obviously) because debugging database issues in a user’s browser is hell without that telemetry.

A gotcha I wasted almost two days on: Safari’s OPFS implementation behaves differently from Chrome’s in subtle ways. Specifically, I hit a bug where createSyncAccessHandle() would silently fail in certain iframe contexts on Safari 18. There’s no error, no exception. It just doesn’t work. I ended up falling back to IndexedDB-backed persistence on Safari, which was slower but at least functioned. (I’m told Safari 19/26 fixes this, but I haven’t verified it yet.)

Quick comparison of the options I’ve actually used:

Storage Good For Watch Out For IndexedDB Broad compatibility, moderate data Terrible DX, no SQL, verbose OPFS + SQLite WASM Relational data, complex queries, serious apps Safari quirks, ~400KB bundle addition PGlite (Postgres in WASM) Full Postgres compatibility on client Newer, larger bundle, still maturing

I’ve also tried cr-sqlite, which adds CRDT column support directly to SQLite tables. Clever idea, but I found it too early-stage for production use when I evaluated it in late 2025. The merge semantics were sometimes surprising, and debugging CRDT state inside SQLite was painful. I’d revisit it later this year.

The Part That’s Actually Hard

Storing data locally is a solved problem. Syncing it reliably across devices and users is where you earn your gray hairs.

When multiple replicas can independently read and write, you need a mechanism to reconcile changes. There are basically four approaches, and I’ve used three of them.

CRDTs (Conflict-Free Replicated Data Types) are data structures designed so that concurrent edits can always be merged without conflicts, mathematically guaranteed. Yjs is the most popular implementation in JavaScript, and it’s genuinely excellent for real-time collaborative text editing. I used it to build a collaborative document editor at my last company, and the experience was mostly good, though I’ll get into the pain points in the conflict resolution section.

Here’s what setting up a shared Yjs document looks like in practice:

import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; const ydoc = new Y.Doc(); const provider = new WebsocketProvider( 'wss://sync.our-app.dev', 'workspace-a1b2c3d4', ydoc ); const tasks = ydoc.getMap('tasks'); // Add a task const task = new Y.Map(); task.set('title', 'Review Q3 roadmap draft'); task.set('completed', false); task.set('assignee', 'maria'); // TODO: type this properly once; yjs exports better TS types // for nested maps. For now, this works fine. tasks.set('f47ac10b-58cc-4372-a567-0e02b2c3d479', task as any); tasks.observeDeep(() => { // Re-render UI. In practice, I debounce this to ~16ms // because observeDeep fires a LOT during active collaboration renderTaskList(tasks.toJSON()); });

Automerge is the other major CRDT library, backed by Rust and with a document-oriented model. I’ve used it less, but I know teams who swear by it. Loro is newer, Rust-based, and claims better performance. I haven’t shipped anything with Loro yet.

Database replication is the other big approach, and honestly, for most apps that don’t need Google Docs-style real-time text editing, I think it’s the better choice. The idea is straightforward: replicate rows between a server database (Postgres) and a client database (SQLite) with a sync engine managing the plumbing.

PowerSync does this well. It gives you one-way replication from Postgres to client SQLite with a write-back path for mutations. ElectricSQL is more ambitious, going for full active-active sync between Postgres and SQLite. I’ve used PowerSync in production and ElectricSQL in prototypes. PowerSync felt more stable when I evaluated them both in early 2026, but ElectricSQL’s approach is more powerful if they nail the execution.

Triplit takes a different angle entirely: it’s a full-stack database with sync built in, so you don’t think about “client DB” and “server DB” separately. I haven’t tried it beyond a weekend prototype, but the developer experience was surprisingly nice.

Event sourcing (syncing a log of mutations rather than the current state) is the approach LiveStore takes. I find it intellectually appealing and occasionally useful, but in practice, I’ve found that reconstructing state from an event log adds complexity that most apps don’t need. My controversial opinion: Event sourcing is over-recommended for application development. It’s great for audit logs and certain domains, but for a task board? Just sync the rows.

Not everyone will agree with that. I know event sourcing has passionate advocates, and I’ve been told I’m wrong about this at least twice at conferences. Maybe I just haven’t built the right app for it yet.

Conflicts: The Thing Everyone’s Afraid Of

I used to think conflict resolution was a terrifying, unsolvable problem. After building three apps that handle it, I’d revise that to: it’s a manageable problem that requires you to think carefully about your specific data model, and most developers overthink it.

Conflicts happen when two replicas modify the same data without seeing each other’s changes. User A edits a task title on their phone while offline. User B edits the same title on their laptop. Both come back online. Now what?

My first attempt at handling this was embarrassingly naive:

// My first try. Don't do this. function resolveConflict(local: any, remote: any) { // just... take the remote one? sure? return remote; }

The problem is obvious: local changes get silently dropped. User A edits a title, syncs, and their edit vanishes. They don’t even know it happened.

What actually works for most cases is last-write-wins (LWW) at the field level, not the record level. If User A changes the title and User B changes the due date, you keep both changes because they touched different fields. You only have a real conflict when both modified the same field, and then you pick the later timestamp.

interface FieldValue { value: string | number | boolean; // ISO timestamp with enough precision to break most ties updatedAt: string; // Client ID as tiebreaker when timestamps match. // This happens more often than you'd think. clientId: string; } function pickWinner(a: FieldValue, b: FieldValue): FieldValue { const timeA = new Date(a.updatedAt).getTime(); const timeB = new Date(b.updatedAt).getTime(); if (timeA !== timeB) return timeA > timeB ? a : b; // Deterministic tiebreaker when timestamps match return a.clientId > b.clientId ? a : b; } // In practice, I apply this per-field across the whole record. function mergeTask(local: Record<string, FieldValue>, remote: Record<string, FieldValue>) { const merged: Record<string, FieldValue> = {}; const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]); for (const key of allKeys) { if (!local[key]) { merged[key] = remote[key]; continue; } if (!remote[key]) { merged[key] = local[key]; continue; } merged[key] = pickWinner(local[key], remote[key]); } return merged; }

In our production app, this handles about 95% of conflicts without any user-visible issues. For the remaining cases (two people editing the same text field), LWW means one person’s edit silently wins. For a task title? Honestly, that’s usually fine. For a document body? No. That’s where CRDTs earn their keep.

But there’s a subtler problem I didn’t appreciate until I hit it: semantic conflicts. Data merges cleanly at the structural level, but the result is nonsensical. Two users, both offline, book the same 2 PM meeting slot with different meetings. Field-level merge accepts both writes because they’re writing to different records. No structural conflict. But you’ve got a double-booking, and your merge function has no idea that’s a problem.

Semantic conflicts require application-level validation, and that has to happen on the server during sync. Your sync engine merges the data structurally, but your server needs to check domain invariants before accepting the result. The approach I’ve landed on (after getting it wrong twice) is: validate on the server during the write-back phase, but flag violations rather than silently rejecting them.

Here’s what I mean. When the client pushes mutations to the server during sync, the server runs them through a constraint validation layer before applying them to Postgres:

interface SyncViolation { type: 'scheduling_conflict' | 'capacity_exceeded' | 'stale_assignment'; recordId: string; description: string; // The conflicting records so the client can show context conflictingRecords: string[]; // When was this violation detected detectedAt: string; } async function validateSyncBatch( mutations: SyncMutation[], serverDb: Database ): Promise<{ accepted: SyncMutation[]; violations: SyncViolation[] }> { const accepted: SyncMutation[] = []; const violations: SyncViolation[] = []; for (const mutation of mutations) { if (mutation.table === 'calendar_events') { // Check for double-booking const overlapping = await serverDb.query( SELECT id, title FROM calendar&#95;events WHERE room&#95;id = ? AND id != ? AND start&#95;time &lt; ? AND end&#95;time &gt; ?, [mutation.data.room_id, mutation.data.id, mutation.data.end_time, mutation.data.start_time] ); if (overlapping.length > 0) { violations.push({ type: 'scheduling_conflict', recordId: mutation.data.id, description: Conflicts with "${overlapping[0].title}", conflictingRecords: overlapping.map(r => r.id), detectedAt: new Date().toISOString() }); // Still accept the write, but flag it // The alternative is rejecting it, but then the user's // local state and server state diverge, and that's worse accepted.push(mutation); continue; } } accepted.push(mutation); } return { accepted, violations }; }

The key decision here — and I went back and forth on this — is that we accept the conflicting write and flag it, rather than rejecting it outright. If you reject it, the user’s local database has a record that the server refuses to acknowledge, and now you’re in a state divergence situation that’s genuinely hard to recover from. I tried the rejection approach first, and it led to ghost records on the client that users couldn’t delete because they didn’t exist on the server. Nightmare.

So instead, the server accepts the write, stores the violation, and syncs the violation back to the client. The client shows a non-blocking notification: “Your meeting ‘Q3 Planning’ conflicts with ‘Design Review’ in Room B at 2 PM. Tap to resolve.” The user taps, sees both meetings, and picks one to reschedule or cancel. The resolution is a normal write that syncs back.

Is this perfect? No. There’s a window between when the violation is created and when the user resolves it, where both conflicting records exist. For meeting rooms, that’s tolerable. For something like inventory management where two people “buy” the last item, that window is unacceptable, and that’s exactly why I said earlier that local-first is wrong for systems requiring strong transactional consistency.

I’m still iterating on this pattern. The violation table grows if users ignore notifications (we expire them after 72 hours, which feels arbitrary). And deciding which invariants to validate on the server requires you to essentially maintain a parallel set of business rules outside your client-side application logic. It’s not elegant. But it works, and it’s the best approach I’ve found for the class of apps I’m building. If you’ve built something cleaner, I genuinely want to hear about it.

For CRDTs like Yjs, conflict resolution at the character level (for text) works remarkably well. Two people typing in the same paragraph will see both sets of characters appear in a sensible order. But CRDT merging of structured data (maps, arrays, nested objects) can produce results that surprise you. I once watched a Yjs-backed task list duplicate items after a merge because two users had reordered the same list offline, and the CRDT’s list merge semantics interleaved their orderings. Technically correct. Practically confusing. We ended up adding a post-merge de-duplication step, which felt like a hack but solved the problem.

When should you surface conflicts to the user, Git-style? In my experience, almost never for typical app data. Users don’t want to resolve merge conflicts. They want the app to figure it out. The exception is high-stakes content: legal documents, medical records, anything where silently dropping an edit could cause real harm.

The Tools Right Now

I’m going to give you my honest read on the tools available as of mid-2026, with the caveat that this space is moving fast enough that some of this might be outdated by the time you read it.

Yjs is the most mature CRDT library. Production-ready, huge community, integrates with most collaborative editors (TipTap, BlockNote, Lexical). If you need real-time collaborative editing, start here.

Automerge is solid, Rust-backed, and takes a more document-oriented approach than Yjs. I’ve seen it used well in apps where the data model fits a document metaphor. Fewer integrations than Yjs, but the core is well-engineered.

PowerSync is what I’d recommend for teams that have an existing Postgres back-end and want to add offline support. It’s production-ready, the docs are good, and the mental model (Postgres syncs to client SQLite, client writes go through a defined upload path) is easy to reason about. In our app, initial sync for a workspace with around 5,000 tasks takes about 1.2 seconds on a decent connection and about 3.5 seconds on a throttled 3G simulation. That was acceptable for us.

ElectricSQL is going for something more ambitious: true active-active replication between Postgres and SQLite, with “shapes” defining what data syncs to which client. I want this to succeed because the developer experience in prototypes was excellent. But when I evaluated it for production in February 2026, I hit enough rough edges (particularly around shape management and reconnection behavior) that I went with PowerSync instead. I plan to revisit it.

Triplit impressed me in a weekend prototype. Full-stack database with sync built in, nice TypeScript API. I haven’t stress-tested it with real production load, and I’d want to before committing.

Zero (from Rocicorp, the Replicache people) is interesting because it takes a query-based approach to sync, which is different from the row-replication model. Replicache was sunset in favor of Zero, which tells you something about how fast approaches are evolving in this space. Worth watching, but I wouldn’t build on it yet for a production app.

TinyBase is a lightweight reactive store that’s great for smaller apps or prototyping. I used it for a personal side project (a reading tracker) and liked it a lot. Not sure I’d use it for a team-scale product.

PGlite (Postgres compiled to WASM) is wild. Same SQL dialect on client and server. Combined with ElectricSQL, you could theoretically run identical queries everywhere. I think this is where things are heading long-term, but PGlite’s bundle size and memory footprint are still concerns for mobile browsers.

One thing the Replicache sunset taught me: don’t bet your architecture on a single tool from a small company without a fallback plan. I keep my sync layer abstracted enough that I could swap engines in a few weeks, not months. I know that sounds like premature abstraction, but in a space this young, I think it’s just prudence.

Building A Real App: Architecture, Auth, And Migrations

I want to walk through how I actually structure a local-first app in practice, because the layer diagrams you see in blog posts rarely match what the code looks like.

My current stack for a collaborative project management tool looks like this:

  • UI: React components that never call fetch() for data reads.
  • Query layer: useLiveQuery hooks that subscribe to the local SQLite database and re-render automatically when data changes.
  • Local database: SQLite via wa-sqlite, persisted to OPFS.
  • Mutation layer: Plain INSERT/UPDATE/DELETE statements against local SQLite.
  • Sync: PowerSync managing replication between local SQLite and our Postgres back-end.
  • Server: Postgres, a Node.js auth service, and a small sync validation layer.

The component code ends up looking almost absurdly simple compared to what I used to write:

import { useLiveQuery } from '@powersync/react'; import { db } from '../lib/database'; function TaskBoard({ projectId }: { projectId: string }) { const tasks = useLiveQuery( SELECT &#42; FROM tasks WHERE project&#95;id = ? AND archived = 0 ORDER BY position, [projectId] ); async function addTask(title: string) { await db.execute( INSERT INTO tasks (id, title, project&#95;id, position, created&#95;at) VALUES (?, ?, ?, ?, datetime('now')), [crypto.randomUUID(), title, projectId, tasks.length] ); // That's it. useLiveQuery picks up the change automatically. // No invalidation, no refetch, no loading state. } // No isLoading check. Data is local. It's always there after the first sync. return ( <div> {tasks.map(task => <TaskCard key={task.id} task={task} />)} <NewTaskInput onSubmit={addTask} /> </div> ); }

Compare that to the React Query + REST equivalent, which would be at least twice the code and include loading states, error states, optimistic update logic with rollback, and cache invalidation. I don’t miss it.

Auth In A Local-First World

Authentication works roughly the same as traditional apps: JWT tokens, OAuth flows, and session management. The token authenticates the sync connection rather than every individual request. Offline access works because the data is already local. The user was authenticated when the data was originally synced.

Authorization is trickier, and I think most local-first articles under-explain this. You cannot sync your entire database to every client and rely on client-side code to hide unauthorized data. Someone will open DevTools, find the local SQLite file, and see everything. The client is not a trust boundary.

You enforce authorization at the sync layer. PowerSync has “sync rules” that define which rows go to which clients. ElectricSQL has “shapes.” Either way, the server only sends data that the user is authorized to see. When the client sends writes back, the server validates them against authorization rules before applying them to Postgres. If a user tries to modify something they shouldn’t, the server rejects it during sync.

I also want to mention end-to-end encryption (E2EE), because it pairs naturally with local-first. Since data lives on the client, you can encrypt it before sync. The server stores and relays encrypted blobs it can’t read. Apps like Anytype do this. We haven’t implemented E2EE in our current app, but it’s on the roadmap for when we handle more sensitive data.

Schema Migrations On A Thousand Devices

This one caught me off guard the first time. On the server, you run a migration against one database you control. On the client, every user has their own database that might be running any version of your schema, depending on when they last opened the app.

I use a simple migration runner that checks a version number at app startup:

const MIGRATIONS = [ { version: 1, sql: CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, status TEXT DEFAULT 'backlog', project&#95;id TEXT NOT NULL, created&#95;at TEXT DEFAULT (datetime('now')) ); }, { version: 2, // Added priority and due_date in sprint 4 sql: ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 0; ALTER TABLE tasks ADD COLUMN due&#95;date TEXT; }, { version: 3, // Denormalized assignee name for offline display. // Yes, I know this is a trade-off. The JOIN was killing // performance on low-end Android devices. sql: ALTER TABLE tasks ADD COLUMN assignee&#95;name TEXT DEFAULT ''; } ]; async function runMigrations(db: Database) { await db.execute(CREATE TABLE IF NOT EXISTS &#95;schema&#95;version (version INTEGER)); const rows = await db.execute('SELECT version FROM _schema_version'); const currentVersion = rows.length > 0 ? rows[0].version : 0; for (const migration of MIGRATIONS) { if (migration.version > currentVersion) { console.log(Migrating local DB to v${migration.version}); await db.execute('BEGIN'); try { await db.execute(migration.sql); await db.execute( 'INSERT OR REPLACE INTO _schema_version (rowid, version) VALUES (1, ?)', [migration.version] ); await db.execute('COMMIT'); } catch (err) { await db.execute('ROLLBACK'); // In production, this fires a Sentry alert with the // migration version and error details throw err; } } } }

Design your migrations to be additive. New columns with defaults. New tables. Don’t rename or drop columns unless you absolutely must, because users running old app versions will still be syncing data, and your server needs to handle the mismatch. I learned this the hard way when I dropped a column that an older client was still writing to, which caused silent sync failures for about 200 users over a weekend. Not fun.

If I Were Starting A New Project Today

I get asked this a lot, so here’s my current answer. It changes every six months or so.

For a collaborative app with real-time features and offline support, I’d start with: React on the front end, PowerSync for sync, SQLite via wa-sqlite on the client (persisted to OPFS with IndexedDB fallback for Safari), and Supabase (which gives me Postgres, auth, and row-level security out of the box). I’d use Yjs only if I needed rich text collaboration, and I’d avoid it if I didn’t, because CRDTs add meaningful complexity to your data model.

For a simpler app where I mostly need offline support and instant reads but collaboration is secondary, I might skip the sync engine entirely and just use a local SQLite database with a custom sync layer that pushes/pulls from a REST API. I know that sounds like reinventing the wheel, but for simple cases, a custom sync that you fully understand is better than a general-purpose sync engine that adds concepts you don’t need.

I would not currently use ElectricSQL or Zero for production, not because they’re bad, but because I want another 6-12 months of maturity before I’d trust them for something I’m on-call for. I’ve been burned before by building on early-stage infrastructure (I was an early Meteor adopter, if that tells you anything) and I’m more cautious now about where I accept novelty risk.

Performance: What’s Actually Fast And What Hurts

Reads are instant. That’s not marketing. Querying a local SQLite database for a list of 500 tasks takes under two milliseconds on my M2 MacBook and about eight milliseconds on a mid-range Android phone. No network. No spinner. No loading state.

Writes are instant, too. INSERT INTO tasks runs locally, the UI updates reactively, and sync happens whenever. Users perceive writes as instantaneous because they are.

Initial sync is where you pay the cost. Bootstrapping the local replica on first load (or on a new device) means downloading potentially megabytes of data. In our app, a workspace with 5,000 tasks, 200 projects, and 50 users takes about 1.2 seconds on broadband and four to five seconds on a slow mobile connection. We mitigate this with partial sync (only sync the user’s active projects) and by showing a one-time “Setting up your workspace” screen during the first sync. After that initial sync, incremental updates are tiny.

Bundle size is a real concern. SQLite compiled to WASM adds roughly 400KB gzipped to your JavaScript bundle. That’s not trivial, especially if you care about Time to Interactive on mobile. I lazy-load the database module with dynamic import() so it doesn’t block the initial render.

Memory is the other gotcha. SQLite WASM runs in memory, and on mobile browsers with aggressive memory limits, a large database can cause tab crashes. I haven’t found a great solution for this beyond keeping the synced dataset small through partial sync and being aggressive about pruning old data.

Note: Speaking of memory issues, I’ve been reading Designing Data-Intensive Applications by Martin Kleppmann for the third time. Every re-read, I catch something new. If you haven’t read it and you’re thinking about distributed data, just stop and read it first.

Testing This Stuff

I’ll keep this brief because the honest answer is that testing local-first apps is harder than testing traditional apps, and the tooling isn’t great yet.

What works for me: unit tests for merge logic (these are pure functions, easy to test), integration tests that spin up two client instances in memory and verify they converge after concurrent edits, and Playwright E2E tests that use context.setOffline(true) to simulate offline/online transitions.

What I haven’t figured out well: reproducing bugs that only happen during conflict resolution with specific timing. When a user reports that a task “lost its description,” I often can’t reproduce it because I don’t know exactly what sequence of offline edits and sync events led to the conflict. I’ve started logging sync events in more detail (what was sent, what was received, what conflicts were detected, how they were resolved) and shipping those logs to our observability stack. It helps, but it’s not as clean as I’d like.

Property-based testing with something like fast-check is genuinely useful for CRDT logic. Generate random operation sequences, apply them in random orders, and assert convergence. I wish I’d started doing this earlier.

What I’m Watching, What Worries Me

I’m excited about where this is going. PGlite (full Postgres in the browser) feels like a glimpse of a future where the client/server data layer distinction just dissolves. You write SQL, it runs everywhere, sync is a runtime concern rather than an architectural decision. We’re not there yet, but you can see it from here.

I’m also watching the convergence of local-first and AI. Running models locally, keeping data on-device, using cloud AI only with explicit consent, and encrypted data. The privacy implications are compelling, and I think “your data never leaves your device” will become a real product differentiator as AI eats more of the software experience.

What worries me is fragmentation. Every sync engine uses its own protocol. There’s no standard. If ElectricSQL shuts down (it won’t, probably, but if), migrating to PowerSync isn’t trivial. I abstract my sync layer partly for this reason, but it still makes me nervous.

The web has standards for nearly everything. We don’t have one for sync, and I don’t see one emerging soon.

I’m also worried about the complexity budget. Local-first adds real architectural complexity: sync engines, conflict resolution, client-side migrations, partial replication, and auth at the sync boundary. For a team of experienced developers building the right kind of app, that complexity pays for itself many times over. For a team that just needs a CRUD app, it’s a trap.

I keep coming back to something a developer named Kevin said to me at a local-first meetup in Berlin last year:

“The best architecture is the one your team can debug at 2 AM.”

He’s right. If local-first makes your app faster, more reliable, and better for users, and your team understands how the sync works, go for it. If you’re adding it because it sounds cool and you don’t fully understand the failure modes yet, build a prototype first. Learn where it breaks. Then decide.

I’m building my fourth local-first app right now: a collaborative planning tool for small teams, with offline support and optional E2E encryption. It’s the most ambitious thing I’ve attempted with this architecture. I’ll write about how it goes.

If you’re starting out, pick one feature in your current app that would benefit from instant local reads and offline writes. Add a local SQLite database. Wire up reactive queries. See how it feels. I think you’ll have the same reaction I did: oh, this is how it should have always worked.

Further Reading
Kategorier: Amerikanska

Rethinking The Experience Of System Tools

Smashingmagazine - tis, 05/05/2026 - 10:00

This article is a sponsored by MacPaw

Your grandmother’s vacuum was a trusty but ugly workhorse hidden in a dark closet. Dyson turned that practical tool into an aspirational product, one you love leaving out even when guests come over. Dish soap was just dish soap until Method put it in a glass container, and it became an addition to, not a distraction from, the aesthetics of your kitchen. Physical product brands spent the last two decades transforming mundane, practical items like soap and vacuums into must-have experiences.

But utility software — especially maintenance tools, a type of system software designed to analyze, configure, optimize, and maintain a computer — hasn’t made that leap from something you open as a chore to an experience you choose with excitement. And that means those brands are missing an interesting design opportunity: these tools are well overdue for a more intelligent, more human, and less emotionally flat approach.

“The Most Underexplored Frontier In UX Is The Maintenance Layer.”

Utility software still feels like a chore. Using it has all the excitement of pulling out that dusty old vacuum from the back of the closet. These four common software design assumptions illustrate why the category hasn’t yet transcended its chore status.

  • Assuming the user already resents the task: they’re here because something is wrong, not because they chose to open this tool. Designing accordingly means assuming they want the software to be fast, clinical, invisible, and something to get out of the way, not get into. But a design built for resentment produces tools that deserve it. If you expect your users to want to get out of the product as fast as possible, they’ll feel it in the design.
  • Assuming function is enough and feelings are for consumer apps: emotion in interface design is decoration. The maintenance layer is infrastructure, and nobody decorates infrastructure. But nobody decorated dish soap either, until Method. They didn’t change the product, just the user’s relationship to the tool they use to accomplish a task.
  • Assuming your users are not your fans because nobody cares about maintenance tools: utility tools don’t build communities, and nobody posts about running a disk cleanup. But people care deeply about tools that respect their time and make complex things simple for them to use. The MacPaw team listens to our community and implements many of the features they ask for, because we know users can be fans too, and they should shape how our products work.
  • Assuming that designers shouldn’t waste pixels on personality: you need to hide complexity and show minimal UI. Utility software should look neutral, technical, and forgettable.

But when software hides the system, people lose trust in it.

Design always starts with function — function shapes form. But if that function can’t be made completely invisible and people still have to interact with it, it inevitably becomes part of their experience. In that case, people expect it not just to work, but to match their environment, influence their mood, and contribute to their overall experience.

A good example is a watch. Its core function is simple: show the time. But because a watch occupies physical space in a person’s world, you want more from it than just functionality. It needs to play an aesthetic role and complement the environment.

“The Maintenance Layer Is A Behavioral Problem, Not Just A UX One.”

The user experience in utility software matters more than the industry tends to admit. In utility software, experience is not something added on top of function. It emerges from how the function is structured, explained, and interacted with. If you think you can design the most functional app on the market without considering how users understand and experience the process, you’re missing an opportunity to build a relationship with that user.

Part of that ignored UX element is a behavioral problem: users don’t avoid utility software because using it is hard, but instead because it produces no positive emotional signal at any point. The problem is rarely complex. It’s the absence of meaningful interaction during the process of using the app.

Another issue is focusing solely on function. The aesthetic-usability effect shows us clearly that if something looks better, it feels better — ATM screens in a 1995 study were judged easier to use if the screen layout was more attractive. Even something as purely functional as an ATM screen display needs attention to how the function is structured, presented, and perceived.

And then there’s the memory problem. People remember the emotional peak and the ending of an experience, not the average. A completed process that ends with a clear “done” is remembered more positively than one that just fades out, even if the end task is completed successfully in both cases. System tools rarely intentionally design the ending of an interaction — they just stop running.

“Thoughtful System Design Can Transform Maintenance From A Technical Chore Into A Seamless User Experience.”

What does emotional design actually mean, then, in utility UX? Here are three principles the MacPaw team follows to design its products against the category norm.

Translating system complexity into human language

Maintenance tools deal with storage, task management, and background processes. Good design explains what’s happening, avoids system jargon, and communicates outcomes clearly.

Linear’s game-changing move that illustrates this principle was agreeing on straightforward units of work, like projects and teams, that any new user can immediately understand. That helps them spend less time ramping up and more time building.

Make the process clear and show progress

System tools run complex processes. Design should show progress, impact, and system change to create trust and control.

Vercel’s deployment infrastructure is an excellent example here. When you trigger a build, the browser tab favicon changes — a spinner while building, a green checkmark when done, a red X if it fails. It’s ruthlessly functional, not visual or warm, but it’s emotionally intelligent: it exists purely to reduce the low-level anxiety of waiting for a build to finish.

Design the moment of completion

Maintenance tasks often end quietly. But completion is the emotional payoff. Design should emphasize clarity of results, a sense of resolution, and visible improvement so users remember a positive and distinct ending.

Take the new CleanMyMac by MacPaw after its 2024 major update. Unlike the maintenance utility category norm, CleanMyMac uses visual language, including color, depth, motion, icons, and 3D illustrations, to shift the focus from diagnosing problems to showing progress: space cleared, threats removed, time saved. Instead of confronting the user with what's wrong, the interface closes with a picture of a machine that's already working better.

The task is the same, but the ending tells a different story, giving the user a picture of a machine that's already working better.

“Even if you don’t care about emotional design as a principle, the change is coming anyway.”

The market is forcing this issue even for those who don’t find the argument I’ve made here compelling.

That’s partly generational — designers and users who grew up with Linear, Figma, and Notion have a completely different baseline for the tools they use. Good software is not a happy accident for them, but a given. That generation is now the primary audience for maintenance software, and so the old “it’s fine, it’s just a utility” excuse doesn’t work philosophically or commercially. Just like Dyson and Method changed how entire product categories approached design, the current state of utility software is shifting for good.

And digital fatigue is the current cultural state. The resurgence of vinyl records, film cameras, and dumbphones is not merely nostalgia, but a signal that the emotional relationship between people and their tools is changing.

The question has shifted from whether your utility software should feel better to use to whether it can afford not to.

Kategorier: Amerikanska

Designing Stable Interfaces For Streaming Content

Smashingmagazine - fre, 05/01/2026 - 10:00

More interfaces now render while the response is still being generated. The UI begins in one state, then updates as more data comes in. You see this in chat apps, logs, transcription tools, and other real-time systems.

The tricky part is that the interface is not in a fixed state; it keeps changing as new content comes in. It grows where lines become longer and new blocks appear. Something that was just below the screen can suddenly move, and the user’s scroll position becomes harder to manage. Parts of the UI might even be incomplete while the user is already interacting with it.

In this article, we’ll take a simple interface and make it handle this properly. We’ll look at how to keep things stable, manage scrolling, and render partial content without breaking the reading experience.

What Does A Streaming UI Actually Look Like?

I’ve built three demos that stream content in different ways: a chat bubble, a log feed, and a transcription view. They look different on the surface, but they all run into the same three problems.

The first is scroll. When content is streaming in, most interfaces keep the viewport pinned to the bottom. That works if you are just watching, but the moment you scroll up to read something, the page snaps back down. You did not ask for that. The interface decided for you, and now you’re fighting it instead of reading.

The second is layout shift. Streaming content means containers are constantly growing, and as they do, everything below shifts downward. A button you were about to click is no longer where it was. A line you were reading has moved. The page is not broken; it is just that nothing stays still long enough to interact with comfortably.

The third is render frequency. Browsers paint the screen around 60 times per second, but streams can arrive much faster than that. This means the DOM, which is the browser’s internal representation of everything on the page, ends up being updated for frames the user will never actually see. Each update still costs something, and that cost adds up quietly until performance starts to slip.

As you go through each demo, pay attention to where things start feeling off. That small moment of friction when the interface starts getting in your way. This is exactly what we are here to fix.

Example 1: Streaming AI Chat Responses

This is the most familiar case. You click Stream, and the message starts growing token by token, just like a typical AI chat interface.

Here’s what I want you to try:

  • Click the Stream button.
  • Try scrolling upwards while the message is streaming.
  • Increase the speed (to something like 10ms).

You will notice something subtle but important: the UI keeps trying to pull you back down. Basically, it is making a decision for you about where your attention should be.

That’s one example. Let’s look at another.

Example 2: Live Processing In A Log Viewer

This example looks different on the surface, but the problem is actually very similar to the first example. Rather than a message that gets longer over time, new lines are appended continuously, like a terminal or a log stream.

The interesting part here is the tail toggle. It makes the trade-off between interaction and stable interfaces very clear:

Again, here is what I want you to try:

  • Click the Start button.
  • Allow the logs to stream past the container’s height.
  • Scroll up to the beginning.
  • Stop the stream and disable the “tail” option.

Notice that, when tail is enabled, the UI follows the new content. But you’re unable to scroll up and stay in place. Instead, you need to stop the stream or enable “tail” to explore the content.

Example 3: Dashboard Displaying Real-Time Metrics

In this case, the UI updates in place:

  • Numbers change,
  • Charts shift,
  • Values refresh continuously.

There is no scroll tension this time, but a different issue shows up. That’s what we’ll get into next.

Why The UI Feels Unstable And How To Fix It

If you tried the chat demo and scrolled upward while the responses were coming in, you may have spotted the first issue right away: the UI keeps pulling you back down to the latest streamed content as it updates. This takes you out of context and never allows you the time to fully digest the content once it has passed.

We see that exact same issue in the second example, the log viewer. Without the tail toggle, the streamed content overrides your scroll position.

These aren’t bugs in the traditional sense that they produce code errors; rather, they are accessibility issues that affect all users. That said, they can be fixed and prevented with careful UX considerations as you plan and test your work.

Ensure Predictable Scroll Behavior

This is the goal:

  • Enable auto-scrolling when detecting that the user is at the bottom of the stream.
  • Stop auto-scrolling when the user has scrolled upwards.
  • Resume auto-scrolling if the user scrolls back to the bottom of the stream.

To do that, we need to know whether the user has intentionally moved away from the bottom, which we can assume is true when the scroll position is manually changed. We can track that behavior with a flag.

let userScrolled = false; chatEl.addEventListener('scroll', () => { const gap = chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight; userScrolled = gap > 60; });

That 60px threshold matters. Without it, tiny layout changes (like a new line) would briefly create a gap and break auto-scroll, even if the user didn’t actually scroll.

Now let’s make sure that we enable auto-scrolling only when the user’s scroll position is equal to the stream’s scroll height, i.e., the user is at the bottom of the stream:

function autoScroll() { if (!userScrolled) { chatEl.scrollTop = chatEl.scrollHeight; } }

One small thing that’s easy to miss: we need to reset userScrolled once a new stream begins. Otherwise, one scroll from a previous message can silently disable auto-scroll for the next one.

Solidify Layout Stability

We saw this in the first example as well. As new content streams in, the layout jumps, or shifts, taking you out of your current context. To be specific about what’s shifting: it’s not the page layout in a broad sense, it’s the content directly below the chat bubble.

There’s also a subtler artifact worth calling out before we look at the code: cursor flicker. Because we’re wiping innerHTML and recreating every element on every tick, the cursor is being destroyed and re-added constantly, up to 80 times per second at fast speeds.

At normal speed, it’s easy to miss, but slow the slider down to around 30ms, and you’ll see a faint but persistent flicker at the end of the text. Once we fix the rebuild pattern, the flicker disappears entirely.

None of these changes is a big effort on its own. But once they are in place, the interface stops reacting blindly to every update. It becomes easier to read, easier to control, and a lot less distracting, even though the content is still coming in continuously.

There are even more considerations to take into account for ensuring a stable, predictable, and good user experience. For example, what happens if the stream is canceled mid-flow? And what can we do to ensure that user preferences are respected for things like reduced motion, keyboard navigation, and screen reader accessibility? Let’s get into those next.

Handling Interrupted Streams

Most streaming interfaces include a way to stop or cancel the stream. We saw that in the demos. But stopping often leaves the UI in an awkward state. The cursor might keep blinking, buttons don’t update, and the message just freezes mid-stream with no clear indication that it didn’t finish.

The problem is that the stop is usually wired to do one thing: cancel the timer. That’s not enough. You also need to (1) clear the pending buffer, (2) remove the cursor, (3) mark the response as incomplete, and (4) reset the buttons. Here’s how we accomplish those.

1. Stop The Stream Cleanly

Here’s what stopStream needs to do, in order:

  1. Cancel the timer and flip the isStreaming flag so no more ticks run.
  2. Clear the requestAnimationFrame (RAF) buffer so nothing still queued gets written on the next frame.
function stopStream() { clearTimeout(streamTimer); isStreaming = false; pending = ''; rafQueued = false; }

Clearing the pending property matters because there might be characters buffered from the last stream instance that haven’t been flushed yet. If you don’t clear it, the next requestAnimationFrame fires, drains the buffer, and writes those characters to the DOM after the stream has officially stopped.

Now we move on to removing the cursor by calling markStopped on the bubble:

if (cursorEl && cursorEl.parentNode) cursorEl.remove(); markStopped(aiBubble); stopBtn.style.display = 'none'; retryBtn.style.display = ''; playBtn.style.display = ''; setStatus('Stopped', 'stopped'); chat.removeEventListener('scroll', onScroll); }

The cursorEl.parentNode check is there because stopStream is also called internally when a new message fires mid-stream, at which point the cursor might already be gone. Calling remove() on a detached node throws, so we check first.

markStopped appends a small label to the bottom of the bubble so the user knows the response didn’t finish:

function markStopped(bubble) { if (!bubble) return; bubble.classList.add('stopped'); const label = document.createElement('span'); label.className = 'stopped-label'; label.textContent = 'response stopped'; bubble.appendChild(label); }

The null check on bubble handles the edge case where stop fires before the AI message element has been initialized, which can happen if the user clicks stop during the 300ms delay before the bubble appears.

Provide A Retry Option

If the stream simply stops — perhaps due to a network issue or some other unexpected error — we ought to provide the user with a path to re-attempt the stream. What that basically means is preventing the UI from doing the expensive work needed to scroll back up to the top, re-read the prompt, and retype it. With a retry option, the user only needs to click a button, and the stream restarts from the current position.

To make that work, we need to hold onto the question when the stream starts:

let lastQuestion = ''; function startStream(question, answer) { lastQuestion = question; // rest of setup... }

Then, when the retry attempt runs, we reset everything and start fresh:

function retryStream() { if (currentMsgEl && currentMsgEl.parentNode) { currentMsgEl.remove(); } charIndex = 0; userScrolled = false; pending = ''; rafQueued = false; isStreaming = true; retryBtn.style.display = 'none'; stopBtn.style.display = ''; setStatus('Streaming...', 'streaming'); chat.addEventListener('scroll', onScroll, { passive: true }); setTimeout(() => { initAIMsg(); tick(lastAnswer); }, 200); }

The reset is critical. Every piece of state needs to go back to its initial value, just like a brand new stream.

Note: We remove the entire message row (currentMsgEl), not just the bubble. If only the bubble is removed, the layout wrapper and avatar remain persistent and break the structure.

Send A New Message Mid-Stream

There’s one more edge case that’s easy to miss. If the user sends a new message while a stream is still running, you end up with two loops writing to the DOM at the same time. The result is messy, and characters from different responses get mixed together.

Here’s what to do: stop the current stream before starting a new one.

function startStream(question, answer) { if (isStreaming) { clearTimeout(streamTimer); isStreaming = false; pending = ''; rafQueued = false; if (cursorEl && cursorEl.parentNode) cursorEl.remove(); chat.removeEventListener('scroll', onScroll); } // now reset and start fresh charIndex = 0; userScrolled = false; isStreaming = true; lastQuestion = question; // ... }

Here, we inline the cleanup rather than calling stopStream directly because stopStream also calls markStopped and resets the buttons. The next demo has all three behaviors wired up. You can start a stream, hit “Stop” mid-stream, and the cursor disappears, the “response stopped” label appears, and a “Retry” buttons displayed.

Accessibility

Streaming interfaces are often built and tested with a mouse, so they may feel just fine in a browser, but break down in other situations that may not have been considered, like whether a screen reader announces new content at all. Or navigating with a keyboard might get stuck or lose focus as things update. And, of course, moving text can be uncomfortable — or even disabling — for those with motion sensitivities.

The good part is that you do not need to rebuild everything to accommodate these things; they can be fixed with solutions that sit on top of what is already there.

Accommodating Assistive Technology With Live Regions

Screen readers don’t automatically announce content that shows up on its own. They usually read things when the user moves to them. So, in a streaming UI, where text builds up over time, nothing gets announced. The content is there, but the user doesn’t hear anything.

The fix is aria-live. It tells the browser to watch a container and announce updates as they happen, without the user needing to move focus.

<div id="chat" role="log" aria-live="polite" aria-atomic="false" aria-label="Chat messages" ></div>
  • role="log" tells assistive tech this is a stream of updates, like a running transcript. Some tools handle this automatically, but it’s safer to be explicit so behavior stays consistent.
  • aria-atomic="false" makes sure only the new content is announced. Without it, some screen readers try to read the whole message again on every update, which quickly becomes unusable.
  • aria-live="polite" queues updates instead of interrupting. Use assertive only for things that really need immediate attention, like errors.
Handling Incomplete States

Earlier, we inserted a “Response Stopped” label to the message when the stream stops mid-stream. Visually, that’s enough. But for a screen reader, that change needs to be announced.

Since the message is inside a live region with aria-live="polite", the label will be automatically announced as new content when it’s added to the DOM. The live region already handles the announcement, so no additional ARIA is needed on the label itself.

The Retry button that appears next also needs context. If a screen reader simply says “Retry, button,” it’s not clear what action that refers to. You can fix that by adding an aria-label that includes the original question:

retryBtn.setAttribute( 'aria-label', `Retry: ${lastQuestion.slice(0, 60)}` );

What you can do here is to set this label when the button appears, not on page load:

retryBtn.style.display = 'inline-block'; retryBtn.setAttribute( 'aria-label', `Retry: ${lastQuestion.slice(0, 60)}` );

We also call retryBtn.focus() after stopping. That way, keyboard users don’t have to Tab around with the keyboard to find the next action.

Testing with assistive technology: Don’t rely on assumptions about how screen readers announce this. Test with actual tools like NVDA (Windows), JAWS (Windows), or VoiceOver (Mac/iOS). Browser DevTools can show you what’s exposed in the accessibility tree, but they can’t tell you how the content sounds. A real screen reader will reveal whether the announcement is happening at the right time and in the right way.

Account For Keyboard Navigation

The controls need to work with the keyboard while the UI is live, so the Stop button has to be reachable. For someone not using a mouse, Tab + Enter is the only way to cancel a running stream.

Using display: none is fine for hiding buttons; it removes them from the tab order. The problem is using things like opacity: 0 or visibility: hidden. Those hide elements visually, but they can still receive focus, so users end up tabbing onto something they can’t see.

Use :focus-visible so the focus ring shows up for keyboard navigation, but not for mouse clicks:

btn:focus-visible { outline: 2px solid #1d9e75; outline-offset: 2px; }

The cursor inside the message should have aria-hidden="true". It’s just visual. Without that, some screen readers try to read it as text, which gets distracting.

Motion Sensitivity

The typewriter effect we see in practically every AI interface produces constant motion. As we’ve already discussed, certain amounts of motion can be disabling. Thankfully, browsers expose prefers-reduced-motion, which detects a user’s motion preferences at the operating system level.

For streaming, the best approach is simple: skip the animation and render the full response at once. The content stays the same, only without the motion.

const reducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; if (reducedMotion) { initAIMsg(); for (const char of text) appendChar(char); if (cursorEl && cursorEl.parentNode) cursorEl.remove(); done(); return; } tick(text); // normal animation

In CSS, the cursor blink also needs to stop. Despite being a minor detail, a blinking cursor element counts as flashing content.

@media (prefers-reduced-motion: reduce) { .cursor { animation: none; opacity: 1; } }

There we go! The demo below puts everything from this article together, so you can see how these patterns work in practice. It also includes a reduced motion toggle, so you can test the instant render version easily.

Conclusion

Streaming itself is mostly solved. Getting data from the server to the client is not the hard part anymore. What breaks is the UI on top of it.

When content updates continuously, small things start to matter, like scroll behavior, layout stability, render timing, and how the interface responds to user actions. If those aren’t handled well, the UI feels unstable and hard to use.

The patterns in this article fix that by:

  • Keeping scroll position under the user’s control,
  • Updating only what has changed,
  • Batching renders per frame,
  • Handling stop and retry actions, and
  • Making the interface accessible.

You don’t need all of these every time. But when streaming is involved, these are the places things usually go wrong.

Further Reading
Kategorier: Amerikanska

A Fresh View In May (2026 Wallpapers Edition)

Smashingmagazine - tors, 04/30/2026 - 13:00

May has a way of sneaking in with longer days, softer light, and that first real hint of summer in the air. It’s the season of fresh ideas and just enough energy to start something new, or finally pick up something you’ve been putting off. And sometimes, all it takes to spark that little bit of inspiration is a fresh view… even if it’s just on your desktop.

That’s where our monthly wallpapers series comes in. For the past 15 years, artists and designers from around the world have been contributing their designs to celebrate each new month. This May is no exception. Created with care and a unique personal touch, every wallpaper in this collection comes in a variety of screen resolutions and can be downloaded for free. A huge thank-you to everyone who got creative — this post wouldn’t be possible without your wonderful support!

If you too would like to get featured in one of our upcoming wallpapers posts, please don’t hesitate to join in. We can’t wait to see what you’ll come up with! Happy May!

  • You can click on every image to see a larger preview.
  • We respect and carefully consider the ideas and motivation behind each and every artist’s work. This is why we give all artists the full freedom to explore their creativity and express emotions and experience through their works. This is also why the themes of the wallpapers weren’t anyhow influenced by us but rather designed from scratch by the artists themselves.
Happily Invisible Online

Designed by Ricardo Gimenes from Spain.

Where Every Sip Tells A Secret

“A quiet ritual, a shared moment, a pause in the rush — tea invites you to slow down and discover warmth in the smallest details. Let each cup unfold its own little story.” — Designed by PopArt Studio from Novi Sad, Serbia.

Just A Style Thing

Designed by Ricardo Gimenes from Spain.

Next Bloom

“A small bee with a big garden plan checks each flower on her list and looks for the next bloom to visit.” — Designed by Ginger IT Solutions from Serbia.

No Play Jack

“Summer is getting closer, but we’re reminded of a more wintry and eerie landscape, like that of ‘The Shining.’ A truly great film, proving that you don’t need much, but it needs to be used well to create suspense and terror.” — Designed by Veronica Valenzuela from Spain.

Buddha Purnima

“Buddha Purnima, falling on May 1st, is the most sacred Buddhist festival commemorating the birth, enlightenment, and passing of Gautama Buddha. It is observed on the full moon day of the Vaisakha month, symbolizing spiritual liberation and the triumph of peace. The day serves as a global reminder of his core teachings: non-violence, compassion, and the path to ending suffering.” — Designed by V D Photography from Surat, Gujarat, India.

Hello May

“The longing for warmth, flowers in bloom, and new beginnings is finally over as we welcome the month of May. From celebrating nature on the days of turtles and birds to marking the days of our favorite wine and macarons, the historical celebrations of the International Workers’ Day, Cinco de Mayo, and Victory Day, to the unforgettable ‘May the Fourth be with you’, May is a time of celebration — so make every May day count!” — Designed by PopArt Studio from Serbia.

Add Color To Your Life!

“This month is dedicated to flowers, to join us and brighten our days giving a little more color to our daily life.” — Designed by Verónica Valenzuela Jimenez from Spain.

Ladies And Gentlemen

Designed by Ricardo Gimenes from Spain.

Poppies Paradise

Designed by Nathalie Ouederni from France.

Understand Yourself

“Sunsets in May are the best way to understand who you are and where you are heading. Let’s think more!” — Designed by Igor Izhik from Canada.

Navigating The Amazon

“We are in May, the spring month par excellence, and we celebrate it in the Amazon jungle.” — Designed by Veronica Valenzuela Jimenez from Spain.

ARRR2-D2

Designed by Ricardo Gimenes from Spain.

Lake Deck

“I wanted to make a big painterly vista with some mountains and a deck and such.” — Designed by Mike Healy from Australia.

Today, Yesterday, Or Tomorrow

Designed by Alma Hoffmann from the United States.

The Monolith

Designed by Ricardo Gimenes from Spain.

Tentacles

Designed by Julie Lapointe from Canada.

Geo

Designed by Amanda Focht from the United States.

Make A Wish

Designed by Julia Versinina from Chicago, USA.

Bat Traffic

Designed by Ricardo Gimenes from Spain.

Blooming May

“In spring, especially in May, we all want bright colors and lightness, which were not there in winter.” — Designed by MasterBundles from Ukraine.

Enjoy May!

“Springtime, especially May, is my favorite time of the year. And I like popsicles — so it’s obvious isn’t it?” — Designed by Steffen Weiß from Germany.

Stone Dahlias

Designed by Rachel Hines from the United States.

Spring Gracefulness

“We don’t usually count the breaths we take, but observing nature in May, we can’t count our breaths being taken away.” — Designed by Ana Masnikosa from Belgrade, Serbia.

Sweet Lily Of The Valley

“The ‘lily of the valley’ came earlier this year. In France, we celebrate the month of May with this plant.” — Designed by Philippe Brouard from France.

April Showers Bring Magnolia Flowers

“April and May are usually when everything starts to bloom, especially the magnolia trees. I live in an area where there are many and when the wind blows, the petals make it look like snow is falling.” — Designed by Sarah Masucci from the United States.

Always Seek Knowledge

“‘As knowledge increases, wonder deepens.’ (Charles Morgan) So I tried to create an illustration based on this.” — Designed by Bisakha Datta from India.

May Your May Be Magnificent

“May should be as bright and colorful as this calendar! That’s why our designers chose these juicy colors.” — Designed by MasterBundles from Ukraine.

Celestial Longitude Of 45°

“Lixia is the 7th solar term according to the traditional East Asian calendars, which divide a year into 24 solar terms. It signifies the beginning of summer in East Asian cultures. Usually begins around May 5 and ends around May 21.” — Designed by Hong, Zi-Cing from Taiwan.

Power

Designed by Elise Vanoorbeek from Belgium.

Rainy Days

“Winter is nearly here in my part of the world and I think rainy days should be spent at home with a good book!” — Designed by Tazi Design from Australia.

Birds Of May

“Inspired by a little-known ‘holiday’ on May 4th known as ‘Bird Day’. It is the first holiday in the United States celebrating birds. Hurray for birds!” — Designed by Clarity Creative Group from Orlando, FL.

Magical Sunset

“I designed Magical Sunset as a friendly reminder to take a moment and enjoy life around you. Each sunset and sunrise brings a new day for greatness and a little magic.” — Designed by Carolyn Warcup from the United States.

All Is Possible In May

“Edwin Way Teale once said that ‘[t]he world’s favorite season is the spring. All things seem possible in May.’ Now that the entire nature is clothed with grass and branches full of blossoms that will grow into fruit, we cannot help going out and enjoying every scent, every sound, every joyful movement of nature’s creatures. Make this May the best so far!” — Designed by PopArt Studio from Serbia.

Get Featured Next Month

Feeling inspired? We’ll publish the June wallpapers on May 31, so if you’d like to be part of the collection, please don’t hesitate to submit your design. We are already looking forward to it!

Kategorier: Amerikanska

Sidor

Prenumerera på Sten Sandvall innehållssamlare - Amerikanska